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