From 0b2833bea70e7d3180ec503e297e32de1940ef77 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 administration page Administration, Artificial Intelligence (AI), LLM settings: API format, base URL and API key, behind the llm_connection feature flag. Saving is connecting. The contract probes the server before anything is written, following the storages precedent where the Nextcloud credentials validator adds a contract error on 401 and stops the write, so a failed connect persists nothing. The probe fires only when base URL, API key or API format changed. Without that guard every unrelated save, and every form render that builds a model through SetAttributesService, would hit the server. Registry-backed formats skip it entirely: there is nothing at their base URL to probe. The API key is write-only. The stored value is never sent to the browser, a blank submission keeps the current key, and the Stimulus controller wipes the field on turbo:before-cache so the back button cannot restore a typed secret. The base URL is normalised only by trimming whitespace and a trailing slash; the version segment is never rewritten, because silently editing an administrator's URL makes the eventual failure harder to diagnose. A 404 for the model list is not a failure: a server can speak chat perfectly while offering no list, so the save succeeds and later parts of this stack give the administrator ways to name models by hand. Part 2 of the AI-3 stack. https://community.openproject.org/work_packages/66020 --- .../llm_connections/form_component.html.erb | 26 +++ .../llm_connections/form_component.rb | 60 ++++++ .../llm_connections/base_contract.rb | 65 +++++++ .../llm_connections/update_contract.rb | 45 +++++ .../admin/llm_connections_controller.rb | 102 ++++++++++ app/forms/llm_connections/connection_form.rb | 101 ++++++++++ .../llm_connections/set_attributes_service.rb | 60 ++++++ .../llm_connections/update_service.rb | 34 ++++ app/validators/llm_server_validator.rb | 151 +++++++++++++++ app/views/admin/llm_connections/show.html.erb | 44 +++++ config/initializers/menus.rb | 6 + config/locales/en.yml | 169 ++++++---------- config/routes.rb | 2 + .../admin/llm-connection-form.controller.ts | 69 +++++++ .../llm_connections/update_contract_spec.rb | 183 ++++++++++++++++++ spec/features/admin/llm_connection_spec.rb | 67 +++++++ spec/requests/admin/llm_connections_spec.rb | 129 ++++++++++++ 17 files changed, 1202 insertions(+), 111 deletions(-) create mode 100644 app/components/llm_connections/form_component.html.erb create mode 100644 app/components/llm_connections/form_component.rb create mode 100644 app/contracts/llm_connections/base_contract.rb create mode 100644 app/contracts/llm_connections/update_contract.rb create mode 100644 app/controllers/admin/llm_connections_controller.rb create mode 100644 app/forms/llm_connections/connection_form.rb create mode 100644 app/services/llm_connections/set_attributes_service.rb create mode 100644 app/services/llm_connections/update_service.rb create mode 100644 app/validators/llm_server_validator.rb create mode 100644 app/views/admin/llm_connections/show.html.erb create mode 100644 frontend/src/stimulus/controllers/dynamic/admin/llm-connection-form.controller.ts create mode 100644 spec/contracts/llm_connections/update_contract_spec.rb create mode 100644 spec/features/admin/llm_connection_spec.rb create mode 100644 spec/requests/admin/llm_connections_spec.rb diff --git a/app/components/llm_connections/form_component.html.erb b/app/components/llm_connections/form_component.html.erb new file mode 100644 index 000000000000..35a5273aa4aa --- /dev/null +++ b/app/components/llm_connections/form_component.html.erb @@ -0,0 +1,26 @@ +<%= + component_wrapper(tag: "turbo-frame", **wrapper_options) do + concat( + settings_primer_form_with(**form_options) do |f| + render(LlmConnections::ConnectionForm.new(f)) + end + ) + + concat( + render( + Primer::Beta::Text.new( + tag: :div, + mt: 3, + hidden: true, + display: :flex, + align_items: :center, + color: :muted, + data: { "admin--llm-connection-form-target": "progressBanner" } + ) + ) do + concat(render(Primer::Beta::Spinner.new(size: :small, mr: 2))) + concat(t("admin.llm_connections.form.label_connecting")) + end + ) + end +%> diff --git a/app/components/llm_connections/form_component.rb b/app/components/llm_connections/form_component.rb new file mode 100644 index 000000000000..18313947a162 --- /dev/null +++ b/app/components/llm_connections/form_component.rb @@ -0,0 +1,60 @@ +# 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 LlmConnections + class FormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def self.wrapper_key = :llm_connection_form + + alias_method :connection, :model + + private + + def wrapper_options + { + data: { + controller: "admin--llm-connection-form", + test_selector: "llm-connection--form" + } + } + end + + def form_options + { + model: connection, + url: llm_connection_path, + method: :patch + } + end + end +end diff --git a/app/contracts/llm_connections/base_contract.rb b/app/contracts/llm_connections/base_contract.rb new file mode 100644 index 000000000000..7689607423a2 --- /dev/null +++ b/app/contracts/llm_connections/base_contract.rb @@ -0,0 +1,65 @@ +# 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 LlmConnections + # Validations that hold for every write, including provisioning from the + # environment. Deliberately makes no network request -- see UpdateContract. + class BaseContract < ModelContract + attribute :enabled + attribute :api_format + attribute :base_url + attribute :api_key + attribute :default_chat_model_id + attribute :default_embedding_model_id + + validates :base_url, presence: true + validates :api_format, inclusion: { in: Llm::Adapters::FORMATS } + # Bedrock and Vertex AI can be discovered against, but not called: they need + # a secret key and region, or a project and location, and there is one + # api_key column. Offering them would only fail at request time. + validates :api_format, exclusion: { in: Llm::Session::UNSUPPORTED_FORMATS, message: :not_supported }, + unless: -> { api_format.blank? } + # Resolves to the validate_url gem, which defaults to http and https. Plain + # http is deliberately allowed: an on-premise LLM server on an internal + # network commonly terminates TLS elsewhere, or not at all. + validates :base_url, url: { message: :invalid_url }, unless: -> { base_url.blank? } + + validate :enabled_requires_connection + + private + + def enabled_requires_connection + return unless model.enabled? + return if model.base_url.present? + + errors.add :enabled, :requires_connection + end + end +end diff --git a/app/contracts/llm_connections/update_contract.rb b/app/contracts/llm_connections/update_contract.rb new file mode 100644 index 000000000000..9c4ec676e034 --- /dev/null +++ b/app/contracts/llm_connections/update_contract.rb @@ -0,0 +1,45 @@ +# 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 LlmConnections + # The contract used when an administrator saves the form. + # + # This is the only contract that talks to the remote server, so that a failed + # probe persists nothing. It must NOT move to BaseContract: provisioning from + # the environment reuses that base, and a validates line cannot be un-declared + # by a subclass -- an outbound request during seeding would fail the boot of a + # container whose LLM server has not started yet. + # + # The probe itself is guarded on changed attributes, so toggling +enabled+ or + # picking a default model contacts nothing. + class UpdateContract < BaseContract + validates :base_url, llm_server: true, unless: -> { errors.include?(:base_url) } + end +end diff --git a/app/controllers/admin/llm_connections_controller.rb b/app/controllers/admin/llm_connections_controller.rb new file mode 100644 index 000000000000..ba95bf0d8ca8 --- /dev/null +++ b/app/controllers/admin/llm_connections_controller.rb @@ -0,0 +1,102 @@ +# 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 Admin + class LlmConnectionsController < ApplicationController + include OpTurbo::ComponentStream + include PaginationHelper + + layout "admin" + menu_item :llm_connection + + before_action :require_feature + before_action :require_admin + before_action :set_connection + + def show; end + + def update + result = ::LlmConnections::UpdateService + .new(user: current_user, model: @connection) + .call(**llm_connection_params) + + result.on_success { redirect_after_save } + result.on_failure { render_form_with_errors } + end + + private + + def set_connection + @connection = LlmConnection.instance + end + + # The flag gates the endpoints, not only the menu entry: an unfinished page + # must not accept writes just because somebody knows the URL. + def require_feature + render_404 unless OpenProject::FeatureDecisions.llm_connection_active? + end + + def redirect_after_save + redirect_with_notice(t(".success")) + end + + def render_form_with_errors + update_via_turbo_stream(component: ::LlmConnections::FormComponent.new(@connection)) + # The HTML fallback re-renders the whole page, which needs everything the + # show action assigns -- not just the form that failed. + respond_with_turbo_streams do |format| + format.html do + show + render :show + end + end + end + + def redirect_with_notice(message) + flash[:notice] = message + redirect_to llm_connection_path, status: :see_other + end + + def redirect_with_error(message) + flash[:error] = message + redirect_to llm_connection_path, status: :see_other + end + + # A blank API key means "keep the stored one": the form never renders the + # saved value, so submitting it unchanged posts an empty string. + def llm_connection_params + permitted = params.expect( + llm_connection: %i[enabled api_format base_url api_key] + ) + permitted.delete(:api_key) if permitted[:api_key].blank? + permitted.to_h.symbolize_keys + end + end +end diff --git a/app/forms/llm_connections/connection_form.rb b/app/forms/llm_connections/connection_form.rb new file mode 100644 index 000000000000..04bdda3e742a --- /dev/null +++ b/app/forms/llm_connections/connection_form.rb @@ -0,0 +1,101 @@ +# 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 LlmConnections + class ConnectionForm < ApplicationForm + form do |f| + f.check_box( + name: :enabled, + label: LlmConnection.human_attribute_name(:enabled), + caption: I18n.t("admin.llm_connections.form.enabled_caption") + ) + + f.select_list( + name: :api_format, + label: LlmConnection.human_attribute_name(:api_format), + caption: I18n.t("admin.llm_connections.form.api_format_caption"), + include_blank: false, + input_width: :medium + ) do |select| + # Only formats a request can actually be sent in. The contract rejects + # the rest as a backstop, but they should not be offered in the first place. + Llm::Adapters::FORMATS.select { |format| Llm::Session.supports?(format) }.each do |format| + select.option(value: format, label: I18n.t("llm.api_formats.#{format}")) + end + end + + f.text_field( + name: :base_url, + label: LlmConnection.human_attribute_name(:base_url), + caption: I18n.t("admin.llm_connections.form.base_url_caption"), + placeholder: "https://example.com/v1", + required: true, + type: :url, + input_width: :large + ) + + f.text_field( + name: :api_key, + label: LlmConnection.human_attribute_name(:api_key), + caption: api_key_caption, + # The stored key is never sent to the browser. A blank submission means + # "keep the current key", handled in the controller. + value: nil, + placeholder: api_key_placeholder, + type: :password, + autocomplete: "off", + input_width: :large, + data: { "admin--llm-connection-form-target": "secretInput" } + ) + + f.submit( + name: :submit, + label: submit_label, + scheme: :primary, + data: { "admin--llm-connection-form-target": "submitButton" } + ) + end + + private + + def submit_label + model.persisted? ? I18n.t(:button_save) : I18n.t("admin.llm_connections.form.button_connect") + end + + def api_key_caption + key = model.persisted? && model.api_key.present? + I18n.t("admin.llm_connections.form.api_key_caption#{'_stored' if key}") + end + + def api_key_placeholder + "••••••••••••••••" if model.api_key.present? + end + end +end diff --git a/app/services/llm_connections/set_attributes_service.rb b/app/services/llm_connections/set_attributes_service.rb new file mode 100644 index 000000000000..4a24ffe2bb37 --- /dev/null +++ b/app/services/llm_connections/set_attributes_service.rb @@ -0,0 +1,60 @@ +# 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 LlmConnections + class SetAttributesService < BaseServices::SetAttributes + private + + def set_attributes(params) + super + + model.base_url = normalized_base_url if model.base_url.present? + set_singleton_defaults + end + + # +name+ and +type+ identify the record but are never user-editable, so they + # are set as system changes: the contract's readonly check only looks at + # attributes the user changed. + def set_singleton_defaults + model.change_by_system do + model.name ||= LlmConnection::SINGLETON_NAME + model.type ||= LlmConnection.name + end + end + + # Only trailing whitespace and slashes are removed. The /v1 segment is + # deliberately not added or stripped: silently rewriting an administrator's + # URL makes the eventual failure harder to diagnose, so a wrong shape is + # reported by the probe instead. + def normalized_base_url + model.base_url.strip.chomp("/") + end + end +end diff --git a/app/services/llm_connections/update_service.rb b/app/services/llm_connections/update_service.rb new file mode 100644 index 000000000000..d82f98369938 --- /dev/null +++ b/app/services/llm_connections/update_service.rb @@ -0,0 +1,34 @@ +# 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 LlmConnections + class UpdateService < BaseServices::Update + end +end diff --git a/app/validators/llm_server_validator.rb b/app/validators/llm_server_validator.rb new file mode 100644 index 000000000000..679a624cfbf3 --- /dev/null +++ b/app/validators/llm_server_validator.rb @@ -0,0 +1,151 @@ +# 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. +#++ + +# Verifies that the configured URL really is an OpenAI-API-compatible server that +# accepts the configured credentials, by fetching its model list. +# +# Runs inside the contract so that a server which cannot be reached never gets +# persisted. It is guarded on the attributes that identify the connection: +# without that guard every unrelated save -- and every form render that builds a +# model through SetAttributesService -- would fire an outbound HTTP request. +class LlmServerValidator < ActiveModel::EachValidator + # Changing any of these means talking to a different server, or to the same + # server in a different dialect, so the connection must be proven again and + # its models resynchronised. + CONNECTION_ATTRIBUTES = %w[base_url api_key api_format].freeze + + # Statuses that mean "this server has no model list here", as opposed to "this + # server is broken". A gateway may route chat completions and nothing else. + MODELS_ENDPOINT_ABSENT = [404, 405, 501].freeze + + def validate_each(contract, attribute, value) + return if value.blank? + return unless queries_the_server?(contract) + return unless connection_changed?(contract) + return unless host_allowed?(contract, attribute, value) + + probe(contract, attribute, value) + end + + private + + # Only OpenAI-compatible connections discover models from the server. For the + # other formats the list comes from the model registry, so there is nothing at + # this URL to probe. + def queries_the_server?(contract) + contract.model.api_format == Llm::Adapters::OPENAI_COMPATIBLE + end + + def connection_changed?(contract) + contract.model.changed_attributes.keys.intersect?(CONNECTION_ATTRIBUTES) + end + + # A pre-flight check purely so the administrator gets an actionable message + # naming the environment variable, rather than a bare connection failure from + # the transport-level SSRF filter. + def host_allowed?(contract, attribute, value) + host = URI.parse(value).host + return false if host.blank? + + addresses = resolve(host) + if addresses.empty? + # Nothing resolved: a typo or a DNS problem, not a policy decision. + contract.errors.add(attribute, :cannot_be_connected_to) + return false + end + + return true if addresses.any? { |address| OpenProject::SsrfProtection.safe_ip?(address) } + + contract.errors.add(attribute, :ssrf_filtered, env_name: ssrf_allowlist_env_name) + false + rescue URI::InvalidURIError + false + end + + def resolve(host) + return [IPAddr.new(host)] if host.match?(Resolv::IPv4::Regex) || host.match?(Resolv::IPv6::Regex) + + OpenProject::SsrfProtection.resolver.call(host) + rescue Resolv::ResolvError, IPAddr::InvalidAddressError + [] + end + + def ssrf_allowlist_env_name + Settings::Definition[:ssrf_protection_ip_allowlist].env_name + end + + def probe(contract, attribute, value) + client(contract, value).models + rescue Llm::Client::Error => e + log(value, e) + add_error(contract, attribute, e) + end + + # Order matters: SsrfError and TimeoutError are both ConnectionError subclasses. + def add_error(contract, attribute, error) + case error + when Llm::Client::SsrfError + contract.errors.add(attribute, :ssrf_filtered, env_name: ssrf_allowlist_env_name) + when Llm::Client::AuthenticationError + contract.errors.add(:api_key, :invalid_api_key) + when Llm::Client::TimeoutError + contract.errors.add(attribute, :request_timed_out) + when Llm::Client::ConnectionError + contract.errors.add(attribute, :cannot_be_connected_to) + when Llm::Client::ApiError + add_api_error(contract, attribute, error) + else + contract.errors.add(attribute, :not_openai_compatible) + end + end + + # A missing model list does not block the save. + # + # The server answered, so it is reachable and the credentials were accepted; it + # simply does not offer a list here. OpenProject's own hosted gateway is exactly + # this case, and it will not be the only one. Blocking would leave an + # administrator unable to save a working connection -- and unable to reach the + # manual model entry that exists for precisely this situation. + def add_api_error(contract, attribute, error) + return if error.status.in?(MODELS_ENDPOINT_ABSENT) + + contract.errors.add(attribute, :not_openai_compatible) + end + + def client(contract, base_url) + Llm::Client.new(base_url:, api_key: contract.model.api_key) + end + + # The upstream message is logged but never surfaced: an OpenAI-compatible + # gateway routinely echoes credentials and internal hostnames in error bodies. + def log(base_url, error) + Rails.logger.info { "LLM connection probe to #{base_url} failed: #{error.class} #{error.message}" } + end +end diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb new file mode 100644 index 000000000000..8b4f63f63cf5 --- /dev/null +++ b/app/views/admin/llm_connections/show.html.erb @@ -0,0 +1,44 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("menus.admin.llm_connection") %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t("menus.admin.llm_connection") } + header.with_description { t(".description") } + header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + { href: mcp_configurations_path, text: t("menus.admin.ai") }, + t("menus.admin.llm_connection")] + ) + end +%> + +<%= render(LlmConnections::FormComponent.new(@connection)) %> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 8897b8bd86f0..bed2ac6b42cc 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -504,6 +504,12 @@ caption: I18n.t("menus.admin.ai"), icon: :sparkle + menu.push :llm_connection, + { controller: "/admin/llm_connections", action: :show }, + if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.llm_connection_active? }, + caption: I18n.t("menus.admin.llm_connection"), + parent: :ai + menu.push :mcp_configurations, { controller: "/admin/mcp_configurations", action: :index }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 1bad3c7b69fc..948a37bb9b45 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -46,7 +46,6 @@ en: version: "A dynamic link to a version placed using a macro." view: "A dynamic link to a view placed using a macro." work_package: "A dynamic link to a work package placed using a macro." - account: auth_source_login_html: Please login as %{login} to activate your account. delete: "Delete account" @@ -77,14 +76,11 @@ en: omniauth_login: Please login to activate your account. signup_title: "Create an account in %{app_title}" signup_with_external_account: "Sign up with an external account" - actionview_instancetag_blank_option: "Please select" - activemodel: attributes: projects/copy_options: dependencies: "Dependencies" - activerecord: attributes: announcements: @@ -201,6 +197,14 @@ en: onthefly: "Automatic user creation" port: "Port" tls_certificate_string: "LDAP server SSL certificate" + llm_connection: + api_format: "API format" + api_key: "API key" + base_url: "Host URL" + # ActiveRecord::Base.human_attribute_name strips the _id suffix, so these + # keys deliberately do not carry it (see lib/open_project/patches/active_record_i18n.rb). + enabled: "Enable LLMs for this instance" + last_connected_at: "Last connected" mcp_configuration: description: Description enabled: Enabled @@ -671,6 +675,22 @@ en: tls_certificate_string: format: "%{message}" invalid_certificate: "The provided SSL certificate is invalid: %{additional_message}" + llm_connection: + attributes: + api_format: + not_supported: "needs credentials that OpenProject cannot store, such as a separate secret key or region." + api_key: + invalid_api_key: "was rejected by the LLM server." + unknown_error: "could not be validated with the LLM server. Please verify that the connection is functioning properly." + base: + singleton: "Only one LLM connection can be configured." + base_url: + cannot_be_connected_to: "could not be reached. Please ensure the LLM server is running and reachable from OpenProject." + not_openai_compatible: "did not return a valid model list. Please ensure the URL points at an OpenAI-API-compatible endpoint, including the API version segment (for example https://example.com/v1)." + request_timed_out: "did not respond in time. Please ensure the LLM server is reachable and not overloaded." + ssrf_filtered: "resolves to a blocked address. If the LLM server runs on an internal network, allow its IP via the %{env_name} environment variable." + enabled: + requires_connection: "cannot be turned on before a connection has been configured." meeting: error_conflict: "Unable to save because the meeting was updated by someone else in the meantime. Please reload the page." member: @@ -1317,7 +1337,6 @@ en: no_results_description_text: 'Choose "Show everything" to show all activity and comments' no_results_title_text: No activity to display unsaved_changes_confirmation_message: "Are you sure you want to dismiss your comment? The content that you have written will be lost." - activity: filter: changeset: "Changesets" @@ -1363,7 +1382,6 @@ en: phase_and_both_gates: "%{phase_message}. %{start_gate_message}, and %{finish_gate_message}" phase_and_one_gate: "%{phase_message}. %{gate_message}" removed_date: "date deleted %{date}" - admin: banners: environment_configured_readonly: "These values are configured via environment variables and cannot be edited here." @@ -1430,7 +1448,6 @@ en:
We have prepared [upgrade guides for all installation methods](upgrade_guide). You can perform the upgrade ahead of the next release at any time by following the guides. - jemalloc_allocator: Jemalloc memory allocator jira: actions: @@ -1673,7 +1690,23 @@ en: journal_aggregation: caption_with_maximum: > User actions on a work package (changing description, status, values, or writing comments) are grouped if performed within this period. It also controls notification and [webhook](webhook_link) delays. The maximum is %{max} minutes. - + llm_connections: + form: + api_format_caption: "Which API the server speaks. Choose OpenAI-compatible for most gateways and self-hosted servers." + api_key_caption: "The key OpenProject authenticates with. Leave blank if the server requires no authentication." + api_key_caption_stored: "A key is stored. Leave blank to keep it, or enter a new one to replace it." + base_url_caption: > + The full base URL of the server, including the API version segment, exactly as your provider documents it (for example https://example.com/v1). OpenProject appends only the endpoint path. + button_connect: "Connect" + enabled_caption: "When turned off, all LLM-backed AI features stop working. The connection settings are kept." + label_connecting: "Contacting the LLM server…" + show: + add_model_description: "If the server does not expose a model list, enter the model name exactly as the server expects it. Manually added models are kept when the list is refreshed." + add_model_submit: "Add model" + description: "Connect OpenProject to a server that speaks the OpenAI API, so AI features can use it." + refresh_models: "Refresh models" + update: + success: "Successfully connected to the LLM server." mcp_configurations: index: description: "The model context protocol allows AI agents to provide its users with tools and resources exposed by this OpenProject instance. This feature is still in beta." @@ -1925,7 +1958,6 @@ en: is_active: currently displayed is_inactive: currently not displayed show_until: Show until - antivirus_scan: deleted_by_admin: "The quarantined file '%{filename}' has been deleted by an administrator." deleted_message: "A virus was detected in file '%{filename}'. The file has been deleted." @@ -2012,7 +2044,6 @@ en: parent: Undisclosed - The parent is invisible because of lacking permissions. project: Undisclosed - The project is invisible because of lacking permissions. workPackage: Undisclosed - The work package is invisible because of lacking permissions. - attribute_help_texts: add_new: "Add help text" caption: "This short version will be displayed as caption of the attribute." @@ -2129,12 +2160,10 @@ en: work_package_id: "Work package" authentication: login_and_registration: "Login and registration" - background_jobs: status: cancelled_due_to: "Job was cancelled due to error: %{message}" error_requeue: "Job experienced an error but is retrying. The error was: %{message}" - backup: error: backup_pending: There is already a backup pending. @@ -2170,7 +2199,6 @@ en: When you create a new token you will only be allowed to request a backup after 24 hours. This is a safety measure. After that you can request a backup any time using that token. text_token_deleted: Backup token deleted. Backups are now disabled. - button_actions: "Actions" button_add: "Add" button_add_comment: "Add comment" @@ -2280,7 +2308,6 @@ en: mathematical: "The mathematical formula leads to an error. Please review the project calculation attribute and try again." missing_value: The attribute "%{custom_field_name}" is required by this Calculated value, but is empty. unknown: "An unknown error occurred. Please review the formula for this Calculated value." - colors: edit: label_edit_color: "Edit Color" @@ -2299,10 +2326,8 @@ en: new: label_new_color: "New Color" - concatenation: single: "or" - consent: checkbox_label: I have noted and do consent to the above. contact_mail_instructions: Define the mail address that users can reach a data controller to perform data change or removal requests. @@ -2315,7 +2340,6 @@ en: title: User Consent update_consent_last_time: "Last update of consent: %{update_time}" user_has_consented: The user gave their consent to your [configured consent information text](consent_settings). - copy_project: copy_options: dependencies_label: "Copy from project" @@ -2339,13 +2363,11 @@ en: description: Start from scratch. Manually add portfolio attributes, members and modules. label: "Blank portfolio" template_heading: "Select a portfolio template to work with the most common project management methods, or create a portfolio from scratch." - create_program: blank_template: description: Start from scratch. Manually add program attributes, members and modules. label: "Blank program" template_heading: "Select a program template to work with the most common project management methods, or create a program from scratch." - create_project: attributes_heading: "Fill in this mandatory information to work on your projects." blank_description: No description provided. @@ -2356,7 +2378,6 @@ en: dependencies_label: "Copy from template" template_heading: "Select a project template to work with the most common project management methods, or create a project from scratch." template_label: "Use template" - create_wiki_page: "Create new wiki page" create_wiki_page_button: "Wiki page" custom_actions: @@ -2370,7 +2391,6 @@ en: execute: "Execute %{name}" new: "New custom action" plural: "Custom actions" - custom_fields: admin: custom_field_projects: @@ -2476,11 +2496,9 @@ en: text_add_new_custom_field: > To add new custom fields to a project you first need to create them before you can add them to this project. - danger_dialog: confirmation_live_message_checked: "The button to proceed is now active." confirmation_live_message_unchecked: "The button to proceed is now inactive. You need to tick the checkbox to continue." - date: abbr_day_names: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] abbr_month_names: @@ -2539,7 +2557,6 @@ en: - :year - :month - :day - datetime: distance_in_words: about_x_hours: @@ -2604,7 +2621,6 @@ en: minute_abbreviated: one: "min" other: "mins" - departments: add_department: "Add department" add_department_form: @@ -2641,7 +2657,6 @@ en: managed_heading: "This user cannot be moved" managed_title: "User managed by LDAP" title: "User already in a department" - description_active: "Active?" description_attachment_toggle: "Show/Hide attachments" description_autocomplete: > @@ -2681,7 +2696,6 @@ en: # Text direction: Left-to-Right (ltr) or Right-to-Left (rtl) direction: ltr - doorkeeper: access_token_url: "Access token URL" auth_url: "Auth URL" @@ -2717,7 +2731,6 @@ en: unsupported_response_type: "The authorization server does not support this response type." pre_authorization: status: "Pre-authorization" - ee: features: baseline_comparison: Baseline Comparisons @@ -2867,7 +2880,6 @@ en: work_package_subject_generation: description: "Create automatically generated subjects using referenced attributes and text." - email_calendar_updates: button: disabled: "Enable" @@ -2877,11 +2889,9 @@ en: enabled: "Enabled." enterprise_plans: legacy_enterprise: "Enterprise Plan" - enterprise_trials: dialog_component: title: Enterprise Trial - enumeration_activities: "Time tracking activities" enumeration_caption_order_changed: "Order successfully changed." enumeration_could_not_be_moved: "Enumeration could not be moved." @@ -2889,7 +2899,6 @@ en: enumeration_reported_project_statuses: "Reported status" enumeration_work_package_priorities: "Work package priorities" error_attribute_not_highlightable: "Attribute(s) not highlightable: %{attributes}" - error_auth_source_sso_failed: "Single Sign-On (SSO) for user '%{value}' failed" error_can_not_archive_project: "This project cannot be archived: %{errors}" error_can_not_deactivate_type_invisible_projects: "There are also work packages in projects you cannot see." @@ -2946,12 +2955,10 @@ en: error_wiki_root_menu_item_conflict: > Can't rename "%{old_name}" to "%{new_name}" due to a conflict in the resulting menu item with the existing menu item "%{existing_caption}" (%{existing_identifier}). - error_work_package_id_not_found: "The work package was not found." error_work_package_not_found_in_project: "The work package was not found or does not belong to this project" error_workflow_copy_source: "Please select a source type or role" error_workflow_copy_target: "Please select target type(s) and role(s)" - errors: field_erroneous_label: "This field is invalid: %{full_errors}\nPlease enter a valid value." header_additional_invalid_fields: @@ -2966,7 +2973,6 @@ en: must_be_template: "must be template" storage_error: "There was an error with the storage connection." unsupported_storage_type: "is not a supported storage type." - events: changeset: "Changeset edited" message: Message edited @@ -2983,7 +2989,6 @@ en: work_package_closed: "Work Package closed" work_package_edit: "Work Package edited" work_package_note: "Work Package note added" - export: demo: button_text: Generate Demo PDF @@ -3084,7 +3089,6 @@ en: continue_message: "Are you sure you want to proceed to the following external link?" title: "Leaving OpenProject" warning_message: "You are about to leave OpenProject and visit an external website. Please be aware that external websites are not under our control and may have different privacy and security policies." - extraction: available: catdoc: "Catdoc available (optional)" @@ -3093,7 +3097,6 @@ en: tesseract: "Tesseract available (optional)" unrtf: "Unrtf available (optional)" xls2csv: "Xls2csv available (optional)" - filterable_tree_view: filter_mode: all: "All" @@ -3105,7 +3108,6 @@ en: show: no_results_title_text: There are currently no posts for the forum. general_csv_decimal_separator: "." - general_csv_encoding: "UTF-8" general_csv_separator: "," general_first_day_of_week: "7" @@ -3126,7 +3128,6 @@ en: all_projects: 'Search for "%{search_term}" in all projects' current_project: 'Search for "%{search_term}" in %{project_name}' project_and_subprojects: 'Search for "%{search_term}" in %{project_name} and all subprojects' - groups: edit: synchronized_groups: "Synchronized groups" @@ -3146,10 +3147,8 @@ en: user_count: User count users: no_results_title_text: There are currently no users part of this group. - gui_validation_error: "1 error" gui_validation_error_plural: "%{count} errors" - header: project_select_component: all_projects: "All projects" @@ -3157,7 +3156,6 @@ en: leave_project: "Leave project" no_favorite_projects: "You have no favorite projects." title: "Projects" - health_reports: common: checks: @@ -3186,7 +3184,6 @@ en: You can only select projects here where the %{plural} module is active. After creating a %{singular} you can add work packages from other projects to it. public: "Publish this view, allowing other users to access your view. Users with the 'Manage public views' permission can modify or remove public query. This does not affect the visibility of work package results in that view and depending on their permissions, users may see different results." - homescreen: additional: favorite_projects: @@ -3233,19 +3230,15 @@ en: missing_authorization: "The server side request failed due to missing authorization information." response: unexpected: "Unexpected response received." - image_conversion: imagemagick: "Imagemagick" - incoming_mails: ignore_filenames: > Specify a list of names to ignore when processing attachments for incoming mails (e.g., signatures or icons). Enter one filename per line. - instructions_after_error_link: "You can try to sign in again by clicking [here](signin_url). If the error persists, ask your admin for help." instructions_after_logout_link: "You can sign in again by clicking [here](signin_url)." instructions_after_registration_link: "You can sign in as soon as your account has been activated by clicking [here](signin_url)." - journals: cause_descriptions: budget_deleted: Budget has been deleted @@ -3335,7 +3328,6 @@ en: version: "a non-visible version" work_package: "a non-visible work package" label_accessibility: "Accessibility" - label_account: "Account" label_actions: "Actions" label_activate_user: "Activate user" @@ -3642,7 +3634,6 @@ en: label_internal: "Internal" label_introduction_video: "Getting started video" label_invitation: Invitation - label_invite_user: "Invite user" label_item: "Item" label_item_plural: "Items" @@ -4104,7 +4095,6 @@ en: other: "Time off: %{count} working days" label_yesterday: "yesterday" label_zen_mode: "Zen mode" - ldap_auth_sources: attribute_texts: admin_map_html: "Optional: The attribute key in LDAP that if present marks the OpenProject user an admin. Leave empty when in doubt." @@ -4187,6 +4177,22 @@ en: update: failure: "The modified list cannot be saved: %{errors}" success: "The modified list has been saved" + llm: + api_formats: + anthropic: "Anthropic" + azure: "Azure OpenAI" + bedrock: "AWS Bedrock" + deepseek: "DeepSeek" + gemini: "Google Gemini" + gpustack: "GPUStack" + mistral: "Mistral" + ollama: "Ollama" + openai: "OpenAI-compatible" + openrouter: "OpenRouter" + perplexity: "Perplexity" + vertexai: "Google Vertex AI" + xai: "xAI" + llm_connections: macro_execution_error: "Error executing the macro %{macro_name}" macro_unavailable: "Macro %{macro_name} cannot be displayed." macro_unknown: "Unknown or unsupported macro." @@ -4322,7 +4328,6 @@ en: without_message: "%{user} added you as a member to the project '%{project}'." roles: "You have the following roles:" subject: "%{project} - You have been added as a member" - mail_member_updated_global: body: roles: "You now have the following roles:" @@ -4330,7 +4335,6 @@ en: with_message: "%{user} updated the roles you have globally writing:" without_message: "%{user} updated the roles you have globally." subject: "Your global permissions have been updated" - mail_member_updated_project: body: roles: "You now have the following roles:" @@ -4350,7 +4354,6 @@ en: mail_subject_register: "Your %{value} account activation" mail_subject_wiki_content_added: "'%{id}' wiki page has been added" mail_subject_wiki_content_updated: "'%{id}' wiki page has been updated" - mail_user_activation_limit_reached: message_html: | A new user (%{email}) tried to create an account on an OpenProject environment that you manage (%{host}). @@ -4364,7 +4367,6 @@ en: server_url_component: caption: "The URL at which the OpenProject MCP server will be reachable. Required for setting up MCP clients." label: "Server URL" - members: columns: shared: "Shared" @@ -4426,20 +4428,18 @@ en: send_invite_to: "Send invite to" menu_item: "Menu item" menu_item_setting: "Visibility" - menus: admin: aggregation: "Aggregation" ai: "Artificial Intelligence (AI)" api_and_webhooks: "API and webhooks" + llm_connection: "LLM settings" mail_notification: "Email notifications" mails_and_notifications: "Emails and notifications" mcp_configurations: "Model Context Protocol (MCP)" quick_add: label: "Add…" - more_actions: "More functions" - my: access_token: created_dialog: @@ -4467,7 +4467,6 @@ en: password_confirmation_dialog: confirmation_required: "You need to enter your account password to confirm this change." title: "Confirm your password to continue" - my_account: access_tokens: access_tokens: "Access tokens" @@ -4607,21 +4606,17 @@ en: email_reminders: "Email reminders" notifications: "Notification settings" title: "Notification and email" - news: index: no_results_content_text: Add a news item no_results_title_text: There is currently no news to report. no_results_title_text: There is currently nothing to display. - noscript_description: "You need to activate JavaScript in order to use OpenProject!" noscript_heading: "JavaScript disabled" noscript_learn_more: "Learn more" note: Note - note_password_login_disabled_link: "Password login has been disabled through a [configuration setting](configuration_url)." nothing_to_preview: "Nothing to preview" - notice_accessibility_mode: The accessibility mode can be enabled in your [account settings](url). notice_account_activated: "Your account has been activated. You can now log in." notice_account_already_activated: The account has already been activated. @@ -4644,7 +4639,6 @@ en: notice_attachment_migration_wiki_page: > This page was generated automatically during the update of OpenProject. It contains all attachments previously associated with the %{container_type} "%{container_name}". - notice_auth_stage_error: "Authentication stage '%{stage}' failed." notice_auth_stage_verification_error: "Could not verify stage '%{stage}'." notice_auth_stage_wrong_stage: "Expected to finish authentication stage '%{expected}', but '%{actual}' returned." @@ -4656,7 +4650,6 @@ en: notice_email_sent: "An email was sent to %{value}" notice_failed_to_save_members: "Failed to save member(s): %{errors}." notice_failed_to_save_work_packages: "Failed to save %{count} work package(s) on %{total} selected: %{ids}." - notice_file_not_found: "The page you were trying to access doesn't exist or has been removed." notice_forced_logout: "You have been automatically logged out after %{ttl_time} minutes of inactivity." notice_internal_server_error: "An error occurred on the page you were trying to access. If you continue to experience problems please contact your %{app_title} administrator for assistance." @@ -4699,7 +4692,6 @@ en: notice_user_invitation_resent: An invitation has been sent to %{email}. notice_user_missing_authentication_method: User has yet to choose a password or another way to sign in. notice_wont_delete_auth_source: The LDAP connection cannot be deleted as long as there are still users using it. - notifications: facets: all: "All" @@ -4814,7 +4806,6 @@ en: api_v3_text: "Application will receive full read & write access to the OpenProject API v3 to perform actions on your behalf." mcp: "Access to MCP" mcp_text: "Application will receive access to the OpenProject MCP endpoints. They are a limited subset of APIv3, that's tailored for usage with AI agents." - oauth_client: errors: oauth_authorization_code_grant_had_errors: "OAuth2 Authorization grant unsuccessful" @@ -4862,13 +4853,11 @@ en: error: "Error" failed_authorization: "Authorization failed" not_connected: "Not connected" - onboarding: heading_getting_started: "Get an overview" select_language: "Please select your language" text_getting_started_description: "Get a quick overview of project management and team collaboration with OpenProject. You can restart this video from the help menu." welcome: "Welcome to %{app_title}" - op_dry_validation: errors: array?: "must be an array." @@ -4919,7 +4908,6 @@ en: short: "Short name" weight: "Weight" open_link_in_a_new_tab: "Open link in a new tab" - open_project: common: work_package_card_component: @@ -4931,7 +4919,6 @@ en: undisclosed: "Undisclosed" page: text: "Text" - pagination: label: "Pagination" next: "Next" @@ -4985,7 +4972,6 @@ en: permission_add_subprojects: "Create subprojects" permission_add_work_package_attachments: "Add attachments" permission_add_work_package_attachments_explanation: "Allows adding attachments without Edit work packages permission" - permission_add_work_package_comments: "Add comments" permission_add_work_package_watchers: "Add watchers" permission_add_work_packages: "Add work packages" @@ -5112,18 +5098,15 @@ en: right_to_manage_members_missing: > You are not allowed to delete the placeholder user. You do not have the right to manage members for all projects that the placeholder user is a member of. - placeholders: default: "-" templated_hint: Automatically generated through type %{type} - plugin_openproject_auth_plugins: description: "Integration of OmniAuth strategy providers for authentication in OpenProject." name: "OpenProject Auth Plugins" plugin_openproject_auth_saml: description: "Adds the OmniAuth SAML provider to OpenProject" name: "OmniAuth SAML / Single-Sign On" - portfolio: count: one: "1 portfolio" @@ -5150,7 +5133,6 @@ en: priority_color_text: | Click to assign or change the color of this priority. It can be used for highlighting work packages in the table. - program: count: one: "1 program" @@ -5158,7 +5140,6 @@ en: zero: "0 programs" filters: name: "Part of Program" - project: archive: archived: "Archived" @@ -5196,14 +5177,12 @@ en: make_template: "Set as template" remove_from_templates: "Remove from templates" use_template: "Use template" - project_module_activity: "Activity" project_module_forums: "Forums" project_module_news: "News" project_module_repository: "Repository" project_module_wiki: "Wiki" project_module_work_package_tracking: "Work packages" - projects: # Contains custom strings for options when copying a project that cannot be found elsewhere. copy: @@ -5413,7 +5392,6 @@ en: automatic: description: "Order the %{plural} by one or more sorting criteria. You will lose the previous sorting." heading: "Automatic" - query: attribute_and_direction: "%{attribute} (%{direction})" @@ -5438,12 +5416,10 @@ en: and_user: "and %{user}" react_with: "React with %{reaction}" reaction_by: "%{reaction} by" - reportings: index: no_results_content_text: Add a status reporting no_results_title_text: There are currently no status reportings. - repositories: at_identifier: "at %{identifier}" atom_revision_feed: "Atom revision feed" @@ -5551,7 +5527,6 @@ en: url: "URL to repository" warnings: cannot_annotate: "This file cannot be annotated." - roles: edit: default_for_new_projects_warning: >- @@ -5568,11 +5543,9 @@ en: matrix_check_uncheck_all_in_row_label_html: "Toggle %{permission} permission for all roles" matrix_checkbox_label: "Assign %{permission} permission to %{role} role" matrix_uncheck_all_label: "Unassign all %{module} permissions from all roles" - scheduling: automatic: "set to Automatic" manual: "set to Manual" - search_input_placeholder: "Search ..." setting_accessibility_mode_for_anonymous: "Enable accessibility mode for anonymous users" setting_activity_days_default: "Days displayed on project activity" @@ -5586,7 +5559,6 @@ en: Set a default path to redirect users after login, if no back link was provided. Redirects to home page if not set.
Example: %{example_code} - setting_allowed_link_protocols: "Allowed link protocols" setting_allowed_link_protocols_text_html: >- Allow these protocols to be rendered as links in work package descriptions, long text fields and comments. @@ -5835,7 +5807,6 @@ en: Maximum number of work packages or projects that can be included in a single export. Larger exports are truncated to this limit. setting_working_days: "Working days" - settings: antivirus: actions: @@ -6149,14 +6120,12 @@ en: Adding additional users will exceed the current limit. Please [upgrade your plan](upgrade_url) to be able to ensure external users are able to access this %{entity}. status_active: "active" - status_archived: "archived" status_blocked: "blocked" status_deleted: deleted status_invited: invited status_locked: locked status_registered: registered - statuses: edit: status_color_text: | @@ -6191,7 +6160,6 @@ en: sentence_connector: "and" skip_last_comma: "false" text_access_token_hint: "Access tokens allow you to grant external applications access to resources in OpenProject." - text_accessibility_hint: "The accessibility mode is designed for users who are blind, motorically handicaped or have a bad eyesight. For the latter focused elements are specially highlighted. Please notice, that the Backlogs module is not available in this mode." text_analyze: "Further analyze: %{subject}" text_are_you_sure: "Are you sure?" @@ -6353,7 +6321,6 @@ en: text_wrote: "wrote" text_zoom_in: "Zoom in" text_zoom_out: "Zoom out" - themes: dark: "Dark" light: "Light" @@ -6366,19 +6333,15 @@ en: short: "%d %b %H:%M" time: "%I:%M %p" pm: "pm" - timeframe: end: "to" show: "Show timeframe" start: "from" title_enterprise_upgrade: "Upgrade to unlock more users." - title_remove_and_delete_user: Remove the invited user from the project and delete him/her. - toggle_switch: label_off: "Off" label_on: "On" - token: hashed_token: display_value_placeholder: "***" @@ -6387,23 +6350,18 @@ en: If enabled a user will be unable to chose a login during registration. Instead their given email address will serve as the login. An administrator may still change the login separately. - tooltip_resend_invitation: > Sends another invitation email with a fresh token in case the old one expired or the user did not get the original email. Can also be used for active users to choose a new authentication method. When used with active users their status will be changed to 'invited'. - tooltip_user_default_timezone: > The default time zone for new users. Can be changed in a user's settings. - top_menu: additional_resources: "Additional resources" getting_started: "Getting started" help_and_support: "Help and support" - total_progress: "Total progress" - types: creation_wizard: add_variant: "Add variant to %{name}" @@ -6642,13 +6600,11 @@ en: one: "1 variant" other: "%{count} variants" milestone_indicator: "Milestone" - unsupported_browser: close_warning: "Ignore this warning." message: "You may run into errors and degraded experience on this page." title: "Your browser is outdated and unsupported." update_message: "Please update your browser." - user: activate: "Activate" activate_and_reset_failed_logins: "Activate and reset failed logins" @@ -6689,7 +6645,6 @@ en: user_preferences: disable_keyboard_shortcuts_caption: > You can choose to disable default [keyboard shortcuts](docs_url) if you use a screen reader or want to avoid accidentally triggering an action with a shortcut. - users: autologins: prompt: "Stay logged in for %{num_days}" @@ -6830,11 +6785,9 @@ en: one: "1 working day" other: "%{count} working days" work_hours: "Work hours" - version_status_closed: "closed" version_status_locked: "locked" version_status_open: "open" - versions: overview: no_results_title_text: There are currently no work packages assigned to this version. @@ -6862,7 +6815,6 @@ en: Please [upgrade your plan](upgrade_url) or block existing users in order to allow invited and registered users to join. warning_protocol_mismatch_html: > - warning_registration_token_expired: | The activation email has expired. We sent you a new one to %{email}. Please click the link inside of it to activate your account. @@ -6875,7 +6827,6 @@ en: warning_user_limit_reached_instructions: > You reached your user limit (%{current}/%{max} active users). Please contact sales@openproject.com to upgrade your Enterprise edition plan and add additional users. - wiki: index: no_results_content_text: Add a new wiki page @@ -6884,7 +6835,6 @@ en: page_not_editable_index: The requested page does not (yet) exist. You have been redirected to the index of all wiki pages. print_hint: This will print the content of this wiki page without any navigation bars. wiki_menu_item_delete_not_permitted: The wiki menu item of the only wiki page cannot be deleted. - wiki_menu_item_for: 'Menu item for wikipage "%{title}"' wiki_menu_item_new_main_item_explanation: > You are deleting the only main wiki menu item. You now have to choose a wiki page for which a new main item will be generated. @@ -7016,7 +6966,6 @@ en: relates_to_description: "Creates a visible link between the two work packages with no additional effect" required_description: "Marks this work package as being a requirement to the related one" requires_description: "Marks the related work package as a requirement to this one" - work_packages: bulk: copy_failed: "The work packages could not be copied." @@ -7184,7 +7133,6 @@ en: x_descendants: one: "One descendant work package" other: "%{count} work package descendants" - workflows: copies: form: @@ -7257,5 +7205,4 @@ en: warning: > Changing which days of the week are considered working days or non-working days can affect the start and finish days of all work packages and life cycles in all projects in this instance. - you: you diff --git a/config/routes.rb b/config/routes.rb index 0bf4e07cc385..db4fea142a69 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -742,6 +742,8 @@ end end + resource :llm_connection, only: %i[show update], controller: "admin/llm_connections" + resources :mcp_configurations, only: %i[index update], controller: "admin/mcp_configurations" do collection do post :multi_update diff --git a/frontend/src/stimulus/controllers/dynamic/admin/llm-connection-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/llm-connection-form.controller.ts new file mode 100644 index 000000000000..1ee8cc63ced1 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/llm-connection-form.controller.ts @@ -0,0 +1,69 @@ +//-- 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. +//++ + +import { Controller } from '@hotwired/stimulus'; + +// Saving the connection contacts the LLM server, which can take seconds. This +// gives the administrator feedback while that happens, and keeps the typed API +// key out of Turbo's page cache. +export default class extends Controller { + static targets = ['submitButton', 'progressBanner', 'secretInput']; + + declare readonly submitButtonTargets:HTMLButtonElement[]; + declare readonly progressBannerTarget:HTMLElement; + declare readonly secretInputTargets:HTMLInputElement[]; + + connect():void { + this.element.addEventListener('submit', this.showProgress); + document.addEventListener('turbo:before-cache', this.clearSecrets); + } + + disconnect():void { + this.element.removeEventListener('submit', this.showProgress); + document.removeEventListener('turbo:before-cache', this.clearSecrets); + } + + // Turbo caches the rendered page for the back button. Without this the typed + // API key would be restored into the DOM on navigating back. + private clearSecrets = ():void => { + this.secretInputTargets.forEach((input:HTMLInputElement) => { + input.value = ''; + }); + }; + + private showProgress = ():void => { + this.progressBannerTarget.hidden = false; + + // aria-disabled rather than disabled: disabling a button while it holds + // focus moves focus to , silently dropping keyboard users to the top + // of the page. It also keeps the button in the submitted form data. + this.submitButtonTargets.forEach((button:HTMLButtonElement) => { + button.setAttribute('aria-disabled', 'true'); + }); + }; +} diff --git a/spec/contracts/llm_connections/update_contract_spec.rb b/spec/contracts/llm_connections/update_contract_spec.rb new file mode 100644 index 000000000000..eb5363793a47 --- /dev/null +++ b/spec/contracts/llm_connections/update_contract_spec.rb @@ -0,0 +1,183 @@ +# 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" +require_relative "../shared/model_contract_shared_context" + +RSpec.describe LlmConnections::UpdateContract, :check_errors_i18n, :llm_server_helpers, :webmock do + include_context "ModelContract shared context" + + let(:current_user) { build_stubbed(:admin) } + let(:base_url) { "https://example.com/v1" } + # Persisted, so that only the attributes an example changes are dirty. A + # freshly built record has every factory attribute in changed_attributes, + # which the contract would rightly reject as writes to non-writable columns. + let(:connection) { create(:llm_connection, base_url: "https://previous.example/v1") } + let(:contract) { described_class.new(connection, current_user) } + + let!(:models_request) { mock_llm_models_response(base_url) } + + before { connection.base_url = base_url } + + context "when the server answers with a model list" do + include_examples "contract is valid" + + it "probes the server exactly once" do + contract.validate + + expect(models_request).to have_been_made.once + end + end + + context "when the server rejects the credentials" do + let!(:models_request) { mock_llm_models_response(base_url, response_code: 401) } + + include_examples "contract is invalid", api_key: :invalid_api_key + end + + context "when the server cannot be reached" do + let!(:models_request) { mock_llm_models_response(base_url, timeout: true) } + + include_examples "contract is invalid", base_url: :request_timed_out + end + + # A server can speak the OpenAI API for chat and still not expose a model list: + # OpenProject's own hosted gateway does exactly that. Blocking the save would + # leave the administrator unable to configure a working connection at all. + [404, 405, 501].each do |status| + context "when the server answers #{status} for the model list" do + let!(:models_request) { mock_llm_models_response(base_url, response_code: status) } + + include_examples "contract is valid" + end + end + + # Formats whose model list comes from the registry have nothing to probe here. + context "with a format that does not discover models from the server" do + let(:connection) { create(:llm_connection, api_format: "anthropic", base_url: "https://previous.example") } + + it "does not contact the base URL" do + contract.validate + + expect(models_request).not_to have_been_made + end + end + + context "when the endpoint is not OpenAI-compatible" do + let!(:models_request) { mock_llm_models_response(base_url, body: "login") } + + include_examples "contract is invalid", base_url: :not_openai_compatible + end + + context "when the host does not resolve at all" do + before { stub_llm_dns(unresolvable: ["example.com"]) } + + include_examples "contract is invalid", base_url: :cannot_be_connected_to + + it "does not contact the server" do + contract.validate + + expect(models_request).not_to have_been_made + end + end + + context "when the host is blocked by the SSRF policy" do + before { allow_llm_host("something.else") } + + include_examples "contract is invalid", base_url: :ssrf_filtered + + it "does not contact the server" do + contract.validate + + expect(models_request).not_to have_been_made + end + end + + context "when the base URL is not a URL at all" do + let(:base_url) { "not a url" } + + # The validate_url gem always records :url in errors.details; the + # message: :invalid_url option controls the rendered text, not the symbol. + include_examples "contract is invalid", base_url: :url + + it "does not contact the server" do + contract.validate + + expect(models_request).not_to have_been_made + end + end + + # Without the changed-attributes guard every unrelated save -- and every form + # render that builds a model through SetAttributesService -- would fire an + # outbound request at the LLM server. + describe "the changed-attributes guard" do + it "probes only when the credentials changed" do + contract.validate + expect(models_request).to have_been_made.once + + WebMock.reset_executed_requests! + connection.save! + contract.validate + + expect(models_request).not_to have_been_made + end + + it "probes again when only the API key changed" do + connection.save! + WebMock.reset_executed_requests! + + connection.api_key = "sk-rotated" + contract.validate + + expect(models_request).to have_been_made.once + end + + # Switching the dialect is switching the server, even at the same URL: a + # connection previously talking to a registry-backed provider must be proven + # again when it becomes OpenAI-compatible. + it "probes when only the API format changed to an OpenAI-compatible one" do + connection.update_columns(api_format: "anthropic", base_url: base_url) + connection.reload.api_format = "openai" + + expect(contract.validate).to be(true) + expect(models_request).to have_been_made.once + end + + it "does not probe when only the enabled flag changed" do + connection.save! + WebMock.reset_executed_requests! + + connection.enabled = true + + expect(contract.validate).to be(true) + expect(models_request).not_to have_been_made + end + end +end diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb new file mode 100644 index 000000000000..e9f69d65937c --- /dev/null +++ b/spec/features/admin/llm_connection_spec.rb @@ -0,0 +1,67 @@ +# 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" + +# The markup on these pages is hand-written rather than generated, so this spec +# exists mainly to put it through axe. The row toggle in particular has no +# accessible name of its own and depends on an explicit aria-label. +# :selenium is required, not incidental: axe-core-api drives the browser through +# Selenium's #manage API, so be_axe_clean does not work under cuprite. Every other +# axe spec in this repository is tagged the same way for the same reason. +RSpec.describe "LLM connection administration", + :js, :llm_server_helpers, :selenium, :webmock, + driver: :firefox_de, + with_flag: { llm_connection: true } do + shared_let(:admin) { create(:admin) } + + let(:base_url) { "https://example.com/v1" } + + current_user { admin } + + # The kebab is a Primer ActionMenu: clicking it before its behaviour is + # attached silently does nothing, so wait for the page to settle first and + # for the item itself to become visible. + def choose_action(item) + expect(page).to have_test_selector("llm-connection--actions") + find_test_selector("llm-connection--actions").click + expect(page).to have_test_selector(item) + find_test_selector(item).click + end + + context "when nothing is configured yet" do + it "renders an accessible, empty settings page" do + visit llm_connection_path + + expect(page).to have_field("Host URL") + expect(page).to be_axe_clean.within("#content") + end + end +end diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb new file mode 100644 index 000000000000..d7e0c39613df --- /dev/null +++ b/spec/requests/admin/llm_connections_spec.rb @@ -0,0 +1,129 @@ +# 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 "Admin LLM connection", :llm_server_helpers, :skip_csrf, :webmock, + type: :rails_request, with_flag: { llm_connection: true } do + let(:admin) { create(:admin) } + let(:non_admin) { create(:user) } + let(:base_url) { "https://example.com/v1" } + + describe "with the feature flag off", with_flag: { llm_connection: false } do + before { login_as admin } + + it "does not expose the endpoints" do + get llm_connection_path + expect(response).to have_http_status(:not_found) + + patch llm_connection_path, params: { llm_connection: { base_url: } } + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /admin/llm_connection" do + it "is not reachable for non-admins" do + login_as non_admin + get llm_connection_path + + expect(response).not_to have_http_status(:ok) + end + + context "when logged in as admin" do + before { login_as admin } + + it "renders without a connection configured" do + get llm_connection_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Host URL") + end + end + end + + describe "PATCH /admin/llm_connection" do + before { login_as admin } + + context "with a reachable server" do + let!(:models_request) { mock_llm_models_response(base_url) } + + it "stores the connection and caches the catalogue" do + patch llm_connection_path, params: { llm_connection: { base_url:, api_key: "sk-test" } } + + expect(response).to have_http_status(:see_other) + connection = LlmConnection.first + expect(connection.base_url).to eq(base_url) + expect(connection.api_key).to eq("sk-test") + end + end + + # The case that matters for OpenProject's own gateway: chat completions are + # routed, the model list is not. + + context "with an unreachable server" do + let!(:models_request) { mock_llm_models_response(base_url, timeout: true) } + + it "persists nothing" do + patch llm_connection_path, params: { llm_connection: { base_url:, api_key: "sk-test" } } + + expect(LlmConnection.count).to eq(0) + end + end + + # Reachability and credentials still gate the save; only the model list is + # treated as optional. + context "with rejected credentials" do + let!(:models_request) { mock_llm_models_response(base_url, response_code: 401) } + + it "persists nothing" do + patch llm_connection_path, params: { llm_connection: { base_url:, api_key: "sk-wrong" } } + + expect(LlmConnection.count).to eq(0) + end + end + + context "when an API key is already stored" do + let!(:connection) { create(:llm_connection, base_url:, api_key: "sk-original") } + let!(:models_request) { mock_llm_models_response(base_url) } + + it "keeps the stored key when the field is submitted blank" do + patch llm_connection_path, params: { llm_connection: { base_url:, api_key: "" } } + + expect(connection.reload.api_key).to eq("sk-original") + end + + it "replaces the stored key when a new one is submitted" do + patch llm_connection_path, params: { llm_connection: { base_url:, api_key: "sk-rotated" } } + + expect(connection.reload.api_key).to eq("sk-rotated") + end + end + end +end