From 69c1c4ed6b58ce2944308ae1c57aebdae521cd5d Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Sat, 22 Aug 2026 18:12:58 +0200 Subject: [PATCH 1/5] [AI-3] Provision the LLM connection from the environment Adds the OPENPROJECT_LLM__CONNECTION setting and an EnvData seeder, so an on-premise deployment can ship the connection alongside its LLM sidecar without touching the UI. When the setting is present the record belongs to the deployment: the form renders read-only with a banner, the destructive actions refuse, and the base contract rejects any write. Seeding must not block on -- or fail because of -- a server that has not finished starting, so the environment path validates with its own contract that skips the reachability probe, and refreshes the catalogue through a background job instead of inline. Part 9 of the AI-3 stack. https://community.openproject.org/work_packages/66020 --- .../llm_connections/form_component.html.erb | 8 ++ .../llm_connections/base_contract.rb | 7 ++ .../environment_update_contract.rb | 47 ++++++++++ .../admin/llm_connections_controller.rb | 7 ++ app/forms/llm_connections/connection_form.rb | 26 ++++-- app/models/llm_connection.rb | 4 + app/seeders/env_data/llm_connection_seeder.rb | 87 +++++++++++++++++++ app/seeders/env_data_seeder.rb | 1 + .../llm_connections/env_sync_service.rb | 69 +++++++++++++++ .../llm_connections/update_service.rb | 10 +++ app/views/admin/llm_connections/show.html.erb | 4 +- app/workers/llm/sync_models_job.rb | 10 ++- config/constants/settings/definition.rb | 7 ++ config/locales/en.yml | 2 + .../configuration/environment/README.md | 1 + spec/requests/admin/llm_connections_spec.rb | 20 +++++ .../env_data/llm_connection_seeder_spec.rb | 81 +++++++++++++++++ 17 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 app/contracts/llm_connections/environment_update_contract.rb create mode 100644 app/seeders/env_data/llm_connection_seeder.rb create mode 100644 app/services/llm_connections/env_sync_service.rb create mode 100644 spec/seeders/env_data/llm_connection_seeder_spec.rb diff --git a/app/components/llm_connections/form_component.html.erb b/app/components/llm_connections/form_component.html.erb index 35a5273aa4aa..401606ee473a 100644 --- a/app/components/llm_connections/form_component.html.erb +++ b/app/components/llm_connections/form_component.html.erb @@ -1,5 +1,13 @@ <%= component_wrapper(tag: "turbo-frame", **wrapper_options) do + if connection.configured_from_env? + concat( + render(Primer::Alpha::Banner.new(mb: 3, icon: :info)) do + t("admin.banners.environment_configured_readonly") + end + ) + end + concat( settings_primer_form_with(**form_options) do |f| render(LlmConnections::ConnectionForm.new(f)) diff --git a/app/contracts/llm_connections/base_contract.rb b/app/contracts/llm_connections/base_contract.rb index f4c12e335379..9865a6b8eb39 100644 --- a/app/contracts/llm_connections/base_contract.rb +++ b/app/contracts/llm_connections/base_contract.rb @@ -54,6 +54,13 @@ class BaseContract < ModelContract validate :features_require_connection validate :default_models_offered_by_server validate :default_chat_model_can_chat + validate :not_configured_from_env + + def not_configured_from_env + return unless model.configured_from_env? + + errors.add :base, :configured_via_env + end private diff --git a/app/contracts/llm_connections/environment_update_contract.rb b/app/contracts/llm_connections/environment_update_contract.rb new file mode 100644 index 000000000000..72529132bef7 --- /dev/null +++ b/app/contracts/llm_connections/environment_update_contract.rb @@ -0,0 +1,47 @@ +# 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 + # Used when the connection is provisioned from the environment. + # + # Inherits from BaseContract, not UpdateContract: seeding must never reach out + # to the LLM server, because the container it runs in may well start before the + # server does. It also lifts the "configured from environment is read-only" + # guard, since this is the code path that legitimately writes those values. + class EnvironmentUpdateContract < BaseContract + def not_configured_from_env = nil + + # On a fresh installation the seed runs before any model synchronisation, so + # there is no catalogue to validate a default model against. A wrong id is + # surfaced afterwards, the same way as a model that vanished: the binding + # shows as no longer offered. + def default_models_offered_by_server = nil + end +end diff --git a/app/controllers/admin/llm_connections_controller.rb b/app/controllers/admin/llm_connections_controller.rb index 50061aebde98..a9eb6659bddd 100644 --- a/app/controllers/admin/llm_connections_controller.rb +++ b/app/controllers/admin/llm_connections_controller.rb @@ -57,6 +57,8 @@ def disconnect_dialog # Clears the credential and switches the AI features off, keeping the endpoint # and the catalogue. Deliberately not a destroy. def disconnect + return redirect_with_error(t(".configured_from_env")) if @connection.configured_from_env? + ApplicationRecord.transaction do @connection.update!(api_key: nil) Setting.llm_features_enabled = false @@ -69,7 +71,12 @@ def delete_api_key_dialog respond_with_dialog LlmConnections::DeleteApiKeyDialogComponent.new(@connection) end + # The environment guard is checked explicitly because this write bypasses + # the contract: removing a credential must always be possible, even against + # a server that would reject the resulting unauthenticated probe. def delete_api_key + return redirect_with_error(t(".configured_from_env")) if @connection.configured_from_env? + @connection.update!(api_key: nil) redirect_with_notice(t(".success")) diff --git a/app/forms/llm_connections/connection_form.rb b/app/forms/llm_connections/connection_form.rb index 8669f08b9968..fecab4894b63 100644 --- a/app/forms/llm_connections/connection_form.rb +++ b/app/forms/llm_connections/connection_form.rb @@ -35,6 +35,7 @@ class ConnectionForm < ApplicationForm name: :llm_features_enabled, label: LlmConnection.human_attribute_name(:llm_features_enabled), caption: I18n.t("admin.llm_connections.form.llm_features_enabled_caption"), + disabled: read_only?, data: { target_name: "llm_features_enabled", show_when_checked_target: "cause" } ) @@ -55,6 +56,7 @@ class ConnectionForm < ApplicationForm caption: I18n.t("admin.llm_connections.form.api_format_caption"), include_blank: false, input_width: :medium, + disabled: read_only?, data: { target_name: "llm_connection_api_format", show_when_value_selected_target: "cause" } ) do |select| supported_formats.each do |format| @@ -69,7 +71,8 @@ class ConnectionForm < ApplicationForm placeholder: "https://example.com/v1", required: true, type: :url, - input_width: :large + input_width: :large, + disabled: read_only? ) fg.group(layout: :horizontal) do |row| @@ -85,10 +88,11 @@ class ConnectionForm < ApplicationForm type: :password, autocomplete: "off", input_width: :large, + disabled: read_only?, data: { "admin--llm-connection-form-target": "secretInput" } ) - if model.api_key_stored? + if model.api_key_stored? && !read_only? row.button( name: :remove_api_key, tag: :a, @@ -103,16 +107,22 @@ class ConnectionForm < ApplicationForm end end - f.submit( - name: :submit, - label: submit_label, - scheme: :primary, - data: { "admin--llm-connection-form-target": "submitButton" } - ) + unless read_only? + f.submit( + name: :submit, + label: submit_label, + scheme: :primary, + data: { "admin--llm-connection-form-target": "submitButton" } + ) + end end private + def read_only? + model.configured_from_env? + end + # 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. def supported_formats diff --git a/app/models/llm_connection.rb b/app/models/llm_connection.rb index dc76772937f7..37e5d0c7a0f3 100644 --- a/app/models/llm_connection.rb +++ b/app/models/llm_connection.rb @@ -81,6 +81,10 @@ def configured? base_url.present? end + def configured_from_env? + Setting.llm_connection.present? + end + # Every model that can be addressed today: discovered and still offered, plus # anything an administrator entered by hand. # diff --git a/app/seeders/env_data/llm_connection_seeder.rb b/app/seeders/env_data/llm_connection_seeder.rb new file mode 100644 index 000000000000..c62948cf72b7 --- /dev/null +++ b/app/seeders/env_data/llm_connection_seeder.rb @@ -0,0 +1,87 @@ +# 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 EnvData + # Provisions the LLM connection from OPENPROJECT_LLM__CONNECTION_* variables so + # a container comes up connected without anyone opening the administration UI. + # + # Never contacts the LLM server: the catalogue refresh is enqueued, so seeding + # succeeds even when the server starts after OpenProject does. + class LlmConnectionSeeder < Seeder + KNOWN_KEYS = %w[base_url api_key default_chat_model default_embedding_model enabled].freeze + + def seed_data! + print_status " ↳ Creating LLM connection" do + validate_options!(config) + + result = LlmConnections::EnvSyncService.new(config).call + raise result.errors.full_messages.join(", ") if result.failure? + + Llm::SyncModelsJob.perform_later + end + end + + def applicable? + config.present? + end + + def not_applicable_message + "No LLM connection configured through environment variables." + end + + private + + def config + Setting.llm_connection + end + + def validate_options!(options) + check_unknown_keys!(options, KNOWN_KEYS) + return if options["base_url"].present? + + raise "LLM connection: #{env_form('base_url')} is required." + end + + def check_unknown_keys!(options, known_keys) + unknown = options.keys - known_keys + return if unknown.empty? + + raise <<~MSG.strip + LLM connection: unknown configuration key(s): #{unknown.map { |k| env_form(k) }.join(', ')}. + Accepted keys: #{known_keys.map { |k| env_form(k) }.join(', ')}. + Note: in environment variable names, single underscores split path segments and double underscores encode a literal underscore (e.g. BASE__URL, not BASE_URL). + MSG + end + + def env_form(key) + key.gsub("_", "__").upcase + end + end +end diff --git a/app/seeders/env_data_seeder.rb b/app/seeders/env_data_seeder.rb index 48a5959cced8..91300692b014 100644 --- a/app/seeders/env_data_seeder.rb +++ b/app/seeders/env_data_seeder.rb @@ -31,6 +31,7 @@ def data_seeder_classes [ EnvData::CustomDesignSeeder, EnvData::LdapSeeder, + EnvData::LlmConnectionSeeder, EnvData::ScimClientSeeder, EnvData::TokenSeeder ] diff --git a/app/services/llm_connections/env_sync_service.rb b/app/services/llm_connections/env_sync_service.rb new file mode 100644 index 000000000000..9b7c0184bfa8 --- /dev/null +++ b/app/services/llm_connections/env_sync_service.rb @@ -0,0 +1,69 @@ +# 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 + # Applies an environment-provided configuration to the connection record. + # + # Uses EnvironmentUpdateContract, which lifts the "configured from environment + # is read-only" guard and, crucially, does not probe the LLM server: the + # container running the seed may well start before the server does. + class EnvSyncService + def initialize(env_config) + @config = env_config.deep_symbolize_keys + end + + def call + UpdateService + .new(user: User.system, + model: LlmConnection.instance, + contract_class: EnvironmentUpdateContract, + sync_models: false) + .call(**attributes) + end + + private + + attr_reader :config + + # Absent keys are written as nil on purpose: the environment is the source + # of truth here, and the form is read-only while it is. Keeping a stored + # value that was removed from the environment would leave, for example, an + # obsolete API key in use with no supported way to clear it. + def attributes + { + base_url: config.fetch(:base_url), + api_key: config[:api_key], + default_chat_model_id: config[:default_chat_model], + default_embedding_model_id: config[:default_embedding_model], + enabled: ActiveRecord::Type::Boolean.new.deserialize(config.fetch(:enabled, true)) + } + end + end +end diff --git a/app/services/llm_connections/update_service.rb b/app/services/llm_connections/update_service.rb index 2b77bcb2f979..59087f74818b 100644 --- a/app/services/llm_connections/update_service.rb +++ b/app/services/llm_connections/update_service.rb @@ -30,6 +30,15 @@ module LlmConnections class UpdateService < BaseServices::Update + # @param sync_models [Boolean] whether to refresh the model catalogue inline + # after a successful save. Provisioning from the environment passes false: + # seeding must not block on an LLM server that has not started yet, and + # enqueues Llm::SyncModelsJob instead. + def initialize(*, sync_models: true, **) + super(*, **) + @sync_models = sync_models + end + private # The contract has already proven the server reachable when the credentials @@ -40,6 +49,7 @@ def after_perform(service_call) next unless service_call.success? Setting.llm_features_enabled = model.llm_features_enabled + next unless @sync_models next unless initial_fill?(service_call.result) SyncModelsService.new(service_call.result).call diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb index 3cda79136b33..0734814891be 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -50,7 +50,9 @@ See COPYRIGHT and LICENSE files for more details. ) render_tab_header_nav(header, llm_settings_tabs(@connection), test_selector: "llm-settings--tabs") - if @connection.persisted? + # Nothing here applies to a connection provisioned from the environment: + # those settings belong to the deployment, not to the administrator. + if @connection.persisted? && !@connection.configured_from_env? header.with_action_menu( menu_arguments: { anchor_align: :end }, button_arguments: { diff --git a/app/workers/llm/sync_models_job.rb b/app/workers/llm/sync_models_job.rb index 9cb300e24624..3cc043c8ebf2 100644 --- a/app/workers/llm/sync_models_job.rb +++ b/app/workers/llm/sync_models_job.rb @@ -34,9 +34,17 @@ module Llm # Used by the environment seeder, which must not block on -- or fail because of # -- an LLM server that has not finished starting. class SyncModelsJob < ApplicationJob + class SyncFailed < StandardError; end + + # The usual reason for a failure here is the startup race with the LLM + # sidecar this job exists for, so a failed sync retries with backoff rather + # than leaving the provisioned connection without its catalogue. + retry_on SyncFailed, wait: :polynomially_longer, attempts: 10 + def perform LlmConnection.find_each do |connection| - LlmConnections::SyncModelsService.new(connection).call + result = LlmConnections::SyncModelsService.new(connection).call + raise SyncFailed, result.errors.to_s unless result.success? end end end diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index d52f07c9c979..139418621537 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -731,6 +731,13 @@ class Definition format: :boolean, default: false }, + llm_connection: { + description: "Configure the connection to an OpenAI-API-compatible LLM server through environment variables", + writable: false, + default: {}, + format: :hash, + string_values: true + }, llm_features_enabled: { description: "Enable the AI features backed by the configured LLM connection", format: :boolean, diff --git a/config/locales/en.yml b/config/locales/en.yml index 42a20aaae861..3dc66f463bce 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1715,6 +1715,7 @@ en: llm_connections: delete_api_key: + configured_from_env: "This connection is configured through the environment and cannot be changed here." description: "OpenProject will stop sending an API key with its requests. Features will keep working only if the server requires no authentication. You can enter a new key at any time." heading: "Remove the stored API key?" menu_label: "Remove API key" @@ -1722,6 +1723,7 @@ en: title: "Remove API key" disabled_notice: "LLM features are switched off for this instance. Switch them on here first." disconnect: + configured_from_env: "This connection is configured through the environment and cannot be changed here." description: "OpenProject will stop using the LLM server. AI features will be unavailable until you connect again." heading: "Disconnect from the LLM server?" keeps_models: "The model list, including any models you added manually, is kept." diff --git a/docs/installation-and-operations/configuration/environment/README.md b/docs/installation-and-operations/configuration/environment/README.md index ed3e2ac89c74..518378410ee6 100644 --- a/docs/installation-and-operations/configuration/environment/README.md +++ b/docs/installation-and-operations/configuration/environment/README.md @@ -286,6 +286,7 @@ OPENPROJECT_LDAP__FORCE__NO__PAGE (default=nil) Force LDAP to respond as a singl OPENPROJECT_LDAP__GROUPS__DISABLE__SYNC__JOB (default=false) Deactivate regular synchronization job for groups in case scheduled as a separate cronjob OPENPROJECT_LDAP__USERS__DISABLE__SYNC__JOB (default=false) Deactivate user attributes synchronization from LDAP OPENPROJECT_LDAP__USERS__SYNC__STATUS (default=false) Enable user status (locked/unlocked) synchronization from LDAP +OPENPROJECT_LLM__CONNECTION (default={}) Configure the connection to an OpenAI-API-compatible LLM server through environment variables OPENPROJECT_LOG__LEVEL (default="info") Set the OpenProject logger level OPENPROJECT_LOG__REQUESTING__USER (default=false) Log user login, name, and mail address for all requests OPENPROJECT_LOGIN__REQUIRED (default=true) Authentication required diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 52b4c3088fb6..ccf1e3e54f54 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -304,6 +304,17 @@ expect(response.body).not_to include("llm-connection--delete-api-key") expect(page).to have_no_css(remove_api_key, visible: :all) end + + # update! bypasses the contract, so without the explicit guard a + # hand-crafted request could wipe a key the environment owns. + it "refuses when the connection comes from the environment" do + connection = create(:llm_connection, base_url:, api_key: "sk-original") + allow(Setting).to receive(:llm_connection).and_return({ "base_url" => base_url }) + + delete api_key_llm_connection_path + + expect(connection.reload.api_key).to eq("sk-original") + end end describe "GET /admin/llm_connection/delete_api_key_dialog" do @@ -352,6 +363,15 @@ expect(connection.models.count).to eq(2) end + it "refuses when the connection comes from the environment" do + allow(Setting).to receive(:llm_connection).and_return({ "base_url" => "https://example.com/v1" }) + + post disconnect_llm_connection_path + + expect(connection.reload.api_key).to eq("sk-test") + expect(connection).to be_enabled + end + it "is refused to a non-admin" do login_as create(:user) diff --git a/spec/seeders/env_data/llm_connection_seeder_spec.rb b/spec/seeders/env_data/llm_connection_seeder_spec.rb new file mode 100644 index 000000000000..f1bc7a834e12 --- /dev/null +++ b/spec/seeders/env_data/llm_connection_seeder_spec.rb @@ -0,0 +1,81 @@ +# 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 EnvData::LlmConnectionSeeder do + subject(:seed) { described_class.new(seed_data).seed! } + + let(:seed_data) { Source::SeedData.new({}) } + + it "does not seed a connection without configuration" do + expect { seed }.not_to change(LlmConnection, :count) + end + + # On a fresh installation the seed runs before any model synchronisation, so + # a configured default model cannot be validated against a catalogue yet. + # Provisioning must still complete; a wrong id surfaces later as dangling. + context "with a default model configured on a fresh installation", with_settings: { + llm_connection: { + "base_url" => "https://example.com/v1", + "api_key" => "sk-from-env", + "default_chat_model" => "qwen3.6-35b-a3b" + } + } do + it "seeds the connection without contacting the server" do + expect { seed }.to change(LlmConnection, :count).from(0).to(1) + + connection = LlmConnection.first + expect(connection.default_chat_model_id).to eq("qwen3.6-35b-a3b") + expect(connection.api_key).to eq("sk-from-env") + end + end + + # The environment is the source of truth while the form is read-only under it, + # so a value removed from the environment must not linger in the database. + context "when a previously set key is removed from the environment", with_settings: { + llm_connection: { "base_url" => "https://example.com/v1" } + } do + before do + create(:llm_connection, base_url: "https://example.com/v1", + api_key: "sk-stale", + default_chat_model_id: "old-default") + end + + it "clears the values the environment no longer provides" do + expect { seed }.not_to change(LlmConnection, :count) + + connection = LlmConnection.first + expect(connection.api_key).to be_nil + expect(connection.default_chat_model_id).to be_nil + expect(connection.base_url).to eq("https://example.com/v1") + end + end +end From a4f8217dd4ce0c2e69b0a8fa6f1907da608ec2ea Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Mon, 7 Sep 2026 15:41:12 +0200 Subject: [PATCH 2/5] [AI-3] Fill the catalogue once when provisioning from the environment The seeder runs on every container start, so enqueuing a refresh there undid whatever an administrator had curated, and could empty the list when the new server answers nothing. Following the UX review with Tom, the environment path now follows the same rule as the administration page: fetch the model list once while nothing is stored, and leave a stored list alone. A host or key changed in the environment shows up as the stale warning on the AI models page, which asks for the refresh. --- app/seeders/env_data/llm_connection_seeder.rb | 6 +++-- .../env_data/llm_connection_seeder_spec.rb | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/seeders/env_data/llm_connection_seeder.rb b/app/seeders/env_data/llm_connection_seeder.rb index c62948cf72b7..6033ac4691e4 100644 --- a/app/seeders/env_data/llm_connection_seeder.rb +++ b/app/seeders/env_data/llm_connection_seeder.rb @@ -33,7 +33,9 @@ module EnvData # a container comes up connected without anyone opening the administration UI. # # Never contacts the LLM server: the catalogue refresh is enqueued, so seeding - # succeeds even when the server starts after OpenProject does. + # succeeds even when the server starts after OpenProject does. It is enqueued + # only while nothing is stored: a re-seed against another server must not + # discard a list an administrator has curated. class LlmConnectionSeeder < Seeder KNOWN_KEYS = %w[base_url api_key default_chat_model default_embedding_model enabled].freeze @@ -44,7 +46,7 @@ def seed_data! result = LlmConnections::EnvSyncService.new(config).call raise result.errors.full_messages.join(", ") if result.failure? - Llm::SyncModelsJob.perform_later + Llm::SyncModelsJob.perform_later if result.result.models.none? end end diff --git a/spec/seeders/env_data/llm_connection_seeder_spec.rb b/spec/seeders/env_data/llm_connection_seeder_spec.rb index f1bc7a834e12..695c6beea876 100644 --- a/spec/seeders/env_data/llm_connection_seeder_spec.rb +++ b/spec/seeders/env_data/llm_connection_seeder_spec.rb @@ -56,6 +56,30 @@ expect(connection.default_chat_model_id).to eq("qwen3.6-35b-a3b") expect(connection.api_key).to eq("sk-from-env") end + + it "enqueues the initial catalogue fill" do + expect { seed }.to have_enqueued_job(Llm::SyncModelsJob) + end + end + + # The seeder runs on every container start, so a refresh here would repeatedly + # overwrite a list an administrator has curated. The stale warning on the AI + # models page asks for the refresh instead. + context "when the environment moves a stored catalogue to another host", with_settings: { + llm_connection: { "base_url" => "https://other.example.com/v1", "api_key" => "sk-from-env" } + } do + let!(:connection) do + create(:llm_connection, :with_models, base_url: "https://example.com/v1", api_key: "sk-from-env") + .tap { |record| record.update!(connection_fingerprint: record.settings_fingerprint) } + end + + it "keeps the stored models and flags them as stale" do + expect { seed }.not_to have_enqueued_job(Llm::SyncModelsJob) + + expect(connection.reload.base_url).to eq("https://other.example.com/v1") + expect(connection.models.count).to eq(2) + expect(connection).to be_models_stale + end end # The environment is the source of truth while the form is read-only under it, From 3eb728f10008ca724f3081aa4a54b1bf804d759f Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Mon, 7 Sep 2026 16:23:47 +0200 Subject: [PATCH 3/5] [AI-3] Keep the default models read-only under the environment The default chat model moved off the LLM settings page onto the AI models page in the UX review with Tom, and it moved out of reach of the environment banner with it. It now renders like the server settings do when the deployment owns the connection: the picker disabled, the banner above it, no Save button that the base contract would only reject. The autocompleter takes its disabled flag through autocomplete_options, which is what reaches the Angular component; the field-level flag it had before never left the Ruby side. The API key caption follows the same idea: asking an administrator to leave the field blank to keep the current key makes no sense where nothing can be typed, so it says where the key comes from instead. --- .../default_models_component.html.erb | 4 +++ app/forms/llm_connections/connection_form.rb | 2 ++ .../llm_connections/default_models_form.rb | 7 ++++- config/locales/en.yml | 1 + spec/requests/admin/llm_connections_spec.rb | 26 +++++++++++++++++++ spec/requests/admin/llm_models_spec.rb | 20 ++++++++++++++ 6 files changed, 59 insertions(+), 1 deletion(-) diff --git a/app/components/llm_connections/default_models_component.html.erb b/app/components/llm_connections/default_models_component.html.erb index 7293172f74a0..42a22a12dc26 100644 --- a/app/components/llm_connections/default_models_component.html.erb +++ b/app/components/llm_connections/default_models_component.html.erb @@ -35,6 +35,10 @@ See COPYRIGHT and LICENSE files for more details. end %> + <% if connection.configured_from_env? %> + <%= render(Primer::Alpha::Banner.new(mb: 3, icon: :info)) { t("admin.banners.environment_configured_readonly") } %> + <% end %> + <%= settings_primer_form_with(**form_options) do |f| %> <%= render(LlmConnections::DefaultModelsForm.new(f)) %> <% end %> diff --git a/app/forms/llm_connections/connection_form.rb b/app/forms/llm_connections/connection_form.rb index fecab4894b63..29f905ee14c1 100644 --- a/app/forms/llm_connections/connection_form.rb +++ b/app/forms/llm_connections/connection_form.rb @@ -157,6 +157,8 @@ def submit_label end def api_key_caption + return I18n.t("admin.llm_connections.form.api_key_caption_env") if read_only? + I18n.t("admin.llm_connections.form.api_key_caption#{'_stored' if model.api_key_stored?}") end diff --git a/app/forms/llm_connections/default_models_form.rb b/app/forms/llm_connections/default_models_form.rb index 56e1c692785f..a8a049c54ed4 100644 --- a/app/forms/llm_connections/default_models_form.rb +++ b/app/forms/llm_connections/default_models_form.rb @@ -41,6 +41,7 @@ class DefaultModelsForm < ApplicationForm caption: I18n.t("admin.llm_models.defaults.chat_caption"), autocomplete_options: { decorated: true, + disabled: read_only?, inputValue: model.default_chat_model_id, placeholder: I18n.t("label_none_parentheses") } @@ -54,11 +55,15 @@ class DefaultModelsForm < ApplicationForm end end - f.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) + f.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) unless read_only? end private + def read_only? + model.configured_from_env? + end + # The one already chosen is kept regardless of what the server offers today: # dropping it would silently blank the field on the next save. def default_chat_model_options diff --git a/config/locales/en.yml b/config/locales/en.yml index 3dc66f463bce..36f7c21ea2ee 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1734,6 +1734,7 @@ en: form: api_format_caption: "Which API the server speaks. Choose OpenAI 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_env: "The key comes from the environment." api_key_caption_stored: "A key is stored. Leave blank to keep the current key, or enter a new one to replace it." api_key_placeholder_stored: "API key stored" api_key_remove: "Remove key" diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index ccf1e3e54f54..349c428413d0 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -136,6 +136,32 @@ expect(page).to have_css(remove_api_key, text: "Remove key", visible: :all) end end + + context "when the connection comes from the environment" do + let!(:connection) { create(:llm_connection, :enabled, base_url:, api_key: "sk-original") } + + before do + allow(Setting).to receive(:llm_connection).and_return({ "base_url" => base_url }) + end + + it "renders the server settings read-only, with a banner saying why" do + get llm_connection_path + + expect(response.body).to include("configured via environment variables") + expect(page).to have_field("Host URL", disabled: true) + expect(page).to have_field("API format", disabled: true) + expect(page).to have_field("Enable LLMs for this instance", disabled: true) + expect(page).to have_no_button("Save") + end + + it "does not ask for a key that cannot be entered" do + get llm_connection_path + + expect(response.body).to include("The key comes from the environment") + expect(response.body).not_to include("A key is stored") + expect(page).to have_no_css(remove_api_key, visible: :all) + end + end end end diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 3eb410121e05..895c62dd0363 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -159,6 +159,17 @@ def streamed_markup = Capybara.string(response.body.gsub(%r{}, "")) expect(response.body).not_to include("Default models") end + it "shows the default read-only when the environment owns the connection" do + create(:llm_connection, :with_models, :enabled, base_url:, default_chat_model_id: "qwen3.6-27b") + allow(Setting).to receive(:llm_connection).and_return({ "base_url" => base_url }) + + get llm_models_path + + expect(response.body).to include("configured via environment variables") + expect(page).to have_css("[data-test-selector='llm-connection--defaults-form'] opce-autocompleter[data-disabled='true']") + expect(page).to have_no_button("Save") + end + it "keeps a stored default listed once its model is switched off" do connection = create(:llm_connection, :with_models, base_url:) chat_model = connection.models.find_by(external_id: "qwen3.6-27b") @@ -660,6 +671,15 @@ def rendered_rows(body) = body.scan("llm-model--toggle-").size expect(connection.reload.base_url).to eq(base_url) end + it "refuses a default the environment owns" do + allow(Setting).to receive(:llm_connection).and_return({ "base_url" => base_url }) + + patch defaults_llm_models_path, params: { llm_connection: { default_chat_model_id: "qwen3.6-27b" } } + + expect(connection.reload.default_chat_model_id).to be_nil + expect(flash[:error]).to be_present + end + it "is refused to a non-admin" do login_as create(:user) From c35e7d336d07b48a3eba25dcf6fc7af4167123e8 Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Mon, 7 Sep 2026 17:09:05 +0200 Subject: [PATCH 4/5] [AI-3] Cover the retry a provisioned catalogue depends on The reviewer thread on the seeder was answered with the backoff retry, but nothing pinned it. The job now loops over every stored connection, so the example also proves that a server which is not up yet stops the run and comes back rather than silently leaving the connection without its model list. --- spec/workers/llm/sync_models_job_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/workers/llm/sync_models_job_spec.rb b/spec/workers/llm/sync_models_job_spec.rb index 90ed7c2ab257..e6b8ce96d098 100644 --- a/spec/workers/llm/sync_models_job_spec.rb +++ b/spec/workers/llm/sync_models_job_spec.rb @@ -46,4 +46,14 @@ it "does nothing while no connection is stored" do expect { described_class.perform_now }.not_to raise_error end + + # The job exists for the startup race with a provisioned LLM sidecar, so a + # failed fetch has to reach the retry rather than leave the connection without + # its catalogue. + it "tries again when the server is not up yet" do + create(:llm_connection, base_url:) + mock_llm_models_response(base_url, response_code: 404) + + expect { described_class.perform_now }.to have_enqueued_job(described_class) + end end From 0c175752624e8aec165d13726c9391cbe8c9b6b3 Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Mon, 7 Sep 2026 17:45:00 +0200 Subject: [PATCH 5/5] [AI-3] Enter the model the environment names as a row The connection defaults reference a model row, so an environment naming a model the server has not been asked about yet had nothing to point at. The seed enters that model the way an administrator entering one by hand would, then writes the reference, both inside one transaction, and clears a default the environment no longer names. Provisioning also writes the AI features switch to its setting rather than to the column it used to live in, and the catalogue fill is now enqueued when no catalogue was ever fetched rather than when no model row exists, since provisioning itself creates one. --- app/seeders/env_data/llm_connection_seeder.rb | 2 +- .../llm_connections/env_sync_service.rb | 39 ++++++++++++++----- spec/requests/admin/llm_connections_spec.rb | 9 +++-- spec/requests/admin/llm_models_spec.rb | 3 +- .../env_data/llm_connection_seeder_spec.rb | 12 +++--- 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/app/seeders/env_data/llm_connection_seeder.rb b/app/seeders/env_data/llm_connection_seeder.rb index 6033ac4691e4..912471599a91 100644 --- a/app/seeders/env_data/llm_connection_seeder.rb +++ b/app/seeders/env_data/llm_connection_seeder.rb @@ -46,7 +46,7 @@ def seed_data! result = LlmConnections::EnvSyncService.new(config).call raise result.errors.full_messages.join(", ") if result.failure? - Llm::SyncModelsJob.perform_later if result.result.models.none? + Llm::SyncModelsJob.perform_later if result.result.catalogue_fetched_at.nil? end end diff --git a/app/services/llm_connections/env_sync_service.rb b/app/services/llm_connections/env_sync_service.rb index 9b7c0184bfa8..b7291502f756 100644 --- a/app/services/llm_connections/env_sync_service.rb +++ b/app/services/llm_connections/env_sync_service.rb @@ -40,12 +40,12 @@ def initialize(env_config) end def call - UpdateService - .new(user: User.system, - model: LlmConnection.instance, - contract_class: EnvironmentUpdateContract, - sync_models: false) - .call(**attributes) + ApplicationRecord.transaction do + result = write(attributes) + break result if result.failure? + + write(default_model_references(result.result), model: result.result) + end end private @@ -60,10 +60,31 @@ def attributes { base_url: config.fetch(:base_url), api_key: config[:api_key], - default_chat_model_id: config[:default_chat_model], - default_embedding_model_id: config[:default_embedding_model], - enabled: ActiveRecord::Type::Boolean.new.deserialize(config.fetch(:enabled, true)) + llm_features_enabled: ActiveRecord::Type::Boolean.new.deserialize(config.fetch(:enabled, true)) } end + + def write(attributes, model: LlmConnection.active_connection) + UpdateService + .new(user: User.system, + model:, + contract_class: EnvironmentUpdateContract, + sync_models: false) + .call(**attributes) + end + + # The environment names a model, and on a fresh installation nothing has + # asked the server for a catalogue yet, so the row it must reference is + # entered here the way an administrator would enter it by hand. + def default_model_references(connection) + { default_chat_model_id: model_row_id(connection, config[:default_chat_model]), + default_embedding_model_id: model_row_id(connection, config[:default_embedding_model]) } + end + + def model_row_id(connection, external_id) + return if external_id.blank? + + connection.models.create_with(manual: true).find_or_create_by!(external_id:).id + end end end diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 349c428413d0..3bc2dd3ae7b1 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -138,10 +138,13 @@ end context "when the connection comes from the environment" do - let!(:connection) { create(:llm_connection, :enabled, base_url:, api_key: "sk-original") } + let!(:connection) { create(:llm_connection, base_url:, api_key: "sk-original") } before do - allow(Setting).to receive(:llm_connection).and_return({ "base_url" => base_url }) + # Provisioning from the environment switches the features on, and the + # server settings are only rendered once they are. + allow(Setting).to receive_messages(llm_connection: { "base_url" => base_url }, + llm_features_enabled?: true) end it "renders the server settings read-only, with a banner saying why" do @@ -395,7 +398,7 @@ post disconnect_llm_connection_path expect(connection.reload.api_key).to eq("sk-test") - expect(connection).to be_enabled + expect(Setting.llm_features_enabled?).to be(true) end it "is refused to a non-admin" do diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 895c62dd0363..b5ff48361fdc 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -160,7 +160,8 @@ def streamed_markup = Capybara.string(response.body.gsub(%r{}, "")) end it "shows the default read-only when the environment owns the connection" do - create(:llm_connection, :with_models, :enabled, base_url:, default_chat_model_id: "qwen3.6-27b") + connection = create(:llm_connection, :with_models, base_url:) + connection.update!(default_chat_model: connection.models.find_by(external_id: "qwen3.6-27b")) allow(Setting).to receive(:llm_connection).and_return({ "base_url" => base_url }) get llm_models_path diff --git a/spec/seeders/env_data/llm_connection_seeder_spec.rb b/spec/seeders/env_data/llm_connection_seeder_spec.rb index 695c6beea876..1495a8c5affa 100644 --- a/spec/seeders/env_data/llm_connection_seeder_spec.rb +++ b/spec/seeders/env_data/llm_connection_seeder_spec.rb @@ -40,8 +40,8 @@ end # On a fresh installation the seed runs before any model synchronisation, so - # a configured default model cannot be validated against a catalogue yet. - # Provisioning must still complete; a wrong id surfaces later as dangling. + # the model the environment names has no row yet. Provisioning enters it the + # way an administrator would, and the refresh confirms it later. context "with a default model configured on a fresh installation", with_settings: { llm_connection: { "base_url" => "https://example.com/v1", @@ -53,7 +53,7 @@ expect { seed }.to change(LlmConnection, :count).from(0).to(1) connection = LlmConnection.first - expect(connection.default_chat_model_id).to eq("qwen3.6-35b-a3b") + expect(connection.default_chat_model.external_id).to eq("qwen3.6-35b-a3b") expect(connection.api_key).to eq("sk-from-env") end @@ -88,9 +88,9 @@ llm_connection: { "base_url" => "https://example.com/v1" } } do before do - create(:llm_connection, base_url: "https://example.com/v1", - api_key: "sk-stale", - default_chat_model_id: "old-default") + connection = create(:llm_connection, base_url: "https://example.com/v1", api_key: "sk-stale") + connection.update!(default_chat_model: create(:llm_model, llm_connection: connection, + external_id: "old-default")) end it "clears the values the environment no longer provides" do