Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,12 @@ gem "turbo-rails", "~> 2.0.20"

gem "httpx", "~> 1.8.1"

# Provider adapters and a model metadata registry for the AI features. Used as
# transport and as a source of published model capabilities; what a given
# connection actually offers is tracked per connection, never in RubyLLM's
# application-wide registry.
gem "ruby_llm", "~> 1.16"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to drag in a deprecation warning.

!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0. Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/

Please make sure it's cleaned up -- looks like it just needs a little configuration tweak.


# Brings actual deep-freezing to most ruby objects
gem "ice_nine"

Expand Down
25 changes: 25 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ GEM
escape_utils (1.3.0)
et-orbi (1.4.0)
tzinfo
event_stream_parser (1.0.0)
eventmachine (1.2.7)
eventmachine_httpserver (0.2.1)
excon (1.5.0)
Expand All @@ -586,8 +587,12 @@ GEM
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
ferrum (0.17.2)
addressable (~> 2.5)
base64 (~> 0.2)
Expand Down Expand Up @@ -848,6 +853,7 @@ GEM
prism (~> 1.5)
msgpack (1.8.4)
multi_json (1.21.1)
multipart-post (2.4.1)
mustermann (4.0.0)
net-http (0.9.1)
uri (>= 0.11.1)
Expand Down Expand Up @@ -1390,6 +1396,17 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby_llm (1.16.0)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.4.0)
rubytree (2.2.1)
json (~> 2.0, >= 2.19.9)
rubyzip (2.4.1)
Expand Down Expand Up @@ -1746,6 +1763,7 @@ DEPENDENCIES
ruby-duration (~> 3.2.0)
ruby-prof
ruby-progressbar (~> 1.13.0)
ruby_llm (~> 1.16)
rubytree (~> 2.2.1)
sanitize (~> 7.0.0)
scimitar (~> 2.13)
Expand Down Expand Up @@ -1842,6 +1860,7 @@ CHECKSUMS
browser (6.2.0) sha256=281d5295788825c9396427c292c2d2be0a5c91875c93c390fde6e5d61a5ace2d
budgets (1.0.0)
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler (4.0.16) sha256=d6ca5dd440c24f9abce9844cf44cc8e18c6a553de65a47efb4544137af92c47d
byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
capybara-screenshot (1.0.27) sha256=afa1896cc23df77be1774e8d3b3ce3953bf060aeaa04ff87607b5daf689174f2
Expand Down Expand Up @@ -1909,14 +1928,17 @@ CHECKSUMS
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
escape_utils (1.3.0) sha256=dffb7010922880ace6ceed642156c64e2a64620f27e0849f43bc4f68fd3c2c09
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
eventmachine_httpserver (0.2.1) sha256=5db5e8a23754204d43592e5fcc2160457c57c870babe6307c4e61fc95019b809
excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50
factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89
factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
ferrum (0.17.2) sha256=2c2540a850b211a46f4d81de21bfd62048f507e4c327d1807225c3823c17e6ee
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
Expand Down Expand Up @@ -2016,6 +2038,7 @@ CHECKSUMS
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mustermann (4.0.0) sha256=91f67411bb208d1d93c41e6128cb3b0f8ddd9ec7c45966f1007e1c43c08040d7
my_page (1.0.0)
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
Expand Down Expand Up @@ -2238,6 +2261,8 @@ CHECKSUMS
ruby-rc4 (0.1.5) sha256=00cc40a39d20b53f5459e7ea006a92cf584e9bc275e2a6f7aa1515510e896c03
ruby-saml (1.18.1) sha256=1b0e7a44aef150b4197955f5e015d593672e242cfdc5d06aa7554ec2350b9107
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
ruby_llm (1.16.0) sha256=26bd5310cf2ce55f74a60f8aae0b0d0327b586ff4532c84828103c3b2b905a18
ruby_llm-schema (0.4.0) sha256=e930f5a5316f9301bff3fb7fe572e44727d05bb8e50621001bbb49a47d63b8da
rubytree (2.2.1) sha256=237f930795479fad48d8860cc8ec057534864b7da92dbee6a3c81470ad00c5c8
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
safety_net_attestation (0.5.0) sha256=c8cd01dd550dbe8553862918af6355a04672db11d218ec96104ce3955293f2aa
Expand Down
96 changes: 96 additions & 0 deletions app/models/llm_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

# The connection to an OpenAI-API-compatible LLM server.
#
# Only a single connection is supported today. That is enforced by a validation
# rather than by the schema, so lifting the restriction later is a one-line change:
# every association is already scoped by +llm_connection_id+ and the STI +type+
# column is in place.
Comment on lines +33 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we already have plans to support multiple connections? If they are definitely coming, we might want to consider dropping that singleton approach, and just relying on a simple active flag in the database, capped by a single occurrence by a validation + index (for now).

class LlmConnection < ApplicationRecord
include Redmine::Ciphering

@thykel thykel Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 We don't usually do encryption at rest and the only other code paths that use this method are leftovers from Redmine. So, this doesn't look like something we want to lean into.

I'm gonna do a bit more skulking and then get back to you.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tangopium Let's drop the attribute encryption altogether please -- the current consensus is that we do not support it, for various good reasons.


SINGLETON_NAME = "default"

has_many :health_reports, as: :subject, dependent: :delete_all

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NobodysNightmare How do we feel about this one? Is that HealthReport framework reusable for arbitrary needs (such as LLM connection tracking)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh totally. I am happy if we start using it in more places, though it's good to be aware that more consumers exist, because it's a "naturally grown abstraction", not one that was designed before it was implemented.

I didn't look into this usage here, but if there is:

  • a health check/self test that's triggered by an admin
  • and the health check tests whether the connection to an external service is working the way we expect it to work
  • and maybe some of the tests can only be performed if others succeeded

then it sounds like a good fit to me.


validates :base_url, presence: true
validate :only_one_connection, on: :create

class << self
# The connection record, whether or not it has been persisted yet.
#
# Identifying attributes are left unset here and filled in by
# LlmConnections::SetAttributesService as system changes, so that they do not
# register as user-made changes to non-writable attributes.
def instance
first || new
end

# Cheap enough to call from a menu visibility lambda.
def enabled?
exists?(enabled: true)
end

# Whether LLM-backed features may run right now. This is the predicate
# sibling features gate on; see #77783.
def available?
OpenProject::FeatureDecisions.llm_connection_active? &&
enabled? &&
instance.configured?
end
Comment on lines +64 to +68

@thykel thykel Aug 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enabled field currently seems misplaced, especially if we consider extending this table with more connections later.

The OpenProject::FeatureDecisions toggles (reachable at admin/settings/experimental) are the pre-release "feature flags" -- we use such flag for a feature that's currently in development. Once it's finalized and ready to roll out to everyone, we generally just drop it (or force it to active).

The intent behind that enabled flag ("toggle all AI features") should probably live in the Setting framework instead -- you already introduce it in a later PR, but it's actually worth doing that at the beginning.

Then, I would suggest enabled to be reused as an "availability" flag for the LLM connection, once/if we remove that singleton restraint. Hence, for now, this would simply be true by default at all times.

end

def api_key
read_ciphered_attribute(:api_key)
end

def api_key=(value)
write_ciphered_attribute(:api_key, value)
end

# Deliberately does not consider +last_connected_at+: a connection provisioned
# from the environment is never probed, and must still count as configured.
def configured?
base_url.present?
end

def server_flavour
options["server_flavour"].presence&.to_sym
end

private

def only_one_connection
return unless self.class.where.not(id:).exists?

errors.add(:base, :singleton)
end
end
77 changes: 77 additions & 0 deletions app/services/llm/adapters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Llm
# Resolves a connection's api_format to the adapter that speaks it.
#
# There is no universal model-discovery standard, so each dialect needs its own
# translation: OpenAI-compatible servers answer GET /models with data[].id,
# Gemini uses /v1beta/models with richer metadata, Bedrock needs AWS signing
# rather than a bearer token, and Azure indirects through deployment names.
#
# Only the OpenAI adapter is implemented. The seam exists so that adding one is
# a new class rather than a migration.
module Adapters
class UnsupportedFormat < StandardError; end

# Formats an administrator can choose. "openai" covers OpenAI itself and the
# great majority of gateways and self-hosted inference servers; the rest are
# RubyLLM providers whose model lists come from its registry.
OPENAI_COMPATIBLE = "openai"

FORMATS = %w[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is currently more or less a mirror of a similar listing in RubyLLM::Provider.providers.keys.

I guess it makes sense to have it here to be more explicit. But in that case, I think we need a way to detect any drift after gem updates. Something like:

it "offers only providers the gem still ships" do
  expect(Llm::Adapters::FORMATS).to all(be_in(RubyLLM::Provider.providers.keys.map(&:to_s)))
end

openai
anthropic
gemini
mistral
deepseek
openrouter
perplexity
xai
ollama
gpustack
azure
bedrock
vertexai
].freeze

def self.for(connection)
format = connection.api_format.to_s
raise UnsupportedFormat, format unless FORMATS.include?(format)

if format == OPENAI_COMPATIBLE
# Queried live, so the list is what this endpoint actually serves.
Openai.new(connection)
else
RegistryBacked.new(connection)
end
end
end
end
85 changes: 85 additions & 0 deletions app/services/llm/adapters/openai.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Llm
module Adapters
# Servers speaking the OpenAI API: OpenAI itself, and the great majority of
# gateways and self-hosted inference servers.
class Openai
def initialize(connection)
@connection = connection
end

# Normalised model cards.
#
# The raw card is kept alongside, because the fields worth having are the
# non-standard ones: the OpenAI schema itself carries only id, object,
# created and owned_by, while vLLM adds max_model_len -- the operator's
# actual --max-model-len, and the only trustworthy context window for this
# deployment.
#
# @return [Array<Hash>] cards with :id and :raw
def models
@models ||= Array(client.models["data"]).filter_map do |card|
id = card["id"]
next if id.blank?

{ id:, raw: card }
end
end

def embeddings(model_id:, input:)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a dead method.

client.embeddings(model: model_id, input:)
end

# Which server we are talking to decides which non-standard metadata is
# worth reading later. +owned_by+ is the documented hint; the structural
# fallback catches an operator who overrode it.
def server_flavour
cards = models
owner = cards.first&.dig(:raw, "owned_by").to_s.downcase

return owner if %w[vllm sglang llamacpp openai].include?(owner)

cards.any? { |card| card[:raw].key?("max_model_len") || card[:raw].key?("root") } ? "vllm" : "unknown"
end

private

attr_reader :connection

def client
@client ||= Llm::Client.new(base_url: connection.base_url,
api_key: connection.api_key,
headers: connection.custom_headers)
end
end
end
end
Loading
Loading