diff --git a/app/components/llm_connections/default_models_component.html.erb b/app/components/llm_connections/default_models_component.html.erb new file mode 100644 index 000000000000..7293172f74a0 --- /dev/null +++ b/app/components/llm_connections/default_models_component.html.erb @@ -0,0 +1,41 @@ +<%#-- 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. + +++#%> + +<%= component_wrapper(class: "mb-4") do %> + <%= + render(Primer::Beta::Subhead.new) do |component| + component.with_heading(tag: :h3) { t("admin.llm_models.defaults.heading") } + component.with_description { t("admin.llm_models.defaults.description") } + end + %> + + <%= settings_primer_form_with(**form_options) do |f| %> + <%= render(LlmConnections::DefaultModelsForm.new(f)) %> + <% end %> +<% end %> diff --git a/app/components/llm_connections/default_models_component.rb b/app/components/llm_connections/default_models_component.rb new file mode 100644 index 000000000000..33762c03cf74 --- /dev/null +++ b/app/components/llm_connections/default_models_component.rb @@ -0,0 +1,54 @@ +# 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 "Default models" section of the LLMs page. + class DefaultModelsComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + alias_method :connection, :model + + # Nothing to choose from, and the empty table right below says so. + def render? = connection.available_model_ids.any? + + private + + def form_options + { + model: connection, + url: url_helpers.defaults_llm_models_path, + method: :patch, + data: { test_selector: "llm-connection--defaults-form" } + } + end + end +end diff --git a/app/components/llm_connections/models_row_component.rb b/app/components/llm_connections/models_row_component.rb index 26346b7496d4..2d1fc7f5f9ac 100644 --- a/app/components/llm_connections/models_row_component.rb +++ b/app/components/llm_connections/models_row_component.rb @@ -76,12 +76,43 @@ def source end def source_label - return %i[accent source_manual] if llm_model.manual? return %i[attention source_withdrawn] if llm_model.withdrawn? + return %i[accent source_manual] if llm_model.manual? %i[secondary source_discovered] end + # Whether a feature may choose this model. A withdrawn model has nothing to + # switch on -- the server stopped offering it -- so its toggle is inert + # rather than absent, which keeps the column aligned and says why. + def status + render(Primer::Alpha::ToggleSwitch.new(**toggle_options)) + end + + def toggle_options + options = { + checked: llm_model.selectable?, + enabled: togglable?, + size: :small, + # A bare ToggleSwitch has no accessible name, and axe fails without one. + aria: { label: I18n.t("admin.llm_models.index.toggle_aria_label", model: llm_model.name) }, + test_selector: "llm-model--toggle-#{llm_model.id}" + } + + togglable? ? options.merge(mutation_options) : options + end + + def mutation_options + { + src: url_helpers.toggle_llm_model_path(llm_model), + csrf_token: helpers.form_authenticity_token, + turbo: true, + classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator" + } + end + + def togglable? = llm_model.active? + def button_links llm_model.manual? ? [edit_link, delete_link] : [edit_link] end diff --git a/app/components/llm_connections/models_table_component.rb b/app/components/llm_connections/models_table_component.rb index cb01b0817d00..94aeac16d1f3 100644 --- a/app/components/llm_connections/models_table_component.rb +++ b/app/components/llm_connections/models_table_component.rb @@ -34,7 +34,7 @@ module LlmConnections # Rendering never issues an HTTP request: the catalogue is refreshed explicitly # through the "Refresh models" action. class ModelsTableComponent < OpPrimer::BorderBoxTableComponent - columns :identifier, :kind, :context_window, :source + columns :identifier, :kind, :context_window, :source, :status main_column :identifier @@ -66,7 +66,8 @@ def headers [:identifier, { caption: I18n.t("admin.llm_models.index.identifier") }], [:kind, { caption: I18n.t("admin.llm_models.index.kind") }], [:context_window, { caption: I18n.t("admin.llm_models.index.context_window") }], - [:source, { caption: I18n.t("admin.llm_models.index.source") }] + [:source, { caption: I18n.t("admin.llm_models.index.source") }], + [:status, { caption: I18n.t("admin.llm_models.index.status") }] ] end diff --git a/app/contracts/llm_connections/base_contract.rb b/app/contracts/llm_connections/base_contract.rb index 9e1963c346fe..f4c12e335379 100644 --- a/app/contracts/llm_connections/base_contract.rb +++ b/app/contracts/llm_connections/base_contract.rb @@ -52,14 +52,40 @@ class BaseContract < ModelContract validates :base_url, url: { message: :invalid_url }, unless: -> { base_url.blank? } validate :features_require_connection + validate :default_models_offered_by_server + validate :default_chat_model_can_chat private + # A model the server identifies as an embedding model is not a chat candidate. + def default_chat_model_can_chat + llm_model = model.default_chat_model + return if llm_model.blank? + return unless model.changed_attributes.include?("default_chat_model_id") + + errors.add(:default_chat_model_id, :cannot_chat) if model.default_chat_model&.embedding? + end + def features_require_connection return unless model.llm_features_enabled return if model.base_url.present? errors.add :llm_features_enabled, :requires_connection end + + # A designated default must be a model the server actually reported. Validated + # only when it changes, so a catalogue that shrinks underneath a stored + # selection does not block every unrelated save; the dangling state is + # surfaced in the UI instead. + def default_models_offered_by_server + LlmModel::CONNECTION_DEFAULTS.each do |attribute| + value = model.public_send(attribute) + next if value.blank? + next unless model.changed_attributes.include?(attribute.to_s) + next if model.models.active.exists?(id: value) + + errors.add attribute, :not_available + end + end end end diff --git a/app/controllers/admin/llm_models_controller.rb b/app/controllers/admin/llm_models_controller.rb index 6e41973a4a19..39e813fd97ad 100644 --- a/app/controllers/admin/llm_models_controller.rb +++ b/app/controllers/admin/llm_models_controller.rb @@ -124,8 +124,40 @@ def destroy redirect_to llm_models_path, status: :see_other end + def update_defaults + result = ::LlmConnections::UpdateService + .new(user: current_user, model: @connection) + .call(**default_model_params) + + result.on_success { flash[:notice] = t("admin.llm_models.defaults.success") } + result.on_failure { flash[:error] = result.errors.full_messages.join(", ") } + + redirect_to llm_models_path, status: :see_other + end + + # Hides a model from the pickers, or puts it back. Deliberately does not + # touch +active+, which the catalogue sync owns and would overwrite. + def toggle + llm_model = @connection.models.find(params.expect(:id)) + + # A withdrawn model has nothing to switch on; its toggle is rendered + # disabled, and this refuses a request that got here anyway. + return render(json: {}, status: :unprocessable_entity) unless llm_model.active? + + llm_model.update!(deactivated_at: llm_model.deactivated? ? nil : Time.current) + + # The default pickers offer the models that are switched on, so they go + # stale the moment a toggle flips. + update_via_turbo_stream(component: ::LlmConnections::DefaultModelsComponent.new(@connection)) + respond_with_turbo_streams + end + private + def default_model_params + params.expect(llm_connection: %i[default_chat_model_id]).to_h.symbolize_keys + end + def set_connection @connection = LlmConnection.active_connection end diff --git a/app/forms/llm_connections/default_models_form.rb b/app/forms/llm_connections/default_models_form.rb new file mode 100644 index 000000000000..56e1c692785f --- /dev/null +++ b/app/forms/llm_connections/default_models_form.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + class DefaultModelsForm < ApplicationForm + form do |f| + # An autocompleter rather than a select: a gateway reports hundreds of + # models, and every one of them would otherwise be inlined as an option in + # the page body. decorated: true serialises the list into the element, so + # this needs no endpoint of its own. + f.autocompleter( + name: :default_chat_model_id, + label: LlmConnection.human_attribute_name(:default_chat_model_id), + caption: I18n.t("admin.llm_models.defaults.chat_caption"), + autocomplete_options: { + decorated: true, + inputValue: model.default_chat_model_id, + placeholder: I18n.t("label_none_parentheses") + } + ) do |list| + list.option(label: I18n.t("label_none_parentheses"), value: "", + selected: model.default_chat_model_id.blank?) + + default_chat_model_options.each do |llm_model| + list.option(label: llm_model.name, value: llm_model.id, + selected: model.default_chat_model_id == llm_model.id) + end + end + + f.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) + end + + private + + # 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 + (model.chat_models + [model.default_chat_model]).compact.uniq + end + end +end diff --git a/app/models/llm_connection.rb b/app/models/llm_connection.rb index 9912b52d0de0..dc76772937f7 100644 --- a/app/models/llm_connection.rb +++ b/app/models/llm_connection.rb @@ -103,6 +103,28 @@ def models_stale? connection_fingerprint.present? && connection_fingerprint != settings_fingerprint end + # What a picker should offer: the above, minus what an administrator has + # switched off. + def selectable_model_ids + models.selectable.by_identifier.pluck(:external_id) + end + + def selectable_models + models.selectable.by_identifier + end + + def chat_models + selectable_models.reject(&:embedding?) + end + + def embedding_model_ids + embedding = capability_verdicts.for_capability(:embeddings).where(state: "supported").pluck(:model_id) + + selectable_model_ids & embedding + end + + def chat_model_ids = selectable_model_ids - embedding_model_ids + def server_flavour options["server_flavour"].presence&.to_sym end diff --git a/app/models/llm_model.rb b/app/models/llm_model.rb index 7550eff79852..75d9ac73b05f 100644 --- a/app/models/llm_model.rb +++ b/app/models/llm_model.rb @@ -47,6 +47,13 @@ class LlmModel < ApplicationRecord scope :manual, -> { where(manual: true) } scope :by_identifier, -> { order(:external_id) } + # What an administrator is willing to have chosen. Distinct from +active+, + # which the catalogue sync owns and rewrites on every refresh. + scope :deactivated, -> { where.not(deactivated_at: nil) } + scope :selectable, -> { active.where(deactivated_at: nil) } + + def deactivated? = deactivated_at.present? + # Everything that points at a model does so by its identifier string, so a # rename has to carry them along or it silently orphans them. # @@ -69,6 +76,12 @@ def cascade_delete! clear_connection_defaults end + # Offerable in a picker. Note that this is *not* what decides whether a model + # still resolves: a feature already bound to a deactivated model keeps working, + # and is surfaced as a warning instead. Switching a row off must never silently + # break a running feature. + def selectable? = active? && !deactivated? + def name = display_name.presence || external_id def clear_connection_defaults diff --git a/app/views/admin/llm_models/index.html.erb b/app/views/admin/llm_models/index.html.erb index 23e6a4e445b2..7a3b59f0b0c6 100644 --- a/app/views/admin/llm_models/index.html.erb +++ b/app/views/admin/llm_models/index.html.erb @@ -61,5 +61,7 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> +<%= render(LlmConnections::DefaultModelsComponent.new(@connection)) %> + <%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> <%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 324e502d8547..42a20aaae861 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -211,6 +211,7 @@ en: 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). + default_chat_model: "Default chat model" llm_features_enabled: "Enable LLMs for this instance" llm_model: display_name: "Display name" @@ -701,6 +702,12 @@ en: request_timed_out: "did not respond in time. Please ensure the LLM server is reachable and not overloaded." ssl_error: "could not be reached over a secure connection. Please check the LLM server's TLS certificate, or use an http:// URL if the server does not offer TLS." 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." + default_chat_model_id: + cannot_chat: "is an embedding model, according to the LLM server, and cannot be used for chat." + not_available: "is not offered by the configured LLM server." + default_embedding_model_id: + cannot_embed: "cannot create embeddings, according to the LLM server." + not_available: "is not offered by the configured LLM server." llm_features_enabled: requires_connection: "cannot be turned on before a connection has been configured." meeting: @@ -1747,6 +1754,11 @@ en: llm_models: create: success: "%{model} has been added." + defaults: + chat_caption: "Only models that are switched on in the list below are offered." + description: "Used by AI features that do not choose a model themselves." + heading: "Default models" + success: "The default models have been saved." destroy: description: "The model will no longer be offered to AI features." description_bound: "This model is currently used by %{features}. Those features will stop working until another model is selected." @@ -1787,6 +1799,8 @@ en: source_withdrawn: "Withdrawn" source_withdrawn_description: "No longer reported by the server" stale_warning: "The connection settings changed after the model list was last refreshed, so the list may be out of date. Refresh the models to be sure." + status: "Available for use" + toggle_aria_label: "Make %{model} available to AI features" new: description: "Name a model this server can use but does not advertise, and describe what it can do." title: "Add a model" diff --git a/config/routes.rb b/config/routes.rb index 3abcb7b0f318..4d0fab08987e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -753,10 +753,12 @@ collection do get :search, defaults: { format: :turbo_stream } post :refresh + patch :defaults, action: :update_defaults end member do get :delete_dialog + post :toggle, defaults: { format: :turbo_stream } end end diff --git a/db/migrate/20260813100000_add_deactivated_at_to_llm_models.rb b/db/migrate/20260813100000_add_deactivated_at_to_llm_models.rb new file mode 100644 index 000000000000..8f59a81367c9 --- /dev/null +++ b/db/migrate/20260813100000_add_deactivated_at_to_llm_models.rb @@ -0,0 +1,42 @@ +# 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. +#++ + +# Separates "the server stopped offering this" from "an administrator does not +# want this used". +# +# +active+ is owned by the catalogue sync, which sets it on every refresh and +# clears it for models the server no longer reports. An administrator's choice +# cannot live there: the next "Refresh models" would silently undo it, and the +# model would be labelled "no longer reported" when it is merely hidden. +class AddDeactivatedAtToLlmModels < ActiveRecord::Migration[8.1] + def change + add_column :llm_models, :deactivated_at, :datetime + end +end diff --git a/spec/contracts/llm_connections/update_contract_spec.rb b/spec/contracts/llm_connections/update_contract_spec.rb index e0daa331c3a2..72089b466d15 100644 --- a/spec/contracts/llm_connections/update_contract_spec.rb +++ b/spec/contracts/llm_connections/update_contract_spec.rb @@ -201,4 +201,42 @@ expect(models_request).not_to have_been_made end end + + describe "default model selection" do + let(:connection) { create(:llm_connection, :with_models, base_url:) } + + context "with a model the server offers" do + before { connection.default_chat_model = connection.models.find_by(external_id: "bge-m3") } + + include_examples "contract is valid" + end + + context "with a model the server does not offer" do + before { connection.default_chat_model_id = LlmModel.maximum(:id).to_i + 1 } + + include_examples "contract is invalid", default_chat_model_id: :not_available + end + + context "with a model the server says is an embedding model" do + before do + connection.capability_verdicts.create!(model_id: "bge-m3", capability: "embeddings", + state: "supported", source: "probe", checked_at: Time.current) + connection.default_chat_model = connection.models.find_by(external_id: "bge-m3") + end + + include_examples "contract is invalid", default_chat_model_id: :cannot_chat + end + + # Curation, not enforcement: switching a model off hides it from the pickers + # and must never break a feature that already points at it. + context "with a model an administrator switched off" do + before do + chat_model = connection.models.find_by(external_id: "qwen3.6-27b") + chat_model.update!(deactivated_at: Time.current) + connection.default_chat_model = chat_model + end + + include_examples "contract is valid" + end + end end diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index f03e0af72b5b..20ebc087c244 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -49,6 +49,12 @@ # 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 offered_default_models + items = find("[data-test-selector='llm-connection--defaults-form'] opce-autocompleter")["data-items"] + + JSON.parse(items).pluck("id").compact_blank + end + def choose_action(item) expect(page).to have_test_selector("llm-connection--actions") find_test_selector("llm-connection--actions").click @@ -142,9 +148,32 @@ def choose_action(item) expect(page).to have_test_selector("llm-model--refresh-button") expect(page).to have_text(connection.models.first.external_id) + expect(page).to have_test_selector("llm-model--toggle-#{connection.models.first.id}") expect(page).to be_axe_clean.within("#content") end + it "hides a model from the feature pickers when it is switched off" do + llm_model = connection.models.find_by(external_id: "qwen3.6-27b") + + visit llm_models_path + + expect(offered_default_models).to include("qwen3.6-27b") + + find_test_selector("llm-model--toggle-#{llm_model.id}").click + + wait_for { llm_model.reload.deactivated_at }.not_to be_nil + wait_for { offered_default_models }.not_to include("qwen3.6-27b") + + # The toggle re-renders the pickers, not the row, so the table itself only + # catches up on the next load. + visit llm_models_path + + within_test_selector("llm-model--toggle-#{llm_model.id}") do + expect(page).to have_css("button[aria-pressed='false']") + end + expect(page).to have_no_text("Hidden") + end + # The chat capabilities are hidden client-side, so only a browser shows that # the type choice actually reaches them. it "adds a model by hand and offers the capabilities its type can have" do diff --git a/spec/models/llm_model_spec.rb b/spec/models/llm_model_spec.rb index b9143bef6ace..98ba190daeb7 100644 --- a/spec/models/llm_model_spec.rb +++ b/spec/models/llm_model_spec.rb @@ -55,4 +55,41 @@ expect(llm_model.model_type).to eq(:chat) end end + + describe "deactivation", :llm_server_helpers, :webmock, with_flag: { llm_connection: true } do + let(:base_url) { "https://example.com/v1" } + let!(:connection) { create(:llm_connection, :with_models, base_url:) } + let(:model) { connection.models.find_by(external_id: "qwen3.6-27b") } + + before { model.update!(deactivated_at: Time.current) } + + it "hides the model from the pickers" do + expect(connection.selectable_model_ids).not_to include("qwen3.6-27b") + expect(connection.selectable_model_ids).to include("bge-m3") + end + + it "stays addressable for a feature that is already bound to it" do + expect(connection.available_model_ids).to include("qwen3.6-27b") + end + + # The reason deactivated_at exists rather than reusing active: the sync writes + # active on every refresh, so an administrator's choice stored there would be + # undone by the next "Refresh models". + it "survives a catalogue sync that still reports the model" do + mock_llm_models_response(base_url) + + LlmConnections::SyncModelsService.new(connection).call + + expect(model.reload).to be_deactivated + expect(model).to be_active + expect(model).not_to be_selectable + end + + it "is distinct from a model the server withdrew" do + withdrawn = create(:llm_model, :withdrawn, llm_connection: connection, external_id: "gone") + + expect(withdrawn).to be_withdrawn + expect(model).not_to be_withdrawn + end + end end diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index da2d57dd3a59..52b4c3088fb6 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -360,4 +360,16 @@ expect(connection.reload.api_key).to eq("sk-test") end end + + describe "the default models" do + let!(:connection) { create(:llm_connection, :with_models, base_url: "https://example.com/v1") } + + before { login_as admin } + + it "are chosen on the LLMs page, not here" do + patch llm_connection_path, params: { llm_connection: { default_chat_model_id: "qwen3.6-27b" } } + + expect(connection.reload.default_chat_model_id).to be_nil + end + end end diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 36266da677d7..3eb410121e05 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -36,6 +36,19 @@ let(:admin) { create(:admin) } let(:base_url) { "https://example.com/v1" } + # The picker is an autocompleter, so its options are serialised into the + # element rather than rendered as markup. + def offered_default_models(markup = page) + items = markup.find("[data-test-selector='llm-connection--defaults-form'] opce-autocompleter")["data-items"] + ids = JSON.parse(items).pluck("id").compact_blank + + LlmModel.where(id: ids).pluck(:external_id) + end + + # Nokogiri does not descend into a