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/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..29f905ee14c1 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 @@ -147,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/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..912471599a91 --- /dev/null +++ b/app/seeders/env_data/llm_connection_seeder.rb @@ -0,0 +1,89 @@ +# 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. 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 + + 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 if result.result.catalogue_fetched_at.nil? + 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..b7291502f756 --- /dev/null +++ b/app/services/llm_connections/env_sync_service.rb @@ -0,0 +1,90 @@ +# 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 + ApplicationRecord.transaction do + result = write(attributes) + break result if result.failure? + + write(default_model_references(result.result), model: result.result) + end + 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], + 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/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 a64ff34394c0..3e85326d7c47 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -753,6 +753,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 ff34b418ade2..1a1196bb4b2b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1778,6 +1778,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" @@ -1785,6 +1786,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." @@ -1795,6 +1797,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/docs/installation-and-operations/configuration/environment/README.md b/docs/installation-and-operations/configuration/environment/README.md index ea5f31eade7f..78a5dbcb593b 100644 --- a/docs/installation-and-operations/configuration/environment/README.md +++ b/docs/installation-and-operations/configuration/environment/README.md @@ -289,6 +289,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_LLM__FEATURES__ENABLED (default=false) Enable the AI features backed by the configured LLM connection 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 diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 52b4c3088fb6..3bc2dd3ae7b1 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -136,6 +136,35 @@ 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, base_url:, api_key: "sk-original") } + + before do + # 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 + 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 @@ -304,6 +333,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 +392,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(Setting.llm_features_enabled?).to be(true) + end + it "is refused to a non-admin" do login_as create(:user) diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 3eb410121e05..b5ff48361fdc 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -159,6 +159,18 @@ 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 + 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 + + 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 +672,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) 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..1495a8c5affa --- /dev/null +++ b/spec/seeders/env_data/llm_connection_seeder_spec.rb @@ -0,0 +1,105 @@ +# 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 + # 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", + "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.external_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, + # 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 + 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 + 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 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