From da7c284f841ff551db025aef54ea1a9f7c347fec Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Sat, 22 Aug 2026 18:12:18 +0200 Subject: [PATCH] [AI-3] Add the LLM connection record and discovery transport The persistence and transport layer for connecting OpenProject to an LLM provider, with no user interface yet. Everything sits behind the new llm_connection feature flag, which is off in production, so this changes nothing for existing instances until the flag is flipped. LlmConnection stores the endpoint: API format, base URL, API key and the designated default models. The key is ciphered through Redmine::Ciphering, matching LdapAuthSource; note this is a no-op unless database_cipher_key is configured. Only one connection is supported, enforced by a validation rather than the schema, so lifting the restriction later needs no migration. Discovery and inference speak through two seams. Llm::Adapters resolves the api_format to a discovery strategy: OpenAI-compatible endpoints are queried live, so the list is what that endpoint actually serves, while the formats RubyLLM knows discover from its registry, which is a published catalogue rather than a live query. Llm::Session wraps RubyLLM for inference with per-connection isolated configuration, and Llm::Errors is the one error taxonomy every caller maps from -- response bodies never surface in messages, because gateways echo credentials and internal hostnames in error payloads. Llm::Client remains the plain-HTTP path for OpenAI-compatible servers. Two of its lessons are worth keeping: transport failures are HTTPX::ErrorResponse instances while a 4xx also populates #error, so the response class is the discriminator; and bodies are parsed directly rather than through #json, which insists on a content type self-hosted proxies do not reliably set. Part 1 of the AI-3 stack. https://community.openproject.org/work_packages/66020 --- Gemfile | 6 + Gemfile.lock | 25 +++ app/models/llm_connection.rb | 96 +++++++++++ app/services/llm/adapters.rb | 77 +++++++++ app/services/llm/adapters/openai.rb | 85 +++++++++ app/services/llm/adapters/registry_backed.rb | 68 ++++++++ app/services/llm/client.rb | 148 ++++++++++++++++ app/services/llm/errors.rb | 161 ++++++++++++++++++ app/services/llm/session.rb | 153 +++++++++++++++++ app/services/llm/structured_output.rb | 55 ++++++ config/initializers/feature_decisions.rb | 4 + .../20260811090000_create_llm_connections.rb | 61 +++++++ .../configuration/environment/README.md | 1 + .../patches/ruby_llm_provider_headers.rb | 74 ++++++++ spec/factories/llm_connection_factory.rb | 43 +++++ .../patches/ruby_llm_provider_headers_spec.rb | 84 +++++++++ spec/services/llm/errors_spec.rb | 145 ++++++++++++++++ spec/services/llm/session_spec.rb | 132 ++++++++++++++ spec/services/llm/structured_output_spec.rb | 58 +++++++ spec/support/llm_server_helpers.rb | 135 +++++++++++++++ 20 files changed, 1611 insertions(+) create mode 100644 app/models/llm_connection.rb create mode 100644 app/services/llm/adapters.rb create mode 100644 app/services/llm/adapters/openai.rb create mode 100644 app/services/llm/adapters/registry_backed.rb create mode 100644 app/services/llm/client.rb create mode 100644 app/services/llm/errors.rb create mode 100644 app/services/llm/session.rb create mode 100644 app/services/llm/structured_output.rb create mode 100644 db/migrate/20260811090000_create_llm_connections.rb create mode 100644 lib/open_project/patches/ruby_llm_provider_headers.rb create mode 100644 spec/factories/llm_connection_factory.rb create mode 100644 spec/lib/open_project/patches/ruby_llm_provider_headers_spec.rb create mode 100644 spec/services/llm/errors_spec.rb create mode 100644 spec/services/llm/session_spec.rb create mode 100644 spec/services/llm/structured_output_spec.rb create mode 100644 spec/support/llm_server_helpers.rb diff --git a/Gemfile b/Gemfile index 6f420fd300c7..206ed7efe6db 100644 --- a/Gemfile +++ b/Gemfile @@ -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" + # Brings actual deep-freezing to most ruby objects gem "ice_nine" diff --git a/Gemfile.lock b/Gemfile.lock index 56c6d1800c59..6b7503cd3349 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -1909,6 +1928,7 @@ 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 @@ -1916,7 +1936,9 @@ CHECKSUMS 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 @@ -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 @@ -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 diff --git a/app/models/llm_connection.rb b/app/models/llm_connection.rb new file mode 100644 index 000000000000..f3252ecca1f9 --- /dev/null +++ b/app/models/llm_connection.rb @@ -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. +class LlmConnection < ApplicationRecord + include Redmine::Ciphering + + SINGLETON_NAME = "default" + + has_many :health_reports, as: :subject, dependent: :delete_all + + 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 + 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 diff --git a/app/services/llm/adapters.rb b/app/services/llm/adapters.rb new file mode 100644 index 000000000000..f319abcfc1c6 --- /dev/null +++ b/app/services/llm/adapters.rb @@ -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[ + 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 diff --git a/app/services/llm/adapters/openai.rb b/app/services/llm/adapters/openai.rb new file mode 100644 index 000000000000..68da5bf06e2f --- /dev/null +++ b/app/services/llm/adapters/openai.rb @@ -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] 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:) + 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 diff --git a/app/services/llm/adapters/registry_backed.rb b/app/services/llm/adapters/registry_backed.rb new file mode 100644 index 000000000000..f551f878cef7 --- /dev/null +++ b/app/services/llm/adapters/registry_backed.rb @@ -0,0 +1,68 @@ +# 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 + # Discovery for providers that do not speak the OpenAI model-list API. + # + # Anthropic, Gemini, Bedrock and the rest each list models differently -- and + # some, like Bedrock, need request signing rather than a bearer token. Rather + # than implement each, the model list comes from RubyLLM's registry for that + # provider, which is a published catalogue rather than a live query. + # + # The consequence is worth stating: this list is what the provider offers in + # general, not what these particular credentials can reach. An administrator + # can add anything missing by hand. + class RegistryBacked + def initialize(connection) + @connection = connection + end + + def models + RubyLLM.models.by_provider(connection.api_format.to_sym).map do |info| + { + id: info.id, + display_name: info.name, + raw: { "context_window" => info.context_window, "owned_by" => connection.api_format } + } + end + rescue StandardError => e + raise Llm::Client::ApiError.new("Model registry lookup failed: #{e.class} #{e.message}", status: nil) + end + + # Nothing is queried, so there is no server to characterise. + def server_flavour = connection.api_format + + private + + attr_reader :connection + end + end +end diff --git a/app/services/llm/client.rb b/app/services/llm/client.rb new file mode 100644 index 000000000000..00641c991d48 --- /dev/null +++ b/app/services/llm/client.rb @@ -0,0 +1,148 @@ +# 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 + # A thin model-discovery client for an OpenAI-API-compatible server. + # + # Inference goes through Llm::Session and RubyLLM. Discovery deliberately + # stays here, for two reasons. RubyLLM's model parsing discards max_model_len + # and root, which are the only trustworthy statement of a self-hosted + # deployment's real context window, and it substitutes OpenAI's capability + # heuristics for arbitrary model ids. And this path runs on OpenProject.httpx, + # so it is covered by the SSRF filter that RubyLLM's own Faraday stack bypasses. + # + # The configured base URL is expected to already contain the API version segment + # (for example +https://example.com/v1+), matching what every provider documents + # and what every OpenAI client library expects. This client only appends the + # endpoint path. + # + # Errors come from Llm::Errors, which is shared with the RubyLLM-backed + # inference path. They are aliased here because every existing caller rescues + # them by their Llm::Client:: name, and both names refer to the same classes. + class Client + Error = Llm::Errors::Error + ConnectionError = Llm::Errors::ConnectionError + SsrfError = Llm::Errors::SsrfError + TimeoutError = Llm::Errors::TimeoutError + AuthenticationError = Llm::Errors::AuthenticationError + ApiError = Llm::Errors::ApiError + ParseError = Llm::Errors::ParseError + + # The global httpx defaults (connect 3s / read 3s / request 10s, all + # writable: false) are tuned for storage and webhook calls and are far too + # tight for an inference endpoint. Every call site must override them. + PROBE_TIMEOUT = { + timeout: { connect_timeout: 5, read_timeout: 15, request_timeout: 20 } + }.freeze + + def initialize(base_url:, api_key: nil, timeout: PROBE_TIMEOUT, headers: {}) + @base_url = base_url.to_s.chomp("/") + @api_key = api_key + @timeout = timeout + @headers = (headers || {}).compact_blank + end + + # The model catalogue as the server reports it, verbatim. + # + # Kept verbatim on purpose: vLLM adds +max_model_len+ and +root+ to each card, + # which is the only trustworthy source for a deployment's real context window. + # + # @return [Hash] the parsed +GET /models+ body + def models + body = get("/models") + + raise ParseError, "Response does not contain a model list" unless body.is_a?(Hash) && body["data"].is_a?(Array) + + body + end + + private + + attr_reader :base_url, :api_key, :timeout, :headers + + def get(path) + response = session.get(uri_for(path)) + # A connection-level failure yields an HTTPX::ErrorResponse. A real response + # carrying a 4xx/5xx is an ordinary HTTPX::Response — note that its #error + # is also populated (it delegates to #raise_for_status), so the response + # class, not #error, is what distinguishes the two. + handle_transport_error(response) if response.is_a?(HTTPX::ErrorResponse) + handle_status(response) + parse(response) + rescue OpenProject::ServerSideRequestForgeryError + # The SSRF plugin either raises directly or surfaces the error as + # response.error, which #handle_transport_error covers. + raise SsrfError, "Host resolves to a blocked address" + end + + def session + request = OpenProject.httpx.with(timeout) + request = request.with(headers:) if headers.any? + api_key.present? ? request.plugin(:auth).bearer_auth(api_key) : request + end + + def uri_for(path) + URI.parse("#{base_url}#{path}") + rescue URI::InvalidURIError + raise ConnectionError, "Invalid URL" + end + + def handle_transport_error(response) + error = response.error + + case error + when OpenProject::ServerSideRequestForgeryError + raise SsrfError, "Host resolves to a blocked address" + when HTTPX::TimeoutError + raise TimeoutError, "Request timed out" + else + raise ConnectionError, error.class.name + end + end + + def handle_status(response) + status = response.status + return if status.in?(200..299) + + raise AuthenticationError, "Server rejected the API key (#{status})" if status.in?([401, 403]) + + raise ApiError.new("Server responded with #{status}", status:) + end + + # Parses the body directly rather than through HTTPX's +#json+, which insists on a + # JSON content type. Self-hosted servers behind a proxy do not reliably set one, + # and a content-type mismatch is not a reason to call a working server broken. + def parse(response) + JSON.parse(response.body.to_s) + rescue JSON::ParserError + raise ParseError, "Response is not valid JSON" + end + end +end diff --git a/app/services/llm/errors.rb b/app/services/llm/errors.rb new file mode 100644 index 000000000000..31381c1a50a6 --- /dev/null +++ b/app/services/llm/errors.rb @@ -0,0 +1,161 @@ +# 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 + # The error taxonomy shared by every path that talks to an LLM server. + # + # Callers map these onto per-attribute contract errors or health check codes, + # so the set is deliberately small and describes *what the administrator has to + # fix*, not what the transport did. + # + # Response bodies never appear in a message. An OpenAI-compatible gateway + # routinely echoes the submitted Authorization header, upstream provider URLs + # and internal hostnames in its error payloads, and RubyLLM puts exactly that + # payload into its exception messages (+RubyLLM::Error#initialize+ falls back + # to +response.body+, and the middleware's own defaults come from parsing the + # body). #translate therefore discards the incoming message and logs it instead. + module Errors + class Error < StandardError; end + + # The server could not be reached at all. + class ConnectionError < Error; end + # The host resolved to an address blocked by the SSRF policy. + class SsrfError < ConnectionError; end + # The server took too long to answer. + class TimeoutError < ConnectionError; end + # The server answered, but rejected our credentials. + class AuthenticationError < Error; end + + # The server answered with an unexpected status. + class ApiError < Error + attr_reader :status + + def initialize(message, status: nil) + super(message) + @status = status + end + end + + # The prompt exceeded the model's context window. + class ContextLengthError < ApiError; end + # The server is throttling us. + class RateLimitedError < ApiError; end + + # The server answered successfully with something we cannot read. + class ParseError < Error; end + + # The connection cannot be expressed at all -- an api_format we cannot supply + # credentials for, or a model the provider refuses to accept. + class ConfigurationError < Error; end + + # A feature asked for a client before its model resolved. Carries the + # Llm::Runtime::Resolution status so a caller can tell "no server configured" + # apart from "this model cannot do that". + class NotReady < Error + attr_reader :status + + def initialize(status) + super("LLM is not ready to serve this feature (#{status})") + @status = status + end + end + + module_function + + # Maps anything RubyLLM or Faraday raised onto this taxonomy. + # + # @param error [StandardError] + # @return [Llm::Errors::Error] + def translate(error) + log(error) + + case error + when Llm::Errors::Error + error + when RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError + from_ruby_llm(error) + else + from_transport(error) + end + end + + def from_ruby_llm(error) + status = status_of(error) + + case error + when RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError + AuthenticationError.new("Server rejected the API key (#{status})") + when RubyLLM::ContextLengthExceededError + ContextLengthError.new("Prompt exceeds the model's context window", status:) + when RubyLLM::RateLimitError + RateLimitedError.new("Server is rate limiting requests", status:) + when RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError + ConfigurationError.new("The connection is not usable as configured") + else + # Covers BadRequestError, PaymentRequiredError, ServerError, + # ServiceUnavailableError, OverloadedError and the middleware's catch-all + # -- the last of which is how 404/405/501 arrive, i.e. a server that does + # not implement the endpoint we asked for. + ApiError.new("Server responded with #{status || 'an error'}", status:) + end + end + + def from_transport(error) + case error + when Faraday::TimeoutError, Timeout::Error, Errno::ETIMEDOUT + TimeoutError.new("Request timed out") + when Faraday::ConnectionFailed, Faraday::SSLError, SocketError, Errno::ECONNREFUSED + ConnectionError.new(error.class.name) + when JSON::ParserError + ParseError.new("Response is not valid JSON") + else + Error.new(error.class.name) + end + end + + # Runs the block, re-raising any RubyLLM or Faraday failure as an Llm::Errors. + def wrap + yield + rescue StandardError => e + raise translate(e) + end + + def status_of(error) + error.respond_to?(:response) ? error.response&.status : nil + end + + # The upstream message can embed whatever the server put into the response, + # up to and including an echoed credential or an internal URL, so only safe + # metadata is logged. + def log(error) + Rails.logger.info { "LLM request failed: #{error.class} (status: #{status_of(error) || 'none'})" } + end + end +end diff --git a/app/services/llm/session.rb b/app/services/llm/session.rb new file mode 100644 index 000000000000..2a672852f962 --- /dev/null +++ b/app/services/llm/session.rb @@ -0,0 +1,153 @@ +# 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 + # Turns an LlmConnection into something that can issue requests. + # + # Every inference request in OpenProject goes through here, so that the + # administrator's stored settings -- endpoint, credential, custom headers -- + # are applied in exactly one place. + # + # Note that RubyLLM builds its own Faraday connection over net_http and so + # does *not* pass through OpenProject.httpx and its SSRF filter. Inference + # traffic to an administrator-supplied base URL is therefore not SSRF + # filtered. This is a deliberate, recorded decision. Model discovery + # deliberately stays on Llm::Client, which is filtered. + class Session + # RubyLLM exposes a single Faraday timeout rather than Llm::Client's + # connect/read/request triple, so those three values collapse to one. + # RubyLLM's own default is 300s, which is far too long to hold a Rails + # worker for a check. + PROBE_TIMEOUT = 20 + INFERENCE_TIMEOUT = 180 + + # Formats whose credentials an LlmConnection cannot express: Bedrock needs a + # secret key and a region as well as a key, Vertex AI a project and a + # location. The model has a single api_key column. + UNSUPPORTED_FORMATS = %w[bedrock vertexai].freeze + + # A self-hosted OpenAI-compatible server frequently needs no credential at + # all, but RubyLLM's ensure_configured! raises when a provider declares + # _api_key as required and none is set. A placeholder gets us past + # that check; a server that wants no credential ignores it. + PLACEHOLDER_API_KEY = "-" + + class << self + def for(connection, timeout: INFERENCE_TIMEOUT, max_retries: 1) + new(connection, timeout:, max_retries:) + end + + def supports?(api_format) + UNSUPPORTED_FORMATS.exclude?(api_format.to_s) + end + end + + def initialize(connection, timeout: INFERENCE_TIMEOUT, max_retries: 1) + @connection = connection + @timeout = timeout + @max_retries = max_retries + + unless self.class.supports?(connection.api_format) + raise Llm::Errors::ConfigurationError, + "#{connection.api_format} needs credentials an LLM connection cannot store" + end + end + + def provider + @provider ||= connection.api_format.to_sym + end + + # Translates failures raised by a chat's own request methods. + # + # RubyLLM::Chat is a builder: the request happens later, when the caller + # invokes #ask, long after this class has returned. Extending the instance + # is what makes the error taxonomy hold for that call too, rather than + # relying on every caller to remember to wrap it. The with_* builder methods + # return self, so the extension survives them. + module TranslatesErrors + def complete(...) + Llm::Errors.wrap { super } + end + end + + # @param model_id [String] + # @return [RubyLLM::Chat] + def chat(model_id) + Llm::Errors.wrap do + context.chat(model: model_id, provider:, assume_model_exists: true) + .extend(TranslatesErrors) + end + end + + # @param input [String, Array] + # @return [RubyLLM::Embedding] + def embed(input, model:, dimensions: nil) + Llm::Errors.wrap do + context.embed(input, model:, provider:, assume_model_exists: true, dimensions:) + end + end + + private + + attr_reader :connection, :timeout, :max_retries + + # A per-call context rather than RubyLLM.configure: the global configuration + # is process-wide, and writing an administrator's endpoint and credential + # into it would leak them across requests and across tenants. + def context + @context ||= RubyLLM.context do |config| + config.public_send(:"#{provider}_api_base=", connection.base_url) + apply_credentials(config) + + config.openproject_custom_headers = connection.custom_headers + config.request_timeout = timeout + # RubyLLM retries POSTs three times by default, so one completion can be + # billed four times. Callers state what they are willing to pay for. + config.max_retries = max_retries + config.logger = Rails.logger + end + end + + def apply_credentials(config) + key = connection.api_key.presence || (PLACEHOLDER_API_KEY if api_key_required?) + + config.public_send(:"#{provider}_api_key=", key) if key + end + + # Asked of the gem rather than hard-coded: which providers insist on a key, + # as opposed to accepting a bare base URL, is RubyLLM's business and changes + # between releases. + def api_key_required? + RubyLLM::Provider.resolve(provider) + .configuration_requirements + .include?(:"#{provider}_api_key") + end + end +end diff --git a/app/services/llm/structured_output.rb b/app/services/llm/structured_output.rb new file mode 100644 index 000000000000..5aa0af7f0dd9 --- /dev/null +++ b/app/services/llm/structured_output.rb @@ -0,0 +1,55 @@ +# 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 + # Turns a schema-constrained answer into a Hash, or fails loudly. + # + # RubyLLM's Chat#with_schema fails soft: when the answer does not parse as + # JSON it rescues and leaves the content as a String, so a caller expecting a + # Hash gets a String and only notices further downstream. + # + # That matters most on a self-hosted server, which is exactly where the + # structured_output capability verdict is :unknown rather than :supported, and + # so exactly where the model is most likely to answer in prose. + module StructuredOutput + module_function + + # @param message [RubyLLM::Message] the answer from Chat#ask + # @raise [Llm::Errors::ParseError] when the model did not honour the schema + # @return [Hash] + def parse!(message) + content = message.respond_to?(:content) ? message.content : message + + return content.deep_symbolize_keys if content.is_a?(Hash) + + raise Llm::Errors::ParseError, "Model did not answer with the requested structure" + end + end +end diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 2630a2d46c45..286f8052fb62 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -59,3 +59,7 @@ OpenProject::FeatureDecisions.add :sprint_reports, description: "Enables sprint reporting within the backlogs module. " \ "It shows a dashboard with various widgets regarding the sprint progress." + +OpenProject::FeatureDecisions.add :llm_connection, + description: "Enables the administration page connecting OpenProject to an " \ + "OpenAI-API-compatible LLM server, and the AI features built on it." diff --git a/db/migrate/20260811090000_create_llm_connections.rb b/db/migrate/20260811090000_create_llm_connections.rb new file mode 100644 index 000000000000..9b5f8369c352 --- /dev/null +++ b/db/migrate/20260811090000_create_llm_connections.rb @@ -0,0 +1,61 @@ +# 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. +#++ + +class CreateLlmConnections < ActiveRecord::Migration[8.1] + def change + create_table :llm_connections do |t| + t.string :name, null: false, index: { unique: true } + t.string :type, null: false, index: true + t.boolean :enabled, null: false, default: false + t.string :base_url, null: false + # Ciphered through Redmine::Ciphering when database_cipher_key is configured. + # Nullable: an unauthenticated self-hosted server needs no key. + t.string :api_key + # Which dialect the server speaks. Only a subset is implemented; the column + # exists so that adding a format is a new adapter rather than a migration. + t.string :api_format, null: false, default: "openai" + # Provider-specific headers sent with every request, e.g. Azure's + # api-version or a gateway's own key header. + t.jsonb :custom_headers, null: false, default: {} + t.jsonb :options, null: false, default: {} + # Raw /v1/models payload plus any server-specific metadata, stored verbatim. + t.jsonb :catalogue, null: false, default: {} + t.datetime :catalogue_fetched_at + t.string :connection_fingerprint + # Model references are strings, never foreign keys: a selection must survive + # the model disappearing from the remote catalogue. + t.string :default_chat_model_id + t.string :default_embedding_model_id + t.datetime :last_connected_at + + t.timestamps null: false + end + end +end diff --git a/docs/installation-and-operations/configuration/environment/README.md b/docs/installation-and-operations/configuration/environment/README.md index 60405bd8994a..ed3e2ac89c74 100644 --- a/docs/installation-and-operations/configuration/environment/README.md +++ b/docs/installation-and-operations/configuration/environment/README.md @@ -234,6 +234,7 @@ OPENPROJECT_ENTERPRISE__PLAN (default="enterprise-on-premises---basic---euro---1 OPENPROJECT_ENTERPRISE__TRIAL__CREATION__HOST (default="https://start.openproject.com") Host for EE trial service OPENPROJECT_FEATURE__BUILT__IN__OAUTH__APPLICATIONS__ACTIVE (default=true) Allows the display and use of built-in OAuth applications. OPENPROJECT_FEATURE__DEPLOY__TARGETS__ACTIVE (default=false) +OPENPROJECT_FEATURE__LLM__CONNECTION__ACTIVE (default=false) Enables the administration page connecting OpenProject to an OpenAI-API-compatible LLM server, and the AI features built on it. OPENPROJECT_FEATURE__MINUTES__STYLING__MEETING__PDF__ACTIVE (default=false) Allow exporting a meeting with FITKO styling. See #65124 for details. OPENPROJECT_FEATURE__SPRINT__REPORTS__ACTIVE (default=false) Enables sprint reporting within the backlogs module. It shows a dashboard with various widgets regarding the sprint progress. OPENPROJECT_FEATURE__STORAGE__FILE__PICKING__SELECT__ALL__ACTIVE (default=false) diff --git a/lib/open_project/patches/ruby_llm_provider_headers.rb b/lib/open_project/patches/ruby_llm_provider_headers.rb new file mode 100644 index 000000000000..a3ed85160ab0 --- /dev/null +++ b/lib/open_project/patches/ruby_llm_provider_headers.rb @@ -0,0 +1,74 @@ +# 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. +#++ + +# Lets an LlmConnection's custom headers reach every request RubyLLM makes. +# +# An OpenAI-compatible deployment is routinely fronted by a gateway that +# authenticates on its own terms -- APISIX's key-auth wants an +apikey+ header, +# not +Authorization+ -- so an administrator must be able to add, and override, +# whatever the provider would send. +# +# RubyLLM's public API cannot express that: +# +# * Chat#with_headers reaches chat only. Provider#embed takes no headers +# argument at all, so an embedding request could never carry them. +# * Even for chat it merges the wrong way round -- +additional_headers.merge( +# req.headers)+ -- so the provider's own headers win every collision and an +# administrator can never replace Authorization. +# +# Provider#headers is the one hook that reaches chat, embeddings and model +# listing alike, and merging our values last makes an override actually override. +# +# The module is prepended onto each registered provider class rather than onto +# RubyLLM::Provider, because all thirteen providers define #headers themselves +# without calling super -- a patch on the base class would never be reached. +# Azure inherits OpenAI, so it receives the module twice; the inner copy is +# simply never consulted. +module OpenProject + module Patches + module RubyLLMProviderHeaders + def headers + custom = config.openproject_custom_headers + + return super if custom.blank? + + super.merge(custom.stringify_keys) + end + end + end +end + +OpenProject::Patches.patch_gem_version("ruby_llm", "1.16.0") do + RubyLLM::Configuration.register_provider_options(%i[openproject_custom_headers]) + + RubyLLM::Provider.providers.each_value do |provider_class| + provider_class.prepend(OpenProject::Patches::RubyLLMProviderHeaders) + end +end diff --git a/spec/factories/llm_connection_factory.rb b/spec/factories/llm_connection_factory.rb new file mode 100644 index 000000000000..74a3f2179440 --- /dev/null +++ b/spec/factories/llm_connection_factory.rb @@ -0,0 +1,43 @@ +# 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. +#++ + +FactoryBot.define do + factory :llm_connection do + name { LlmConnection::SINGLETON_NAME } + type { "LlmConnection" } + base_url { "https://example.com/v1" } + api_key { "sk-test-key" } + enabled { false } + + trait :enabled do + enabled { true } + end + end +end diff --git a/spec/lib/open_project/patches/ruby_llm_provider_headers_spec.rb b/spec/lib/open_project/patches/ruby_llm_provider_headers_spec.rb new file mode 100644 index 000000000000..9373d5fdc729 --- /dev/null +++ b/spec/lib/open_project/patches/ruby_llm_provider_headers_spec.rb @@ -0,0 +1,84 @@ +# 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. +#++ + +require "spec_helper" + +RSpec.describe OpenProject::Patches::RubyLLMProviderHeaders do + def provider_for(slug, custom_headers) + context = RubyLLM.context do |config| + config.public_send(:"#{slug}_api_key=", "test-key") + config.openproject_custom_headers = custom_headers + config.logger = Rails.logger + end + + RubyLLM::Provider.resolve(slug).new(context.config) + end + + it "adds the connection's custom headers to a provider's own" do + headers = provider_for(:openai, { "apikey" => "gateway-secret" }).headers + + expect(headers).to include("apikey" => "gateway-secret") + expect(headers).to include("Authorization" => "Bearer test-key") + end + + # The point of the patch: a gateway may authenticate on its own terms, so an + # administrator has to be able to replace what the provider would send. + # RubyLLM's own Chat#with_headers merges the other way round and cannot. + it "lets a custom header override the provider's" do + headers = provider_for(:openai, { "Authorization" => "Bearer override" }).headers + + expect(headers).to include("Authorization" => "Bearer override") + end + + it "accepts symbol keys" do + headers = provider_for(:openai, { apikey: "gateway-secret" }).headers + + expect(headers).to include("apikey" => "gateway-secret") + end + + it "leaves the provider's headers untouched when none are configured" do + expect(provider_for(:openai, {}).headers).to eq(provider_for(:openai, nil).headers) + expect(provider_for(:openai, nil).headers).to include("Authorization" => "Bearer test-key") + end + + # All thirteen providers define #headers without calling super, so the patch + # has to be prepended onto each one rather than onto the base class. + it "applies to every registered provider" do + RubyLLM::Provider.providers.each_value do |provider_class| + expect(provider_class.ancestors).to include(described_class) + end + end + + it "reaches a provider that is not openai" do + headers = provider_for(:anthropic, { "apikey" => "gateway-secret" }).headers + + expect(headers).to include("apikey" => "gateway-secret") + end +end diff --git a/spec/services/llm/errors_spec.rb b/spec/services/llm/errors_spec.rb new file mode 100644 index 000000000000..45c9dc4abd41 --- /dev/null +++ b/spec/services/llm/errors_spec.rb @@ -0,0 +1,145 @@ +# 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. +#++ + +require "spec_helper" + +RSpec.describe Llm::Errors do + def rubyllm_response(status) + instance_double(Faraday::Env, status:) + end + + describe ".translate" do + it "maps an unauthorized response onto an authentication error" do + translated = described_class.translate(RubyLLM::UnauthorizedError.new(rubyllm_response(401), "nope")) + + expect(translated).to be_a(Llm::Errors::AuthenticationError) + end + + it "maps a forbidden response onto an authentication error" do + translated = described_class.translate(RubyLLM::ForbiddenError.new(rubyllm_response(403), "nope")) + + expect(translated).to be_a(Llm::Errors::AuthenticationError) + end + + it "maps an exceeded context window onto a context length error" do + translated = described_class.translate(RubyLLM::ContextLengthExceededError.new(rubyllm_response(400), "too long")) + + expect(translated).to be_a(Llm::Errors::ContextLengthError) + expect(translated.status).to eq(400) + end + + it "maps throttling onto a rate limited error" do + translated = described_class.translate(RubyLLM::RateLimitError.new(rubyllm_response(429), "slow down")) + + expect(translated).to be_a(Llm::Errors::RateLimitedError) + end + + it "maps a bad request onto an api error carrying the status" do + translated = described_class.translate(RubyLLM::BadRequestError.new(rubyllm_response(400), "bad")) + + expect(translated).to be_a(Llm::Errors::ApiError) + expect(translated.status).to eq(400) + end + + # A server that does not implement the endpoint answers 404/405/501, which + # RubyLLM's middleware raises as a bare Error. This is the path that tells a + # probe "this model cannot do that" rather than "the server is broken". + it "maps an unimplemented endpoint onto an api error carrying the status" do + translated = described_class.translate(RubyLLM::Error.new(rubyllm_response(404), "not found")) + + expect(translated).to be_a(Llm::Errors::ApiError) + expect(translated.status).to eq(404) + end + + it "maps a misconfiguration onto a configuration error" do + expect(described_class.translate(RubyLLM::ConfigurationError.new("missing key"))) + .to be_a(Llm::Errors::ConfigurationError) + expect(described_class.translate(RubyLLM::ModelNotFoundError.new("no such model"))) + .to be_a(Llm::Errors::ConfigurationError) + end + + it "maps transport failures onto connection errors" do + expect(described_class.translate(Faraday::TimeoutError.new("timeout"))) + .to be_a(Llm::Errors::TimeoutError) + expect(described_class.translate(Faraday::ConnectionFailed.new("refused"))) + .to be_a(Llm::Errors::ConnectionError) + expect(described_class.translate(JSON::ParserError.new("unexpected token"))) + .to be_a(Llm::Errors::ParseError) + end + + it "passes an already translated error through untouched" do + original = Llm::Errors::SsrfError.new("Host resolves to a blocked address") + + expect(described_class.translate(original)).to be(original) + end + + # An OpenAI-compatible gateway echoes the submitted Authorization header, + # upstream provider URLs and internal hostnames in its error payloads, and + # RubyLLM puts that payload straight into the exception message. + it "never carries the upstream message into the translated error" do + secret = "Bearer sk-super-secret upstream=http://10.0.0.5:8000" + + http_errors = [RubyLLM::Error, RubyLLM::BadRequestError, RubyLLM::ForbiddenError, + RubyLLM::ContextLengthExceededError, RubyLLM::OverloadedError, + RubyLLM::PaymentRequiredError, RubyLLM::RateLimitError, RubyLLM::ServerError, + RubyLLM::ServiceUnavailableError, RubyLLM::UnauthorizedError] + plain_errors = [RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError] + + errors = http_errors.map { |klass| klass.new(rubyllm_response(400), secret) } + + plain_errors.map { |klass| klass.new(secret) } + + errors.each do |error| + message = described_class.translate(error).message + + expect(message).not_to include("sk-super-secret") + expect(message).not_to include("10.0.0.5") + end + end + end + + describe ".wrap" do + it "returns the block's value when nothing is raised" do + expect(described_class.wrap { :fine }).to be(:fine) + end + + it "re-raises a RubyLLM failure as an Llm::Errors" do + expect { described_class.wrap { raise RubyLLM::UnauthorizedError.new(rubyllm_response(401), "nope") } } + .to raise_error(Llm::Errors::AuthenticationError) + end + end + + describe "the Llm::Client aliases" do + it "resolve to the same classes, so existing rescues keep working" do + expect(Llm::Client::Error).to be(Llm::Errors::Error) + expect(Llm::Client::ApiError).to be(Llm::Errors::ApiError) + expect(Llm::Client::SsrfError).to be(Llm::Errors::SsrfError) + end + end +end diff --git a/spec/services/llm/session_spec.rb b/spec/services/llm/session_spec.rb new file mode 100644 index 000000000000..3871da3264a3 --- /dev/null +++ b/spec/services/llm/session_spec.rb @@ -0,0 +1,132 @@ +# 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. +#++ + +require "spec_helper" + +RSpec.describe Llm::Session, :llm_server_helpers, :webmock do + let(:base_url) { "https://example.com/v1" } + let(:connection) { create(:llm_connection, base_url:, api_key: "sk-test-key") } + + describe ".supports?" do + it "rejects the formats a connection cannot supply credentials for" do + expect(described_class.supports?("bedrock")).to be(false) + expect(described_class.supports?("vertexai")).to be(false) + end + + it "accepts the rest" do + expect(described_class.supports?("openai")).to be(true) + expect(described_class.supports?("anthropic")).to be(true) + end + end + + describe "#initialize" do + it "refuses a format whose credentials cannot be stored" do + connection.update_column(:api_format, "bedrock") + + expect { described_class.for(connection) } + .to raise_error(Llm::Errors::ConfigurationError, /bedrock/) + end + end + + describe "#chat" do + it "sends the completion to the configured base URL with the stored key" do + stub = mock_llm_chat_response(base_url, content: "pong") + + answer = described_class.for(connection, max_retries: 0).chat("qwen3.6-27b").ask("ping") + + expect(answer.content).to eq("pong") + expect(stub).to have_been_requested.once + expect(WebMock).to have_requested(:post, "#{base_url}/chat/completions") + .with(headers: { "Authorization" => "Bearer sk-test-key" }) + end + + it "sends the connection's custom headers" do + connection.update!(custom_headers: { "apikey" => "gateway-secret" }) + mock_llm_chat_response(base_url) + + described_class.for(connection, max_retries: 0).chat("qwen3.6-27b").ask("ping") + + expect(WebMock).to have_requested(:post, "#{base_url}/chat/completions") + .with(headers: { "apikey" => "gateway-secret" }) + end + + it "translates a rejected key into an authentication error" do + mock_llm_chat_response(base_url, response_code: 401) + + expect { described_class.for(connection, max_retries: 0).chat("qwen3.6-27b").ask("ping") } + .to raise_error(Llm::Errors::AuthenticationError) + end + + # RubyLLM retries POSTs three times by default, so an unbounded retry would + # bill four completions for one call. + it "does not retry when told not to" do + stub = mock_llm_chat_response(base_url, response_code: 500) + + expect { described_class.for(connection, max_retries: 0).chat("qwen3.6-27b").ask("ping") } + .to raise_error(Llm::Errors::ApiError) + expect(stub).to have_been_requested.once + end + + # A self-hosted OpenAI-compatible server commonly needs no credential, but + # RubyLLM's ensure_configured! raises unless one is set. + it "reaches a server that needs no API key" do + connection.update!(api_key: nil) + mock_llm_chat_response(base_url) + + expect { described_class.for(connection, max_retries: 0).chat("qwen3.6-27b").ask("ping") } + .not_to raise_error + end + end + + describe "#embed" do + it "requests a vector and carries the custom headers" do + connection.update!(custom_headers: { "apikey" => "gateway-secret" }) + mock_llm_embeddings_response(base_url, dimensions: 8) + + embedding = described_class.for(connection, max_retries: 0).embed("hello", model: "bge-m3") + + expect(embedding.vectors.length).to eq(8) + expect(WebMock).to have_requested(:post, "#{base_url}/embeddings") + .with(headers: { "apikey" => "gateway-secret" }) + end + end + + describe "global configuration" do + it "never writes the connection's settings into RubyLLM's global config" do + mock_llm_chat_response(base_url) + + described_class.for(connection, max_retries: 0).chat("qwen3.6-27b").ask("ping") + + expect(RubyLLM.config.openai_api_base).to be_nil + expect(RubyLLM.config.openai_api_key).to be_nil + expect(RubyLLM.config.openproject_custom_headers).to be_nil + end + end +end diff --git a/spec/services/llm/structured_output_spec.rb b/spec/services/llm/structured_output_spec.rb new file mode 100644 index 000000000000..b7ee9a7d4357 --- /dev/null +++ b/spec/services/llm/structured_output_spec.rb @@ -0,0 +1,58 @@ +# 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. +#++ + +require "spec_helper" + +RSpec.describe Llm::StructuredOutput do + def message(content) + instance_double(RubyLLM::Message, content:) + end + + it "returns the structure with symbol keys" do + expect(described_class.parse!(message({ "summary" => "hi", "tags" => %w[a b] }))) + .to eq(summary: "hi", tags: %w[a b]) + end + + it "symbolizes nested keys" do + expect(described_class.parse!(message({ "a" => { "b" => 1 } }))).to eq(a: { b: 1 }) + end + + # RubyLLM rescues a JSON parse failure and leaves the content a String, so + # without this the caller gets a String where it expected a Hash and only + # notices much further downstream. + it "raises when the model answered in prose instead" do + expect { described_class.parse!(message("Sure! Here is the summary you asked for.")) } + .to raise_error(Llm::Errors::ParseError) + end + + it "accepts a bare structure as well as a message" do + expect(described_class.parse!({ "a" => 1 })).to eq(a: 1) + end +end diff --git a/spec/support/llm_server_helpers.rb b/spec/support/llm_server_helpers.rb new file mode 100644 index 000000000000..2acfd14baeda --- /dev/null +++ b/spec/support/llm_server_helpers.rb @@ -0,0 +1,135 @@ +# 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 LlmServerHelpers + DEFAULT_MODELS = [ + { id: "qwen3.6-27b", object: "model", owned_by: "vllm", max_model_len: 262_144 }, + { id: "bge-m3", object: "model", owned_by: "vllm", max_model_len: 8_192 } + ].freeze + + # Stubs GET /models. Returns the stub so specs can assert on how + # often it was called -- which is how the changed-attributes guard is pinned. + def mock_llm_models_response(base_url, + models: DEFAULT_MODELS, + response_code: 200, + body: nil, + timeout: false) + stub = stub_request(:get, "#{base_url.chomp('/')}/models") + + return stub.to_timeout if timeout + + stub.to_return( + status: response_code, + headers: { "Content-Type" => "application/json" }, + body: body || { object: "list", data: models }.to_json + ) + end + + DEFAULT_CHAT_BODY = { + id: "chatcmpl-test", + object: "chat.completion", + model: "qwen3.6-27b", + choices: [{ index: 0, message: { role: "assistant", content: "pong" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + }.freeze + + # Stubs POST /chat/completions. + # + # Note that RubyLLM retries POSTs up to max_retries times on 429 and 5xx, so a + # spec stubbing one of those against a session built with the default + # max_retries sees more than one request. Build the session with an explicit + # max_retries when the request count matters. + def mock_llm_chat_response(base_url, + content: "pong", + response_code: 200, + body: nil, + timeout: false) + stub = stub_request(:post, "#{base_url.chomp('/')}/chat/completions") + + return stub.to_timeout if timeout + + payload = body || DEFAULT_CHAT_BODY.merge( + choices: [{ index: 0, message: { role: "assistant", content: }, finish_reason: "stop" }] + ).to_json + + stub.to_return( + status: response_code, + headers: { "Content-Type" => "application/json" }, + body: payload.is_a?(String) ? payload : payload.to_json + ) + end + + # Stubs POST /embeddings, returning a vector of the requested size. + def mock_llm_embeddings_response(base_url, + dimensions: 4, + response_code: 200, + body: nil, + timeout: false) + stub = stub_request(:post, "#{base_url.chomp('/')}/embeddings") + + return stub.to_timeout if timeout + + payload = body || { + object: "list", + model: "bge-m3", + data: [{ object: "embedding", index: 0, embedding: Array.new(dimensions) { 0.1 } }], + usage: { prompt_tokens: 1, total_tokens: 1 } + } + + stub.to_return( + status: response_code, + headers: { "Content-Type" => "application/json" }, + body: payload.is_a?(String) ? payload : payload.to_json + ) + end + + # example.com resolves publicly, but a spec that needs a literal or private + # host has to say so explicitly rather than opening the allowlist to 0.0.0.0/0. + def allow_llm_host(*hosts) + allow(OpenProject::SsrfProtection).to receive(:safe_ip?) do |host| + hosts.include?(host) ? IPAddr.new("93.184.216.34") : nil + end + end + + RESOLVED_ADDRESSES = [IPAddr.new("93.184.216.34")].freeze + + # Specs never hit real DNS: every hostname resolves to a public address + # unless a spec declares it unresolvable. + def stub_llm_dns(unresolvable: []) + allow(OpenProject::SsrfProtection).to receive(:resolver).and_return( + ->(host) { unresolvable.include?(host) ? [] : RESOLVED_ADDRESSES } + ) + end +end + +RSpec.configure do |config| + config.include LlmServerHelpers, :llm_server_helpers + config.before(:each, :llm_server_helpers) { stub_llm_dns } +end