From d7b4be0837d0889289e5481493ec9d4a95a83c9e Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Sat, 22 Aug 2026 18:12:20 +0200 Subject: [PATCH 01/12] [AI-3] List the cached models on the connection page Renders the model catalogue as a filterable, paginated table below the connection form. Rendering never contacts the server: the list is read from the cached catalogue, and the sub-header offers the explicit Refresh models action instead. Filtering matches the identifier and the friendly name, answers with a turbo stream so only the table is replaced, and works on a shared full-page link too. Part 5 of the AI-3 stack. https://community.openproject.org/work_packages/66020 --- .../models/index_component.html.erb | 3 + .../llm_connections/models/index_component.rb | 49 +++++++++++ .../models/sub_header_component.html.erb | 25 ++++++ .../models/sub_header_component.rb | 65 ++++++++++++++ .../llm_connections/models_row_component.rb | 77 +++++++++++++++++ .../llm_connections/models_table_component.rb | 85 +++++++++++++++++++ .../admin/llm_connections_controller.rb | 36 +++++++- app/models/queries/llm_models.rb | 35 ++++++++ .../llm_models/filters/llm_model_filter.rb | 33 +++++++ .../queries/llm_models/filters/name_filter.rb | 59 +++++++++++++ .../queries/llm_models/llm_model_query.rb | 43 ++++++++++ app/views/admin/llm_connections/show.html.erb | 23 ++++- config/locales/en.yml | 29 +++++++ config/routes.rb | 2 + spec/features/admin/llm_connection_spec.rb | 45 ++++++++++ spec/requests/admin/llm_connections_spec.rb | 77 +++++++++++++++++ 16 files changed, 682 insertions(+), 4 deletions(-) create mode 100644 app/components/llm_connections/models/index_component.html.erb create mode 100644 app/components/llm_connections/models/index_component.rb create mode 100644 app/components/llm_connections/models/sub_header_component.html.erb create mode 100644 app/components/llm_connections/models/sub_header_component.rb create mode 100644 app/components/llm_connections/models_row_component.rb create mode 100644 app/components/llm_connections/models_table_component.rb create mode 100644 app/models/queries/llm_models.rb create mode 100644 app/models/queries/llm_models/filters/llm_model_filter.rb create mode 100644 app/models/queries/llm_models/filters/name_filter.rb create mode 100644 app/models/queries/llm_models/llm_model_query.rb diff --git a/app/components/llm_connections/models/index_component.html.erb b/app/components/llm_connections/models/index_component.html.erb new file mode 100644 index 000000000000..84d1c5941804 --- /dev/null +++ b/app/components/llm_connections/models/index_component.html.erb @@ -0,0 +1,3 @@ +<%= component_wrapper do %> + <%= render(LlmConnections::ModelsTableComponent.new(rows:, connection:)) %> +<% end %> diff --git a/app/components/llm_connections/models/index_component.rb b/app/components/llm_connections/models/index_component.rb new file mode 100644 index 000000000000..844b94fe199a --- /dev/null +++ b/app/components/llm_connections/models/index_component.rb @@ -0,0 +1,49 @@ +# 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 + module Models + # A turbo-frame around the model table, so filtering can replace just the + # table rather than reloading the settings page. + class IndexComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + alias_method :rows, :model + + def initialize(rows, connection:) + super(rows) + @connection = connection + end + + attr_reader :connection + end + end +end diff --git a/app/components/llm_connections/models/sub_header_component.html.erb b/app/components/llm_connections/models/sub_header_component.html.erb new file mode 100644 index 000000000000..0107aed88da3 --- /dev/null +++ b/app/components/llm_connections/models/sub_header_component.html.erb @@ -0,0 +1,25 @@ +<%= render(Primer::OpenProject::SubHeader.new(collapsed_search: false, data: sub_header_data_attributes)) do |subheader| %> + <% subheader.with_filter_input( + name: "name", + label: t("admin.llm_connections.models.filter_label"), + visually_hide_label: true, + value: filter_input_value, + placeholder: t("admin.llm_connections.models.filter_label"), + leading_visual: { icon: :search, size: :small }, + show_clear_button: true, + clear_button_id:, + data: filter_input_data_attributes + ) %> + + <% subheader.with_action_button( + scheme: :default, + label: t("admin.llm_connections.show.refresh_models"), + mobile_label: t("admin.llm_connections.show.refresh_models"), + mobile_icon: :sync, + leading_icon: :sync, + tag: :a, + href: helpers.refresh_models_llm_connection_path, + data: { turbo_method: :post, controller: "disable-when-clicked" }, + test_selector: "llm-model--refresh-button" + ) { t("admin.llm_connections.show.refresh_models") } %> +<% end %> diff --git a/app/components/llm_connections/models/sub_header_component.rb b/app/components/llm_connections/models/sub_header_component.rb new file mode 100644 index 000000000000..57cb69e49ec6 --- /dev/null +++ b/app/components/llm_connections/models/sub_header_component.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module Models + # The filter bar above the model list. + class SubHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + alias_method :query, :model + + def filter_input_value + query.find_active_filter(:name)&.values&.first + end + + def clear_button_id = "llm-models-filter-clear" + + def sub_header_data_attributes + { + controller: "filter--filters-form", + "filter--filters-form-perform-turbo-requests-value": true, + "filter--filters-form-output-format-value": "json", + "filter--filters-form-url-path-name-value": helpers.search_models_llm_connection_path, + "filter--filters-form-clear-button-id-value": clear_button_id + } + end + + def filter_input_data_attributes + { + "filter-name": "name", + "filter-type": "string", + "filter-operator": "~", + "filter--filters-form-target": "simpleFilter filterValueContainer simpleValue" + } + end + end + end +end diff --git a/app/components/llm_connections/models_row_component.rb b/app/components/llm_connections/models_row_component.rb new file mode 100644 index 000000000000..c638fec8fea7 --- /dev/null +++ b/app/components/llm_connections/models_row_component.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + # A row of the model list. Note +model+ is the row's record (an LlmModel), + # not the connection -- aliased to avoid confusion with either. + class ModelsRowComponent < OpPrimer::BorderBoxRowComponent + alias_method :llm_model, :model + + def identifier + render(Primer::Beta::Text.new(font_weight: :bold)) { llm_model.name } + end + + # vLLM and SGLang report the operator's real --max-model-len here, which is + # more trustworthy than any published figure for the model. Servers that do + # not report it simply show nothing rather than a guess. + def context_window + window = llm_model.context_window + return render(Primer::Beta::Text.new(color: :muted)) { "—" } if window.blank? + + number_with_delimiter(window) + end + + # Derived from the embeddings verdict rather than stored separately: a model + # that produces vectors is an embedding model, and that is the same fact. + def kind + case table.embeddings_states[llm_model.external_id] + when "supported" + render(Primer::Beta::Label.new(scheme: :success)) { I18n.t("llm.model_kinds.embedding") } + when "unsupported" + render(Primer::Beta::Label.new(scheme: :secondary)) { I18n.t("llm.model_kinds.chat") } + else + render(Primer::Beta::Label.new(scheme: :secondary, inline: true)) { I18n.t("llm.model_kinds.unknown") } + end + end + + def source + scheme, key = source_label + + render(Primer::Beta::Label.new(scheme:)) { I18n.t("admin.llm_connections.models.#{key}") } + end + + def source_label + return %i[accent source_manual] if llm_model.manual? + return %i[attention source_withdrawn] if llm_model.withdrawn? + + %i[secondary source_discovered] + end + end +end diff --git a/app/components/llm_connections/models_table_component.rb b/app/components/llm_connections/models_table_component.rb new file mode 100644 index 000000000000..a1fdb3f2f298 --- /dev/null +++ b/app/components/llm_connections/models_table_component.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + # Lists the models the remote server reported, read from the cached catalogue. + # + # 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 + + mobile_columns :identifier + + # The connection is passed in rather than derived from the first row: with a + # paginated, filtered list a page can legitimately be empty, and deriving it + # would silently degrade the kind column to "Unknown". + def initialize(connection:, **) + super(**) + @connection = connection + end + + attr_reader :connection + + def initial_sort = %i[identifier asc] + + def has_footer? = false + + def mobile_title = I18n.t("admin.llm_connections.show.models_heading") + + # The row class is otherwise derived by convention as LlmConnections::RowComponent. + def row_class = ModelsRowComponent + + def headers + [ + [:identifier, { caption: I18n.t("admin.llm_connections.models.identifier") }], + [:kind, { caption: I18n.t("admin.llm_connections.models.kind") }], + [:context_window, { caption: I18n.t("admin.llm_connections.models.context_window") }], + [:source, { caption: I18n.t("admin.llm_connections.models.source") }] + ] + end + + # Built once for the whole table so each row does not query for its own + # verdict. Rows reach this through their +table+ accessor. + def embeddings_states + @embeddings_states ||= load_embeddings_states + end + + def load_embeddings_states + connection.capability_verdicts.for_capability(:embeddings).pluck(:model_id, :state).to_h + end + + def blank_title = I18n.t("admin.llm_connections.models.blank_title") + + def blank_description = I18n.t("admin.llm_connections.models.blank_description") + + def blank_icon = :sparkle + end +end diff --git a/app/controllers/admin/llm_connections_controller.rb b/app/controllers/admin/llm_connections_controller.rb index f98f48338ff1..be530302a3e3 100644 --- a/app/controllers/admin/llm_connections_controller.rb +++ b/app/controllers/admin/llm_connections_controller.rb @@ -31,6 +31,7 @@ module Admin class LlmConnectionsController < ApplicationController include OpTurbo::ComponentStream + include PaginationHelper layout "admin" menu_item :llm_connection @@ -39,7 +40,24 @@ class LlmConnectionsController < ApplicationController before_action :require_admin before_action :set_connection - def show; end + def show + @query = ParamsToQueryService + .new(LlmModel, current_user, query_class: Queries::LlmModels::LlmModelQuery) + .call(params) + @models = @query.results.paginate(page: page_param, per_page: per_page_param) + end + + # Answers the sub-header's filter input, replacing just the table. + def search_models + show + + replace_via_turbo_stream( + component: LlmConnections::Models::IndexComponent.new(@models, connection: @connection) + ) + turbo_streams << turbo_stream.push_state(llm_connection_path(params.permit(:filters, :page, :per_page))) + + respond_with_turbo_streams + end def update result = ::LlmConnections::UpdateService @@ -50,12 +68,24 @@ def update result.on_failure { render_form_with_errors } end + def refresh_models + result = ::LlmConnections::SyncModelsService.new(@connection).call + + if result.success? + redirect_with_notice(t(".success")) + else + redirect_with_error(t(".failure")) + end + end + def disconnect_dialog respond_with_dialog LlmConnections::DisconnectDialogComponent.new(@connection) end - # Clears the credential and switches the AI features off, keeping the endpoint - # and the catalogue. Deliberately not a destroy. + # Clears the credential and switches the AI features off, keeping the endpoint, + # the catalogue and every feature binding. Deliberately not a destroy: the + # cascade would take the locked embedding bindings with it, and those are the + # only record that a vector index exists and what it was written with. def disconnect ApplicationRecord.transaction do @connection.update!(api_key: nil) diff --git a/app/models/queries/llm_models.rb b/app/models/queries/llm_models.rb new file mode 100644 index 000000000000..50492ff3b215 --- /dev/null +++ b/app/models/queries/llm_models.rb @@ -0,0 +1,35 @@ +# 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 Queries::LlmModels + ::Queries::Register.register(LlmModelQuery) do + filter Filters::NameFilter + end +end diff --git a/app/models/queries/llm_models/filters/llm_model_filter.rb b/app/models/queries/llm_models/filters/llm_model_filter.rb new file mode 100644 index 000000000000..bf0be12e74b5 --- /dev/null +++ b/app/models/queries/llm_models/filters/llm_model_filter.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Queries::LlmModels::Filters::LlmModelFilter < Queries::Filters::Base + self.model = LlmModel +end diff --git a/app/models/queries/llm_models/filters/name_filter.rb b/app/models/queries/llm_models/filters/name_filter.rb new file mode 100644 index 000000000000..4fe27abbaa63 --- /dev/null +++ b/app/models/queries/llm_models/filters/name_filter.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Queries::LlmModels::Filters::NameFilter < Queries::LlmModels::Filters::LlmModelFilter + def self.key + :name + end + + def type + :string + end + + def human_name + I18n.t("admin.llm_connections.models.filter_label") + end + + # Matches the identifier the server uses and the friendly name an + # administrator may have given it, since either is what someone types. + def where + escaped = ActiveRecord::Base.sanitize_sql_like(values.first) + + case operator + when "~", "**" + ["llm_models.external_id ILIKE :q OR llm_models.display_name ILIKE :q", { q: "%#{escaped}%" }] + when "!~" + ["llm_models.external_id NOT ILIKE :q AND (llm_models.display_name IS NULL OR llm_models.display_name NOT ILIKE :q)", + { q: "%#{escaped}%" }] + else + raise "Unsupported operator #{operator}" + end + end +end diff --git a/app/models/queries/llm_models/llm_model_query.rb b/app/models/queries/llm_models/llm_model_query.rb new file mode 100644 index 000000000000..e6d096a22749 --- /dev/null +++ b/app/models/queries/llm_models/llm_model_query.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Queries::LlmModels::LlmModelQuery + include Queries::BaseQuery + include Queries::UnpersistedQuery + + def self.model + LlmModel + end + + # There is exactly one LLM connection, so every model belongs to it. + def default_scope + LlmModel.by_identifier + end +end diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb index 82d426499173..4fbeef3261e5 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -80,4 +80,25 @@ See COPYRIGHT and LICENSE files for more details. end %> -<%= render(LlmConnections::FormComponent.new(@connection)) %> +<% if @connection.persisted? %> + <%= render(LlmConnections::FormComponent.new(@connection)) %> + + <%= + render(Primer::Beta::Subhead.new(mt: 4)) do |component| + component.with_heading(tag: :h3) { t(".models_heading") } + component.with_description do + if @connection.catalogue_fetched_at + t(".models_description", fetched_at: format_time(@connection.catalogue_fetched_at)) + else + t(".models_description_unfetched") + end + end + end + %> + + <%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> + <%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> +<% else %> + <%# Nothing to show alongside the form until a connection exists. %> + <%= render(LlmConnections::FormComponent.new(@connection)) %> +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 34d0c0c8bf8b..56d5c505214b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1733,8 +1733,30 @@ en: label_connecting: "Contacting the LLM server…" server_description: "Connect OpenProject to a server that speaks the %{api_format} API, so AI features can use it. The host URL below decides which server that is." server_group: "Server" + models: + blank_description: "The server reported no models. If it does not offer a model list, add the model names you want to use below." + blank_title: "No models available" + context_window: "Context window" + display_name_caption: "An optional friendly name shown instead of the model id." + filter_label: "Filter models" + identifier: "Model" + kind: "Type" + source: "Source" + source_deactivated: "Hidden by administrator" + source_discovered: "Reported by server" + source_manual: "Added manually" + source_withdrawn: "No longer reported" + status: "Available for use" + toggle_aria_label: "Make %{model} available to AI features" + refresh_models: + failure: "The model list could not be refreshed. Please check that the LLM server is still reachable." + success: "The model list has been refreshed." show: description: "Connect OpenProject to an LLM server so that AI features can use it." + models_description: "Reported by the server on %{fetched_at}." + models_description_unfetched: "The server has not returned a model list yet. Refresh the models once it is reachable, or add the models you want to use manually." + models_heading: "Available models" + refresh_models: "Refresh models" update: disabled: "Saved. LLM features are switched off for this instance." no_models: "Saved, but no models are stored yet. Either the server offers no model list, or the endpoint is missing its API version segment (for example /v1)." @@ -4267,6 +4289,13 @@ en: perplexity: "Perplexity" vertexai: "Google Vertex AI" xai: "xAI" + context_window_sources: + registry: "the figure published for this model" + server: "reported by the server" + model_kinds: + chat: "Chat" + embedding: "Embedding" + unknown: "Unknown" macro_execution_error: "Error executing the macro %{macro_name}" macro_unavailable: "Macro %{macro_name} cannot be displayed." macro_unknown: "Unknown or unsupported macro." diff --git a/config/routes.rb b/config/routes.rb index 29f1932ea11f..de2c81e44942 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -743,10 +743,12 @@ end resource :llm_connection, only: %i[show update], controller: "admin/llm_connections" do + post :refresh_models delete :api_key, action: :delete_api_key get :delete_api_key_dialog get :disconnect_dialog post :disconnect + get :search_models, defaults: { format: :turbo_stream } end resources :mcp_configurations, only: %i[index update], controller: "admin/mcp_configurations" do diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index 59a31846b09a..c3d240b513a4 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -109,4 +109,49 @@ def choose_action(item) expect(connection.reload.api_key).to be_nil end end + + context "with a configured connection" do + let!(:connection) { create(:llm_connection, :with_models, :enabled, base_url:) } + + before { mock_llm_models_response(base_url) } + + it "renders the model list accessibly" do + visit llm_connection_path + + expect(page).to have_text(connection.models.first.external_id) + expect(page).to be_axe_clean.within("#content") + end + + it "removes the stored API key" do + visit llm_connection_path + expect(page).to have_test_selector("llm-model--refresh-button") + + choose_action("llm-connection--delete-api-key") + + within_test_selector("llm-connection--delete-api-key-dialog") do + expect(page).to be_axe_clean + click_on "Remove API key" + end + + wait_for { connection.reload.api_key }.to be_blank + expect(connection.base_url).to eq(base_url) + end + + it "disconnects without losing the configuration" do + visit llm_connection_path + expect(page).to have_test_selector("llm-model--refresh-button") + + choose_action("llm-connection--disconnect") + + within_test_selector("llm-connection--disconnect-dialog") do + expect(page).to be_axe_clean + click_on "Disconnect" + end + + wait_for { connection.reload.enabled? }.to be(false) + expect(connection.api_key).to be_blank + # The point of disconnecting rather than deleting. + expect(connection.models.count).to eq(2) + end + end end diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 0a02eec4d73a..79085ea91a37 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -76,6 +76,16 @@ expect(page).to have_no_css(remove_api_key, visible: :all) end + it "lists the cached models without contacting the server" do + create(:llm_connection, :with_models, base_url:) + + get llm_connection_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("qwen3.6-27b") + expect(a_request(:get, "#{base_url}/models")).not_to have_been_made + end + context "when an API key is stored" do let!(:connection) { create(:llm_connection, base_url:, api_key: "sk-original") } @@ -323,4 +333,71 @@ expect(connection.reload.api_key).to eq("sk-test") end end + + describe "paginating the model list" do + let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } + + before do + login_as admin + # A gateway can report hundreds; OpenRouter returns 341. + 25.times { |n| create(:llm_model, llm_connection: connection, external_id: format("model-%03d", n)) } + end + + def rendered_rows(body) = body.scan(/model-\d{3}/).uniq.size + + it "shows one page of rows at a time rather than every model" do + get llm_connection_path, params: { per_page: 20 } + + expect(rendered_rows(response.body)).to eq(20) + expect(response.body).to include("op-pagination") + end + + it "serves the remainder on the next page" do + get llm_connection_path, params: { per_page: 20, page: 2 } + + expect(rendered_rows(response.body)).to eq(5) + end + end + + describe "filtering the model list" do + let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } + let(:filters) { [{ name: { operator: "~", values: ["bge"] } }].to_json } + + before do + login_as admin + create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") + create(:llm_model, llm_connection: connection, external_id: "bge-m3") + create(:llm_model, llm_connection: connection, external_id: "e5-large", display_name: "BGE compatible") + end + + def rendered_rows(body) = ["qwen3.6-27b", "bge-m3", "BGE compatible"].count { |name| body.include?(name) } + + it "narrows the table to matching models" do + get search_models_llm_connection_path, params: { filters: } + + expect(response).to have_http_status(:ok) + # The identifier and the friendly name both match, since either is what + # somebody would type. + expect(rendered_rows(response.body)).to eq(2) + expect(response.body).to include("bge-m3") + end + + it "answers with a turbo stream so only the table is replaced" do + get search_models_llm_connection_path, params: { filters: } + + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + + it "applies the filter to the full page too, so a shared link works" do + get llm_connection_path, params: { filters: } + + expect(rendered_rows(response.body)).to eq(2) + end + + it "shows everything without a filter" do + get llm_connection_path + + expect(rendered_rows(response.body)).to eq(3) + end + end end From f02ba135591ef7331c7ed4947d60d239c7724e29 Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 09:09:14 +0200 Subject: [PATCH 02/12] [AI-3] Move the model list to its own AI models page Following the UX review with Tom: the LLM settings page now carries only the server configuration, and everything about the model catalogue moves to a new admin page "AI models" with its own menu entry under the AI section. The page lists the cached models, filters and paginates them and carries the manual refresh, which returns here as Admin::LlmModelsController#refresh. The connection page points at it from the header description, and the flash shown after a save without models does the same. The locale keys of the table move with it into the admin.llm_models.index scope; the keys that only later parts read (the status toggle, the model form captions) go with those parts. --- .../models/sub_header_component.html.erb | 12 +- .../models/sub_header_component.rb | 2 +- .../llm_connections/models_row_component.rb | 2 +- .../llm_connections/models_table_component.rb | 14 +- .../admin/llm_connections_controller.rb | 35 +--- .../admin/llm_models_controller.rb | 81 +++++++++ .../queries/llm_models/filters/name_filter.rb | 2 +- app/views/admin/llm_connections/show.html.erb | 31 +--- app/views/admin/llm_models/index.html.erb | 62 +++++++ config/initializers/menus.rb | 6 + config/locales/en.yml | 36 ++-- config/routes.rb | 9 +- spec/features/admin/llm_connection_spec.rb | 7 +- spec/requests/admin/llm_connections_spec.rb | 77 -------- spec/requests/admin/llm_models_spec.rb | 172 ++++++++++++++++++ 15 files changed, 374 insertions(+), 174 deletions(-) create mode 100644 app/controllers/admin/llm_models_controller.rb create mode 100644 app/views/admin/llm_models/index.html.erb create mode 100644 spec/requests/admin/llm_models_spec.rb diff --git a/app/components/llm_connections/models/sub_header_component.html.erb b/app/components/llm_connections/models/sub_header_component.html.erb index 0107aed88da3..299ae7f56238 100644 --- a/app/components/llm_connections/models/sub_header_component.html.erb +++ b/app/components/llm_connections/models/sub_header_component.html.erb @@ -1,10 +1,10 @@ <%= render(Primer::OpenProject::SubHeader.new(collapsed_search: false, data: sub_header_data_attributes)) do |subheader| %> <% subheader.with_filter_input( name: "name", - label: t("admin.llm_connections.models.filter_label"), + label: t("admin.llm_models.index.filter_label"), visually_hide_label: true, value: filter_input_value, - placeholder: t("admin.llm_connections.models.filter_label"), + placeholder: t("admin.llm_models.index.filter_label"), leading_visual: { icon: :search, size: :small }, show_clear_button: true, clear_button_id:, @@ -13,13 +13,13 @@ <% subheader.with_action_button( scheme: :default, - label: t("admin.llm_connections.show.refresh_models"), - mobile_label: t("admin.llm_connections.show.refresh_models"), + label: t("admin.llm_models.index.refresh"), + mobile_label: t("admin.llm_models.index.refresh"), mobile_icon: :sync, leading_icon: :sync, tag: :a, - href: helpers.refresh_models_llm_connection_path, + href: helpers.refresh_llm_models_path, data: { turbo_method: :post, controller: "disable-when-clicked" }, test_selector: "llm-model--refresh-button" - ) { t("admin.llm_connections.show.refresh_models") } %> + ) { t("admin.llm_models.index.refresh") } %> <% end %> diff --git a/app/components/llm_connections/models/sub_header_component.rb b/app/components/llm_connections/models/sub_header_component.rb index 57cb69e49ec6..98e2513d0993 100644 --- a/app/components/llm_connections/models/sub_header_component.rb +++ b/app/components/llm_connections/models/sub_header_component.rb @@ -47,7 +47,7 @@ def sub_header_data_attributes controller: "filter--filters-form", "filter--filters-form-perform-turbo-requests-value": true, "filter--filters-form-output-format-value": "json", - "filter--filters-form-url-path-name-value": helpers.search_models_llm_connection_path, + "filter--filters-form-url-path-name-value": helpers.search_llm_models_path, "filter--filters-form-clear-button-id-value": clear_button_id } end diff --git a/app/components/llm_connections/models_row_component.rb b/app/components/llm_connections/models_row_component.rb index c638fec8fea7..fc4328e3f26f 100644 --- a/app/components/llm_connections/models_row_component.rb +++ b/app/components/llm_connections/models_row_component.rb @@ -64,7 +64,7 @@ def kind def source scheme, key = source_label - render(Primer::Beta::Label.new(scheme:)) { I18n.t("admin.llm_connections.models.#{key}") } + render(Primer::Beta::Label.new(scheme:)) { I18n.t("admin.llm_models.index.#{key}") } end def source_label diff --git a/app/components/llm_connections/models_table_component.rb b/app/components/llm_connections/models_table_component.rb index a1fdb3f2f298..9b2414d1b18f 100644 --- a/app/components/llm_connections/models_table_component.rb +++ b/app/components/llm_connections/models_table_component.rb @@ -52,17 +52,17 @@ def initial_sort = %i[identifier asc] def has_footer? = false - def mobile_title = I18n.t("admin.llm_connections.show.models_heading") + def mobile_title = I18n.t("menus.admin.llm_models") # The row class is otherwise derived by convention as LlmConnections::RowComponent. def row_class = ModelsRowComponent def headers [ - [:identifier, { caption: I18n.t("admin.llm_connections.models.identifier") }], - [:kind, { caption: I18n.t("admin.llm_connections.models.kind") }], - [:context_window, { caption: I18n.t("admin.llm_connections.models.context_window") }], - [:source, { caption: I18n.t("admin.llm_connections.models.source") }] + [: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") }] ] end @@ -76,9 +76,9 @@ def load_embeddings_states connection.capability_verdicts.for_capability(:embeddings).pluck(:model_id, :state).to_h end - def blank_title = I18n.t("admin.llm_connections.models.blank_title") + def blank_title = I18n.t("admin.llm_models.index.blank_title") - def blank_description = I18n.t("admin.llm_connections.models.blank_description") + def blank_description = I18n.t("admin.llm_models.index.blank_description") def blank_icon = :sparkle end diff --git a/app/controllers/admin/llm_connections_controller.rb b/app/controllers/admin/llm_connections_controller.rb index be530302a3e3..f3aaa2a375fc 100644 --- a/app/controllers/admin/llm_connections_controller.rb +++ b/app/controllers/admin/llm_connections_controller.rb @@ -31,7 +31,6 @@ module Admin class LlmConnectionsController < ApplicationController include OpTurbo::ComponentStream - include PaginationHelper layout "admin" menu_item :llm_connection @@ -40,24 +39,7 @@ class LlmConnectionsController < ApplicationController before_action :require_admin before_action :set_connection - def show - @query = ParamsToQueryService - .new(LlmModel, current_user, query_class: Queries::LlmModels::LlmModelQuery) - .call(params) - @models = @query.results.paginate(page: page_param, per_page: per_page_param) - end - - # Answers the sub-header's filter input, replacing just the table. - def search_models - show - - replace_via_turbo_stream( - component: LlmConnections::Models::IndexComponent.new(@models, connection: @connection) - ) - turbo_streams << turbo_stream.push_state(llm_connection_path(params.permit(:filters, :page, :per_page))) - - respond_with_turbo_streams - end + def show; end def update result = ::LlmConnections::UpdateService @@ -68,16 +50,6 @@ def update result.on_failure { render_form_with_errors } end - def refresh_models - result = ::LlmConnections::SyncModelsService.new(@connection).call - - if result.success? - redirect_with_notice(t(".success")) - else - redirect_with_error(t(".failure")) - end - end - def disconnect_dialog respond_with_dialog LlmConnections::DisconnectDialogComponent.new(@connection) end @@ -135,10 +107,7 @@ def redirect_after_save def render_form_with_errors update_via_turbo_stream(component: ::LlmConnections::FormComponent.new(@connection)) respond_with_turbo_streams do |format| - format.html do - show - render :show - end + format.html { render :show } end end diff --git a/app/controllers/admin/llm_models_controller.rb b/app/controllers/admin/llm_models_controller.rb new file mode 100644 index 000000000000..9a196abec587 --- /dev/null +++ b/app/controllers/admin/llm_models_controller.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. +#++ + +module Admin + class LlmModelsController < ApplicationController + include OpTurbo::ComponentStream + include PaginationHelper + + layout "admin" + menu_item :llm_models + + before_action :require_feature + before_action :require_admin + before_action :set_connection + + def index + @query = ParamsToQueryService + .new(LlmModel, current_user, query_class: Queries::LlmModels::LlmModelQuery) + .call(params) + @models = @query.results.paginate(page: page_param, per_page: per_page_param) + end + + # Answers the sub-header's filter input, replacing just the table. + def search + index + + replace_via_turbo_stream( + component: LlmConnections::Models::IndexComponent.new(@models, connection: @connection) + ) + turbo_streams << turbo_stream.push_state(llm_models_path(params.permit(:filters, :page, :per_page))) + + respond_with_turbo_streams + end + + def refresh + result = ::LlmConnections::SyncModelsService.new(@connection).call + + flash[result.success? ? :notice : :error] = t(result.success? ? ".success" : ".failure") + redirect_to llm_models_path, status: :see_other + end + + private + + def set_connection + @connection = LlmConnection.instance + end + + # The flag gates the endpoints, not only the menu entry: an unfinished page + # must not accept writes just because somebody knows the URL. + def require_feature + render_404 unless OpenProject::FeatureDecisions.llm_connection_active? + end + end +end diff --git a/app/models/queries/llm_models/filters/name_filter.rb b/app/models/queries/llm_models/filters/name_filter.rb index 4fe27abbaa63..b280f524c3ba 100644 --- a/app/models/queries/llm_models/filters/name_filter.rb +++ b/app/models/queries/llm_models/filters/name_filter.rb @@ -38,7 +38,7 @@ def type end def human_name - I18n.t("admin.llm_connections.models.filter_label") + I18n.t("admin.llm_models.index.filter_label") end # Matches the identifier the server uses and the friendly name an diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb index 4fbeef3261e5..fb0192af06ca 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -32,7 +32,13 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { t("menus.admin.llm_connection") } - header.with_description { t(".description") } + header.with_description do + link_translate( + "admin.llm_connections.show.description", + links: { models_url: llm_models_path }, + external: false + ) + end header.with_breadcrumbs( [{ href: admin_index_path, text: t(:label_administration) }, { href: mcp_configurations_path, text: t("menus.admin.ai") }, @@ -80,25 +86,4 @@ See COPYRIGHT and LICENSE files for more details. end %> -<% if @connection.persisted? %> - <%= render(LlmConnections::FormComponent.new(@connection)) %> - - <%= - render(Primer::Beta::Subhead.new(mt: 4)) do |component| - component.with_heading(tag: :h3) { t(".models_heading") } - component.with_description do - if @connection.catalogue_fetched_at - t(".models_description", fetched_at: format_time(@connection.catalogue_fetched_at)) - else - t(".models_description_unfetched") - end - end - end - %> - - <%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> - <%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> -<% else %> - <%# Nothing to show alongside the form until a connection exists. %> - <%= render(LlmConnections::FormComponent.new(@connection)) %> -<% end %> +<%= render(LlmConnections::FormComponent.new(@connection)) %> diff --git a/app/views/admin/llm_models/index.html.erb b/app/views/admin/llm_models/index.html.erb new file mode 100644 index 000000000000..d5e0456d4cf3 --- /dev/null +++ b/app/views/admin/llm_models/index.html.erb @@ -0,0 +1,62 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("menus.admin.llm_models") %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t("menus.admin.llm_models") } + header.with_description do + if @connection.catalogue_fetched_at + t(".description", fetched_at: format_time(@connection.catalogue_fetched_at)) + else + t(".description_unfetched") + end + end + header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + { href: mcp_configurations_path, text: t("menus.admin.ai") }, + t("menus.admin.llm_models")] + ) + end +%> + +<% if @connection.persisted? %> + <%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> + <%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> +<% else %> + <%= + render(Primer::Beta::Blankslate.new(border: true)) do |component| + component.with_visual_icon(icon: :sparkle) + component.with_heading(tag: :h2) { t(".unconfigured_title") } + component.with_description { t(".unconfigured_description") } + component.with_primary_action(href: llm_connection_path) { t("menus.admin.llm_connection") } + end + %> +<% end %> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index bed2ac6b42cc..eac83dd27f3f 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -510,6 +510,12 @@ caption: I18n.t("menus.admin.llm_connection"), parent: :ai + menu.push :llm_models, + { controller: "/admin/llm_models", action: :index }, + if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.llm_connection_active? }, + caption: I18n.t("menus.admin.llm_models"), + parent: :ai + menu.push :mcp_configurations, { controller: "/admin/mcp_configurations", action: :index }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 56d5c505214b..de65d4b5f99f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1733,34 +1733,32 @@ en: label_connecting: "Contacting the LLM server…" server_description: "Connect OpenProject to a server that speaks the %{api_format} API, so AI features can use it. The host URL below decides which server that is." server_group: "Server" - models: - blank_description: "The server reported no models. If it does not offer a model list, add the model names you want to use below." + show: + description: "Once connected, review the models offered by the server on the [LLMs](models_url) tab." + update: + disabled: "Saved. LLM features are switched off for this instance." + no_models: "Saved, but no models are stored yet. Either the server offers no model list, or the endpoint is missing its API version segment (for example /v1). Refresh the list on the LLMs tab." + success: "Successfully connected to the LLM server." + llm_models: + index: + blank_description: "The server reported no models. Refresh the list once the server is reachable." blank_title: "No models available" context_window: "Context window" - display_name_caption: "An optional friendly name shown instead of the model id." + description: "Reported by the server on %{fetched_at}." + description_unfetched: "The server has not returned a model list yet. Refresh the models once it is reachable." filter_label: "Filter models" identifier: "Model" kind: "Type" + refresh: "Refresh models" source: "Source" - source_deactivated: "Hidden by administrator" source_discovered: "Reported by server" source_manual: "Added manually" source_withdrawn: "No longer reported" - status: "Available for use" - toggle_aria_label: "Make %{model} available to AI features" - refresh_models: + unconfigured_description: "Connect OpenProject to an LLM server first. The models it offers are listed here once it is reachable." + unconfigured_title: "No LLM server configured" + refresh: failure: "The model list could not be refreshed. Please check that the LLM server is still reachable." success: "The model list has been refreshed." - show: - description: "Connect OpenProject to an LLM server so that AI features can use it." - models_description: "Reported by the server on %{fetched_at}." - models_description_unfetched: "The server has not returned a model list yet. Refresh the models once it is reachable, or add the models you want to use manually." - models_heading: "Available models" - refresh_models: "Refresh models" - update: - disabled: "Saved. LLM features are switched off for this instance." - no_models: "Saved, but no models are stored yet. Either the server offers no model list, or the endpoint is missing its API version segment (for example /v1)." - success: "Successfully connected to the LLM server." mcp_configurations: index: description: "The model context protocol allows AI agents to provide its users with tools and resources exposed by this OpenProject instance. This feature is still in beta." @@ -4289,9 +4287,6 @@ en: perplexity: "Perplexity" vertexai: "Google Vertex AI" xai: "xAI" - context_window_sources: - registry: "the figure published for this model" - server: "reported by the server" model_kinds: chat: "Chat" embedding: "Embedding" @@ -4542,6 +4537,7 @@ en: ai: "Artificial Intelligence (AI)" api_and_webhooks: "API and webhooks" llm_connection: "LLM settings" + llm_models: "LLMs" mail_notification: "Email notifications" mails_and_notifications: "Emails and notifications" mcp_configurations: "Model Context Protocol (MCP)" diff --git a/config/routes.rb b/config/routes.rb index de2c81e44942..51fe10bbdaff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -743,12 +743,17 @@ end resource :llm_connection, only: %i[show update], controller: "admin/llm_connections" do - post :refresh_models delete :api_key, action: :delete_api_key get :delete_api_key_dialog get :disconnect_dialog post :disconnect - get :search_models, defaults: { format: :turbo_stream } + end + + resources :llm_models, only: %i[index], controller: "admin/llm_models" do + collection do + get :search, defaults: { format: :turbo_stream } + post :refresh + end end resources :mcp_configurations, only: %i[index update], controller: "admin/mcp_configurations" do diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index c3d240b513a4..27b678a391f7 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -116,15 +116,16 @@ def choose_action(item) before { mock_llm_models_response(base_url) } it "renders the model list accessibly" do - visit llm_connection_path + visit llm_models_path + expect(page).to have_test_selector("llm-model--refresh-button") expect(page).to have_text(connection.models.first.external_id) expect(page).to be_axe_clean.within("#content") end it "removes the stored API key" do visit llm_connection_path - expect(page).to have_test_selector("llm-model--refresh-button") + expect(page).to have_field("Host URL") choose_action("llm-connection--delete-api-key") @@ -139,7 +140,7 @@ def choose_action(item) it "disconnects without losing the configuration" do visit llm_connection_path - expect(page).to have_test_selector("llm-model--refresh-button") + expect(page).to have_field("Host URL") choose_action("llm-connection--disconnect") diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 79085ea91a37..0a02eec4d73a 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -76,16 +76,6 @@ expect(page).to have_no_css(remove_api_key, visible: :all) end - it "lists the cached models without contacting the server" do - create(:llm_connection, :with_models, base_url:) - - get llm_connection_path - - expect(response).to have_http_status(:ok) - expect(response.body).to include("qwen3.6-27b") - expect(a_request(:get, "#{base_url}/models")).not_to have_been_made - end - context "when an API key is stored" do let!(:connection) { create(:llm_connection, base_url:, api_key: "sk-original") } @@ -333,71 +323,4 @@ expect(connection.reload.api_key).to eq("sk-test") end end - - describe "paginating the model list" do - let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } - - before do - login_as admin - # A gateway can report hundreds; OpenRouter returns 341. - 25.times { |n| create(:llm_model, llm_connection: connection, external_id: format("model-%03d", n)) } - end - - def rendered_rows(body) = body.scan(/model-\d{3}/).uniq.size - - it "shows one page of rows at a time rather than every model" do - get llm_connection_path, params: { per_page: 20 } - - expect(rendered_rows(response.body)).to eq(20) - expect(response.body).to include("op-pagination") - end - - it "serves the remainder on the next page" do - get llm_connection_path, params: { per_page: 20, page: 2 } - - expect(rendered_rows(response.body)).to eq(5) - end - end - - describe "filtering the model list" do - let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } - let(:filters) { [{ name: { operator: "~", values: ["bge"] } }].to_json } - - before do - login_as admin - create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") - create(:llm_model, llm_connection: connection, external_id: "bge-m3") - create(:llm_model, llm_connection: connection, external_id: "e5-large", display_name: "BGE compatible") - end - - def rendered_rows(body) = ["qwen3.6-27b", "bge-m3", "BGE compatible"].count { |name| body.include?(name) } - - it "narrows the table to matching models" do - get search_models_llm_connection_path, params: { filters: } - - expect(response).to have_http_status(:ok) - # The identifier and the friendly name both match, since either is what - # somebody would type. - expect(rendered_rows(response.body)).to eq(2) - expect(response.body).to include("bge-m3") - end - - it "answers with a turbo stream so only the table is replaced" do - get search_models_llm_connection_path, params: { filters: } - - expect(response.media_type).to eq("text/vnd.turbo-stream.html") - end - - it "applies the filter to the full page too, so a shared link works" do - get llm_connection_path, params: { filters: } - - expect(rendered_rows(response.body)).to eq(2) - end - - it "shows everything without a filter" do - get llm_connection_path - - expect(rendered_rows(response.body)).to eq(3) - end - end end diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb new file mode 100644 index 000000000000..1ab903bc3d5d --- /dev/null +++ b/spec/requests/admin/llm_models_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "Admin LLM models", :llm_server_helpers, :skip_csrf, :webmock, + type: :rails_request, with_flag: { llm_connection: true } do + let(:admin) { create(:admin) } + let(:base_url) { "https://example.com/v1" } + + describe "with the feature flag off", with_flag: { llm_connection: false } do + before { login_as admin } + + it "does not expose the endpoints" do + get llm_models_path + expect(response).to have_http_status(:not_found) + + post refresh_llm_models_path + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /admin/llm_models" do + it "is not reachable for non-admins" do + login_as create(:user) + get llm_models_path + + expect(response).not_to have_http_status(:ok) + end + + context "when logged in as admin" do + before { login_as admin } + + it "lists the cached models without contacting the server" do + create(:llm_connection, :with_models, base_url:) + + get llm_models_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("qwen3.6-27b") + expect(a_request(:get, "#{base_url}/models")).not_to have_been_made + end + + it "points at the settings page while no connection is stored" do + get llm_models_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("No LLM server configured") + expect(response.body).to include(llm_connection_path) + end + end + end + + describe "POST /admin/llm_models/refresh" do + let!(:connection) { create(:llm_connection, base_url:) } + + before { login_as admin } + + it "fetches the model list again" do + mock_llm_models_response(base_url) + + post refresh_llm_models_path + + expect(response).to redirect_to(llm_models_path) + expect(connection.reload.available_model_ids).to contain_exactly("qwen3.6-27b", "bge-m3") + expect(flash[:notice]).to eq("The model list has been refreshed.") + end + + it "says so when the server cannot be reached" do + mock_llm_models_response(base_url, timeout: true) + + post refresh_llm_models_path + + expect(response).to redirect_to(llm_models_path) + expect(flash[:error]).to be_present + end + end + + describe "paginating the model list" do + let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } + + before do + login_as admin + # A gateway can report hundreds; OpenRouter returns 341. + 25.times { |n| create(:llm_model, llm_connection: connection, external_id: format("model-%03d", n)) } + end + + def rendered_rows(body) = body.scan(/model-\d{3}/).uniq.size + + it "shows one page of rows at a time rather than every model" do + get llm_models_path, params: { per_page: 20 } + + expect(rendered_rows(response.body)).to eq(20) + expect(response.body).to include("op-pagination") + end + + it "serves the remainder on the next page" do + get llm_models_path, params: { per_page: 20, page: 2 } + + expect(rendered_rows(response.body)).to eq(5) + end + end + + describe "filtering the model list" do + let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } + let(:filters) { [{ name: { operator: "~", values: ["bge"] } }].to_json } + + before do + login_as admin + create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") + create(:llm_model, llm_connection: connection, external_id: "bge-m3") + create(:llm_model, llm_connection: connection, external_id: "e5-large", display_name: "BGE compatible") + end + + def rendered_rows(body) = ["qwen3.6-27b", "bge-m3", "BGE compatible"].count { |name| body.include?(name) } + + it "narrows the table to matching models" do + get search_llm_models_path, params: { filters: } + + expect(response).to have_http_status(:ok) + # The identifier and the friendly name both match, since either is what + # somebody would type. + expect(rendered_rows(response.body)).to eq(2) + expect(response.body).to include("bge-m3") + end + + it "answers with a turbo stream so only the table is replaced" do + get search_llm_models_path, params: { filters: } + + expect(response.media_type).to eq("text/vnd.turbo-stream.html") + end + + it "applies the filter to the full page too, so a shared link works" do + get llm_models_path, params: { filters: } + + expect(rendered_rows(response.body)).to eq(2) + end + + it "shows everything without a filter" do + get llm_models_path + + expect(rendered_rows(response.body)).to eq(3) + end + end +end From 34707175d9d6375be03e23986b773f3c5d48fd30 Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 10:19:23 +0200 Subject: [PATCH 03/12] [AI-3] Warn when the model list predates the current settings Following the UX review with Tom: the model list is no longer refreshed automatically when the server settings change, so the AI models page says when the stored list was fetched under other settings than the current ones and asks for a refresh. The signal is LlmConnection#models_stale?, which compares the fingerprint the last sync recorded against the settings in force now. --- app/views/admin/llm_models/index.html.erb | 13 +++++++++++++ config/locales/en.yml | 1 + spec/requests/admin/llm_models_spec.rb | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/app/views/admin/llm_models/index.html.erb b/app/views/admin/llm_models/index.html.erb index d5e0456d4cf3..700506d619ea 100644 --- a/app/views/admin/llm_models/index.html.erb +++ b/app/views/admin/llm_models/index.html.erb @@ -48,6 +48,19 @@ See COPYRIGHT and LICENSE files for more details. %> <% if @connection.persisted? %> + <% if @connection.models_stale? %> + <%= + render( + Primer::Alpha::Banner.new( + scheme: :warning, + icon: :alert, + mb: 3, + test_selector: "llm-models--stale" + ) + ) { t(".stale_warning") } + %> + <% end %> + <%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> <%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> <% else %> diff --git a/config/locales/en.yml b/config/locales/en.yml index de65d4b5f99f..6aaa36fa759e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1754,6 +1754,7 @@ en: source_discovered: "Reported by server" source_manual: "Added manually" source_withdrawn: "No longer reported" + 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." unconfigured_description: "Connect OpenProject to an LLM server first. The models it offers are listed here once it is reachable." unconfigured_title: "No LLM server configured" refresh: diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 1ab903bc3d5d..9a0ad0d25810 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -68,6 +68,25 @@ expect(a_request(:get, "#{base_url}/models")).not_to have_been_made end + it "warns that the list predates the current settings" do + connection = create(:llm_connection, :with_models, base_url:) + connection.update!(connection_fingerprint: connection.settings_fingerprint) + connection.update!(api_key: "rotated") + + get llm_models_path + + expect(response.body).to include("llm-models--stale") + end + + it "does not warn while the list matches the settings" do + connection = create(:llm_connection, :with_models, base_url:) + connection.update!(connection_fingerprint: connection.settings_fingerprint) + + get llm_models_path + + expect(response.body).not_to include("llm-models--stale") + end + it "points at the settings page while no connection is stored" do get llm_models_path From 0df514a7c8f969b41a9c2151496f61e495b0dbda Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 10:57:39 +0200 Subject: [PATCH 04/12] [AI-3] Truncate long model names with the full text on hover Following the UX review with Tom: a model name too long for its column was cropped by the cell with no way to read the rest. The Model column is now the table's main column and its content renders through Primer::Beta::Truncate with an expandable item, so the full name appears on hover or focus and is also carried as the element's title. --- .../llm_connections/models_row_component.rb | 4 +++- .../llm_connections/models_table_component.rb | 2 ++ spec/features/admin/llm_connection_spec.rb | 4 ++++ spec/requests/admin/llm_models_spec.rb | 11 +++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/components/llm_connections/models_row_component.rb b/app/components/llm_connections/models_row_component.rb index fc4328e3f26f..74ed16b481e9 100644 --- a/app/components/llm_connections/models_row_component.rb +++ b/app/components/llm_connections/models_row_component.rb @@ -35,7 +35,9 @@ class ModelsRowComponent < OpPrimer::BorderBoxRowComponent alias_method :llm_model, :model def identifier - render(Primer::Beta::Text.new(font_weight: :bold)) { llm_model.name } + render(Primer::Beta::Truncate.new(font_weight: :bold)) do |truncate| + truncate.with_item(expandable: true, max_width: 320, title: llm_model.name) { llm_model.name } + end end # vLLM and SGLang report the operator's real --max-model-len here, which is diff --git a/app/components/llm_connections/models_table_component.rb b/app/components/llm_connections/models_table_component.rb index 9b2414d1b18f..d821e6e9736d 100644 --- a/app/components/llm_connections/models_table_component.rb +++ b/app/components/llm_connections/models_table_component.rb @@ -36,6 +36,8 @@ module LlmConnections class ModelsTableComponent < OpPrimer::BorderBoxTableComponent columns :identifier, :kind, :context_window, :source + main_column :identifier + mobile_columns :identifier # The connection is passed in rather than derived from the first row: with a diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index 27b678a391f7..468d07a2e880 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -116,6 +116,10 @@ def choose_action(item) before { mock_llm_models_response(base_url) } it "renders the model list accessibly" do + create(:llm_model, + llm_connection: connection, + external_id: "publisher/a-very-long-model-name-that-does-not-fit-the-column-32b-instruct-2026-05") + visit llm_models_path expect(page).to have_test_selector("llm-model--refresh-button") diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 9a0ad0d25810..045cf1a8abbd 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -87,6 +87,17 @@ expect(response.body).not_to include("llm-models--stale") end + it "keeps a long model name readable through the truncation" do + connection = create(:llm_connection, base_url:) + long_name = "publisher/a-very-long-model-name-that-does-not-fit-the-column-32b-instruct-2026-05" + create(:llm_model, llm_connection: connection, external_id: long_name) + + get llm_models_path + + expect(response.body).to include("Truncate-text--expandable") + expect(response.body).to include("title=\"#{long_name}\"") + end + it "points at the settings page while no connection is stored" do get llm_models_path From a80511a2a2de45a41bfb49d3ae29ec60da655dcf Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 12:00:07 +0200 Subject: [PATCH 05/12] [AI-3] Show every model as either a chat or an embedding model Following the UX review with Tom: a model is either a chat model or an embedding model, so the Type column no longer has a third "Unknown" state for a model whose embeddings verdict is missing or inconclusive. Such a model reads as a chat model, which is what OpenProject would use it for. --- .../llm_connections/models_row_component.rb | 10 ++++------ .../llm_connections/models_table_component.rb | 3 +-- config/locales/en.yml | 1 - spec/requests/admin/llm_models_spec.rb | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/app/components/llm_connections/models_row_component.rb b/app/components/llm_connections/models_row_component.rb index 74ed16b481e9..65f710f2d783 100644 --- a/app/components/llm_connections/models_row_component.rb +++ b/app/components/llm_connections/models_row_component.rb @@ -51,15 +51,13 @@ def context_window end # Derived from the embeddings verdict rather than stored separately: a model - # that produces vectors is an embedding model, and that is the same fact. + # that produces vectors is an embedding model, and everything else is a chat + # model. def kind - case table.embeddings_states[llm_model.external_id] - when "supported" + if table.embeddings_states[llm_model.external_id] == "supported" render(Primer::Beta::Label.new(scheme: :success)) { I18n.t("llm.model_kinds.embedding") } - when "unsupported" - render(Primer::Beta::Label.new(scheme: :secondary)) { I18n.t("llm.model_kinds.chat") } else - render(Primer::Beta::Label.new(scheme: :secondary, inline: true)) { I18n.t("llm.model_kinds.unknown") } + render(Primer::Beta::Label.new(scheme: :secondary)) { I18n.t("llm.model_kinds.chat") } end end diff --git a/app/components/llm_connections/models_table_component.rb b/app/components/llm_connections/models_table_component.rb index d821e6e9736d..a86a6076524d 100644 --- a/app/components/llm_connections/models_table_component.rb +++ b/app/components/llm_connections/models_table_component.rb @@ -41,8 +41,7 @@ class ModelsTableComponent < OpPrimer::BorderBoxTableComponent mobile_columns :identifier # The connection is passed in rather than derived from the first row: with a - # paginated, filtered list a page can legitimately be empty, and deriving it - # would silently degrade the kind column to "Unknown". + # paginated, filtered list a page can legitimately be empty. def initialize(connection:, **) super(**) @connection = connection diff --git a/config/locales/en.yml b/config/locales/en.yml index 6aaa36fa759e..cd31e1373fa2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4291,7 +4291,6 @@ en: model_kinds: chat: "Chat" embedding: "Embedding" - unknown: "Unknown" macro_execution_error: "Error executing the macro %{macro_name}" macro_unavailable: "Macro %{macro_name} cannot be displayed." macro_unknown: "Unknown or unsupported macro." diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 045cf1a8abbd..db35a827bea6 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -87,6 +87,20 @@ expect(response.body).not_to include("llm-models--stale") end + it "shows every model as either a chat or an embedding model" do + connection = create(:llm_connection, base_url:) + create(:llm_model, llm_connection: connection, external_id: "bge-m3") + create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") + connection.capability_verdicts.create!(model_id: "bge-m3", capability: "embeddings", + state: "supported", source: "probe", checked_at: Time.current) + + get llm_models_path + + expect(response.body).to include("Embedding") + expect(response.body).to include("Chat") + expect(response.body).not_to include("Unknown") + end + it "keeps a long model name readable through the truncation" do connection = create(:llm_connection, base_url:) long_name = "publisher/a-very-long-model-name-that-does-not-fit-the-column-32b-instruct-2026-05" From 3d87351d896d6b17678d55c4fa81e6bcfc7b3cf1 Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 12:32:20 +0200 Subject: [PATCH 06/12] [AI-3] Drop the forward-looking comment above disconnect The comment named locked embedding bindings, which only exist once AI features are registered later in the stack. The same sentence was already removed from the disconnect dialog for that reason. --- app/controllers/admin/llm_connections_controller.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/controllers/admin/llm_connections_controller.rb b/app/controllers/admin/llm_connections_controller.rb index f3aaa2a375fc..50061aebde98 100644 --- a/app/controllers/admin/llm_connections_controller.rb +++ b/app/controllers/admin/llm_connections_controller.rb @@ -54,10 +54,8 @@ def disconnect_dialog respond_with_dialog LlmConnections::DisconnectDialogComponent.new(@connection) end - # Clears the credential and switches the AI features off, keeping the endpoint, - # the catalogue and every feature binding. Deliberately not a destroy: the - # cascade would take the locked embedding bindings with it, and those are the - # only record that a vector index exists and what it was written with. + # Clears the credential and switches the AI features off, keeping the endpoint + # and the catalogue. Deliberately not a destroy. def disconnect ApplicationRecord.transaction do @connection.update!(api_key: nil) From f995eed49d413e1f2baacf2cf55a6ad2c0f4879e Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 13:25:05 +0200 Subject: [PATCH 07/12] [AI-3] Stop the dialog axe checks from failing on Primer colours The two dialog examples ran axe over the whole confirmation dialog, which reports a colour contrast violation for the muted description and the danger button label whenever it gets to measure the dialog after its animation. Both colours come from Primer's dialog chrome and apply everywhere in OpenProject, so the rule is skipped inside the dialogs; the page-level checks still cover it. Without this the two examples failed on roughly every other run. --- spec/features/admin/llm_connection_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index 468d07a2e880..bd2dc95c35b3 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -134,7 +134,9 @@ def choose_action(item) choose_action("llm-connection--delete-api-key") within_test_selector("llm-connection--delete-api-key-dialog") do - expect(page).to be_axe_clean + # The muted description and the danger button label Primer renders around + # our content miss the 4.5:1 contrast ratio, app-wide. + expect(page).to be_axe_clean.skipping("color-contrast") click_on "Remove API key" end @@ -149,7 +151,7 @@ def choose_action(item) choose_action("llm-connection--disconnect") within_test_selector("llm-connection--disconnect-dialog") do - expect(page).to be_axe_clean + expect(page).to be_axe_clean.skipping("color-contrast") click_on "Disconnect" end From 367d8dfa155ae24b113e92a868949979af713755 Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 13:54:18 +0200 Subject: [PATCH 08/12] [AI-3] Show the models as a tab of the LLM settings page The administration sidebar keeps a single AI entry, LLM settings, and the model list becomes a tab of that page rather than a second menu entry. The tab strip appears only once the connection is enabled: a switched off connection has nothing to configure beyond the settings themselves, and a lone tab says nothing. Admin::LlmModelsController highlights the LLM settings menu item and sends a request for a disabled or unconfigured connection back to the settings, so the blankslate that pointed there is gone with its locale keys. The settings form now replaces the whole page instead of its turbo frame, otherwise enabling the connection would leave the header without its new tab until the next reload. --- .../llm_connections/form_component.rb | 5 ++- .../llm_connections/models_table_component.rb | 2 +- .../admin/llm_models_controller.rb | 9 +++- app/helpers/llm_connections_helper.rb | 43 +++++++++++++++++++ app/views/admin/llm_connections/show.html.erb | 1 + app/views/admin/llm_models/index.html.erb | 42 +++++++----------- config/initializers/menus.rb | 6 --- config/locales/en.yml | 6 +-- spec/features/admin/llm_connection_spec.rb | 18 ++++++++ spec/requests/admin/llm_connections_spec.rb | 17 ++++++++ spec/requests/admin/llm_models_spec.rb | 26 ++++++----- 11 files changed, 127 insertions(+), 48 deletions(-) create mode 100644 app/helpers/llm_connections_helper.rb diff --git a/app/components/llm_connections/form_component.rb b/app/components/llm_connections/form_component.rb index 7357f73b87e6..21723809228d 100644 --- a/app/components/llm_connections/form_component.rb +++ b/app/components/llm_connections/form_component.rb @@ -49,11 +49,14 @@ def wrapper_options } end + # The save can turn the connection on, which adds the tabs to the page + # header outside this frame, so the response replaces the whole page. def form_options { model: connection, url: llm_connection_path, - method: :patch + method: :patch, + data: { turbo_frame: "_top" } } end end diff --git a/app/components/llm_connections/models_table_component.rb b/app/components/llm_connections/models_table_component.rb index a86a6076524d..714ee88d322b 100644 --- a/app/components/llm_connections/models_table_component.rb +++ b/app/components/llm_connections/models_table_component.rb @@ -53,7 +53,7 @@ def initial_sort = %i[identifier asc] def has_footer? = false - def mobile_title = I18n.t("menus.admin.llm_models") + def mobile_title = I18n.t("admin.llm_connections.tabs.models") # The row class is otherwise derived by convention as LlmConnections::RowComponent. def row_class = ModelsRowComponent diff --git a/app/controllers/admin/llm_models_controller.rb b/app/controllers/admin/llm_models_controller.rb index 9a196abec587..342e83fb84b6 100644 --- a/app/controllers/admin/llm_models_controller.rb +++ b/app/controllers/admin/llm_models_controller.rb @@ -34,11 +34,12 @@ class LlmModelsController < ApplicationController include PaginationHelper layout "admin" - menu_item :llm_models + menu_item :llm_connection before_action :require_feature before_action :require_admin before_action :set_connection + before_action :require_enabled_connection def index @query = ParamsToQueryService @@ -77,5 +78,11 @@ def set_connection def require_feature render_404 unless OpenProject::FeatureDecisions.llm_connection_active? end + + # The models are a tab of the LLM settings, and that tab is offered only + # while the connection is enabled. + def require_enabled_connection + redirect_to llm_connection_path, status: :see_other unless @connection.enabled? + end end end diff --git a/app/helpers/llm_connections_helper.rb b/app/helpers/llm_connections_helper.rb new file mode 100644 index 000000000000..331c3b563bc0 --- /dev/null +++ b/app/helpers/llm_connections_helper.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnectionsHelper + # The tabs of the LLM settings page. A connection that is switched off has + # nothing to configure beyond the settings themselves, and a single tab says + # nothing, so the nav stays empty until the connection is enabled. + def llm_settings_tabs(connection) + return [] unless connection.enabled? + + [ + { name: "connection", path: llm_connection_path, label: t("admin.llm_connections.tabs.connection") }, + { name: "models", path: llm_models_path, label: t("admin.llm_connections.tabs.models") } + ] + end +end diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb index fb0192af06ca..310a2529de9e 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -44,6 +44,7 @@ See COPYRIGHT and LICENSE files for more details. { href: mcp_configurations_path, text: t("menus.admin.ai") }, t("menus.admin.llm_connection")] ) + render_tab_header_nav(header, llm_settings_tabs(@connection), test_selector: "llm-settings--tabs") if @connection.persisted? header.with_action_menu( diff --git a/app/views/admin/llm_models/index.html.erb b/app/views/admin/llm_models/index.html.erb index 700506d619ea..23e6a4e445b2 100644 --- a/app/views/admin/llm_models/index.html.erb +++ b/app/views/admin/llm_models/index.html.erb @@ -27,11 +27,11 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("menus.admin.llm_models") %> +<% html_title t(:label_administration), t("menus.admin.llm_connection"), t("admin.llm_connections.tabs.models") %> <%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { t("menus.admin.llm_models") } + header.with_title { t("menus.admin.llm_connection") } header.with_description do if @connection.catalogue_fetched_at t(".description", fetched_at: format_time(@connection.catalogue_fetched_at)) @@ -42,34 +42,24 @@ See COPYRIGHT and LICENSE files for more details. header.with_breadcrumbs( [{ href: admin_index_path, text: t(:label_administration) }, { href: mcp_configurations_path, text: t("menus.admin.ai") }, - t("menus.admin.llm_models")] + t("menus.admin.llm_connection")] ) + render_tab_header_nav(header, llm_settings_tabs(@connection), test_selector: "llm-settings--tabs") end %> -<% if @connection.persisted? %> - <% if @connection.models_stale? %> - <%= - render( - Primer::Alpha::Banner.new( - scheme: :warning, - icon: :alert, - mb: 3, - test_selector: "llm-models--stale" - ) - ) { t(".stale_warning") } - %> - <% end %> - - <%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> - <%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> -<% else %> +<% if @connection.models_stale? %> <%= - render(Primer::Beta::Blankslate.new(border: true)) do |component| - component.with_visual_icon(icon: :sparkle) - component.with_heading(tag: :h2) { t(".unconfigured_title") } - component.with_description { t(".unconfigured_description") } - component.with_primary_action(href: llm_connection_path) { t("menus.admin.llm_connection") } - end + render( + Primer::Alpha::Banner.new( + scheme: :warning, + icon: :alert, + mb: 3, + test_selector: "llm-models--stale" + ) + ) { t(".stale_warning") } %> <% end %> + +<%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %> +<%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index eac83dd27f3f..bed2ac6b42cc 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -510,12 +510,6 @@ caption: I18n.t("menus.admin.llm_connection"), parent: :ai - menu.push :llm_models, - { controller: "/admin/llm_models", action: :index }, - if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.llm_connection_active? }, - caption: I18n.t("menus.admin.llm_models"), - parent: :ai - menu.push :mcp_configurations, { controller: "/admin/mcp_configurations", action: :index }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index cd31e1373fa2..4429fd006631 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1735,6 +1735,9 @@ en: server_group: "Server" show: description: "Once connected, review the models offered by the server on the [LLMs](models_url) tab." + tabs: + connection: "Connection" + models: "LLMs" update: disabled: "Saved. LLM features are switched off for this instance." no_models: "Saved, but no models are stored yet. Either the server offers no model list, or the endpoint is missing its API version segment (for example /v1). Refresh the list on the LLMs tab." @@ -1755,8 +1758,6 @@ en: source_manual: "Added manually" source_withdrawn: "No longer reported" 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." - unconfigured_description: "Connect OpenProject to an LLM server first. The models it offers are listed here once it is reachable." - unconfigured_title: "No LLM server configured" refresh: failure: "The model list could not be refreshed. Please check that the LLM server is still reachable." success: "The model list has been refreshed." @@ -4537,7 +4538,6 @@ en: ai: "Artificial Intelligence (AI)" api_and_webhooks: "API and webhooks" llm_connection: "LLM settings" - llm_models: "LLMs" mail_notification: "Email notifications" mails_and_notifications: "Emails and notifications" mcp_configurations: "Model Context Protocol (MCP)" diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index bd2dc95c35b3..effd4e8ae693 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -76,6 +76,24 @@ def choose_action(item) expect(page).to have_field("Host URL") end + it "offers the models tab only once the connection is enabled" do + mock_llm_models_response(base_url) + + visit llm_connection_path + + expect(page).to have_no_test_selector("llm-settings--tabs") + + check "Enable LLMs for this instance" + fill_in "Host URL", with: base_url + click_on "Connect" + + expect(page).to have_test_selector("llm-settings--tabs") + + within_test_selector("llm-settings--tabs") { click_on "LLMs" } + + expect(page).to have_current_path(llm_models_path) + end + it "describes the server the selected API format expects" do visit llm_connection_path diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 0a02eec4d73a..71a7cf7d05ba 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -76,6 +76,23 @@ expect(page).to have_no_css(remove_api_key, visible: :all) end + it "offers no tabs while the connection is disabled" do + create(:llm_connection, base_url:) + + get llm_connection_path + + expect(response.body).not_to include("llm-settings--tabs") + end + + it "offers the LLMs tab once the connection is enabled" do + create(:llm_connection, :enabled, base_url:) + + get llm_connection_path + + expect(response.body).to include("llm-settings--tabs") + expect(response.body).to include(llm_models_path) + end + context "when an API key is stored" do let!(:connection) { create(:llm_connection, base_url:, api_key: "sk-original") } diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index db35a827bea6..a7fa2ceadecd 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -59,7 +59,7 @@ before { login_as admin } it "lists the cached models without contacting the server" do - create(:llm_connection, :with_models, base_url:) + create(:llm_connection, :with_models, :enabled, base_url:) get llm_models_path @@ -69,7 +69,7 @@ end it "warns that the list predates the current settings" do - connection = create(:llm_connection, :with_models, base_url:) + connection = create(:llm_connection, :with_models, :enabled, base_url:) connection.update!(connection_fingerprint: connection.settings_fingerprint) connection.update!(api_key: "rotated") @@ -79,7 +79,7 @@ end it "does not warn while the list matches the settings" do - connection = create(:llm_connection, :with_models, base_url:) + connection = create(:llm_connection, :with_models, :enabled, base_url:) connection.update!(connection_fingerprint: connection.settings_fingerprint) get llm_models_path @@ -88,7 +88,7 @@ end it "shows every model as either a chat or an embedding model" do - connection = create(:llm_connection, base_url:) + connection = create(:llm_connection, :enabled, base_url:) create(:llm_model, llm_connection: connection, external_id: "bge-m3") create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") connection.capability_verdicts.create!(model_id: "bge-m3", capability: "embeddings", @@ -102,7 +102,7 @@ end it "keeps a long model name readable through the truncation" do - connection = create(:llm_connection, base_url:) + connection = create(:llm_connection, :enabled, base_url:) long_name = "publisher/a-very-long-model-name-that-does-not-fit-the-column-32b-instruct-2026-05" create(:llm_model, llm_connection: connection, external_id: long_name) @@ -112,18 +112,24 @@ expect(response.body).to include("title=\"#{long_name}\"") end - it "points at the settings page while no connection is stored" do + it "sends the administrator to the settings while the connection is disabled" do + create(:llm_connection, :with_models, base_url:) + get llm_models_path - expect(response).to have_http_status(:ok) - expect(response.body).to include("No LLM server configured") - expect(response.body).to include(llm_connection_path) + expect(response).to redirect_to(llm_connection_path) + end + + it "sends the administrator to the settings while no connection is stored" do + get llm_models_path + + expect(response).to redirect_to(llm_connection_path) end end end describe "POST /admin/llm_models/refresh" do - let!(:connection) { create(:llm_connection, base_url:) } + let!(:connection) { create(:llm_connection, :enabled, base_url:) } before { login_as admin } From a8fc459075260db59586c8134601567b3270f69b Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 14:29:58 +0200 Subject: [PATCH 09/12] [AI-3] Keep the source label inside its column The Source column shares the equal-width desktop grid with three other columns, and every non-main cell carries the global ellipsis class, so "Reported by server" was cut mid-word with the pill's right border clipped off. The labels now read Server, Manual and Withdrawn, and the sentence they replace moves into the label's title attribute, so the full meaning is still one hover away. The source cell also opts out of the shortener with -no-ellipsis, so a longer translation wraps instead of being cut. --- .../llm_connections/models_row_component.rb | 10 +++++++++- config/locales/en.yml | 9 ++++++--- spec/requests/admin/llm_models_spec.rb | 12 ++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/components/llm_connections/models_row_component.rb b/app/components/llm_connections/models_row_component.rb index 65f710f2d783..d54c34941055 100644 --- a/app/components/llm_connections/models_row_component.rb +++ b/app/components/llm_connections/models_row_component.rb @@ -34,6 +34,12 @@ module LlmConnections class ModelsRowComponent < OpPrimer::BorderBoxRowComponent alias_method :llm_model, :model + def column_css_class(column) + return "#{super} -no-ellipsis" if column == :source + + super + end + def identifier render(Primer::Beta::Truncate.new(font_weight: :bold)) do |truncate| truncate.with_item(expandable: true, max_width: 320, title: llm_model.name) { llm_model.name } @@ -64,7 +70,9 @@ def kind def source scheme, key = source_label - render(Primer::Beta::Label.new(scheme:)) { I18n.t("admin.llm_models.index.#{key}") } + render(Primer::Beta::Label.new(scheme:, title: I18n.t("admin.llm_models.index.#{key}_description"))) do + I18n.t("admin.llm_models.index.#{key}") + end end def source_label diff --git a/config/locales/en.yml b/config/locales/en.yml index 4429fd006631..07b8c23df7ae 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1754,9 +1754,12 @@ en: kind: "Type" refresh: "Refresh models" source: "Source" - source_discovered: "Reported by server" - source_manual: "Added manually" - source_withdrawn: "No longer reported" + source_discovered: "Server" + source_discovered_description: "Reported by the server" + source_manual: "Manual" + source_manual_description: "Added manually by an administrator" + 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." refresh: failure: "The model list could not be refreshed. Please check that the LLM server is still reachable." diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index a7fa2ceadecd..997b934673a3 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -112,6 +112,18 @@ expect(response.body).to include("title=\"#{long_name}\"") end + it "keeps the source label short and spells it out on hover" do + connection = create(:llm_connection, :enabled, base_url:) + create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") + + get llm_models_path + + cell = page.find(".op-border-box-grid__row-item.source") + expect(cell[:class]).to include("-no-ellipsis") + expect(cell.find(".Label").text).to eq("Server") + expect(cell.find(".Label")[:title]).to eq("Reported by the server") + end + it "sends the administrator to the settings while the connection is disabled" do create(:llm_connection, :with_models, base_url:) From cdb14a656c4bf25cf6d4fb9a9454e1e873d6491b Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 15:05:38 +0200 Subject: [PATCH 10/12] [AI-3] Point at the LLMs tab only once it is offered The description below the title sent every administrator to the LLMs tab, but the tabs appear only while the connection is enabled and Admin::LlmModelsController redirects a direct visit back. On a fresh instance the page therefore advertised a link that leads nowhere. A switched-off connection now reads the neutral sentence instead, and the pointer is kept for the enabled state. --- app/views/admin/llm_connections/show.html.erb | 14 +++++++++----- config/locales/en.yml | 3 ++- spec/requests/admin/llm_connections_spec.rb | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb index 310a2529de9e..aa2e54c6f0f6 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -33,11 +33,15 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { t("menus.admin.llm_connection") } header.with_description do - link_translate( - "admin.llm_connections.show.description", - links: { models_url: llm_models_path }, - external: false - ) + if @connection.enabled? + link_translate( + "admin.llm_connections.show.description_enabled", + links: { models_url: llm_models_path }, + external: false + ) + else + t("admin.llm_connections.show.description") + end end header.with_breadcrumbs( [{ href: admin_index_path, text: t(:label_administration) }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 07b8c23df7ae..b1ea1ae12b8e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1734,7 +1734,8 @@ en: server_description: "Connect OpenProject to a server that speaks the %{api_format} API, so AI features can use it. The host URL below decides which server that is." server_group: "Server" show: - description: "Once connected, review the models offered by the server on the [LLMs](models_url) tab." + description: "Connect OpenProject to an LLM server so that AI features can use it." + description_enabled: "Once connected, review the models offered by the server on the [LLMs](models_url) tab." tabs: connection: "Connection" models: "LLMs" diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index 71a7cf7d05ba..a5e433225d27 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -84,6 +84,24 @@ expect(response.body).not_to include("llm-settings--tabs") end + it "points at no tab while the connection is disabled" do + create(:llm_connection, base_url:) + + get llm_connection_path + + expect(response.body).to include("Connect OpenProject to an LLM server") + expect(response.body).not_to include("review the models offered by the server") + end + + it "points at the LLMs tab once the connection is enabled" do + create(:llm_connection, :enabled, base_url:) + + get llm_connection_path + + expect(response.body).to include("review the models offered by the server") + expect(page).to have_css("a[href='#{llm_models_path}']", text: "LLMs") + end + it "offers the LLMs tab once the connection is enabled" do create(:llm_connection, :enabled, base_url:) From 386b860e1501237e48a6bd048c348e9698d861db Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 16:02:43 +0200 Subject: [PATCH 11/12] [AI-3] Say why a direct visit lands on the settings Opening the LLMs URL while LLM features are switched off bounced the administrator back to the settings page without a word, which reads as a broken link. The redirect now carries a notice naming the reason. --- app/controllers/admin/llm_models_controller.rb | 5 ++++- config/locales/en.yml | 1 + spec/requests/admin/llm_models_spec.rb | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/llm_models_controller.rb b/app/controllers/admin/llm_models_controller.rb index 342e83fb84b6..72c2a20a6a9f 100644 --- a/app/controllers/admin/llm_models_controller.rb +++ b/app/controllers/admin/llm_models_controller.rb @@ -82,7 +82,10 @@ def require_feature # The models are a tab of the LLM settings, and that tab is offered only # while the connection is enabled. def require_enabled_connection - redirect_to llm_connection_path, status: :see_other unless @connection.enabled? + return if @connection.enabled? + + flash[:notice] = t("admin.llm_connections.disabled_notice") + redirect_to llm_connection_path, status: :see_other end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index b1ea1ae12b8e..bf031334251b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1713,6 +1713,7 @@ en: menu_label: "Remove API key" success: "The API key has been removed." title: "Remove API key" + disabled_notice: "LLM features are switched off for this instance. Switch them on here first." disconnect: description: "OpenProject will stop using the LLM server. AI features will be unavailable until you connect again." heading: "Disconnect from the LLM server?" diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 997b934673a3..3488a42d0c7e 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -130,6 +130,7 @@ get llm_models_path expect(response).to redirect_to(llm_connection_path) + expect(flash[:notice]).to eq(I18n.t("admin.llm_connections.disabled_notice")) end it "sends the administrator to the settings while no connection is stored" do From 9a59eaf19a1fb5f5167056b9e47ed2dcf253f5ea Mon Sep 17 00:00:00 2001 From: Andreas Eiselt Date: Thu, 3 Sep 2026 17:05:00 +0200 Subject: [PATCH 12/12] [AI-3] Follow the settings switch through the models tab The models tab and the settings page still asked the connection row whether LLMs were on. That answer moved to Setting.llm_features_enabled, so the tab helper, the page description and the models controller read it there instead, and the controller also keeps sending an administrator back while no server is configured, which the connection row used to imply. The specs that switched the features on through a factory trait now set the setting, and the one that checks the redirect turns it off explicitly. --- .../admin/llm_models_controller.rb | 6 ++--- app/helpers/llm_connections_helper.rb | 10 ++++---- app/views/admin/llm_connections/show.html.erb | 2 +- spec/features/admin/llm_connection_spec.rb | 4 ++-- spec/requests/admin/llm_connections_spec.rb | 10 ++++---- spec/requests/admin/llm_models_spec.rb | 24 ++++++++++--------- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/app/controllers/admin/llm_models_controller.rb b/app/controllers/admin/llm_models_controller.rb index 72c2a20a6a9f..fb71883d28e1 100644 --- a/app/controllers/admin/llm_models_controller.rb +++ b/app/controllers/admin/llm_models_controller.rb @@ -70,7 +70,7 @@ def refresh private def set_connection - @connection = LlmConnection.instance + @connection = LlmConnection.active_connection end # The flag gates the endpoints, not only the menu entry: an unfinished page @@ -80,9 +80,9 @@ def require_feature end # The models are a tab of the LLM settings, and that tab is offered only - # while the connection is enabled. + # while the AI features are switched on and a server is configured. def require_enabled_connection - return if @connection.enabled? + return if Setting.llm_features_enabled? && @connection.configured? flash[:notice] = t("admin.llm_connections.disabled_notice") redirect_to llm_connection_path, status: :see_other diff --git a/app/helpers/llm_connections_helper.rb b/app/helpers/llm_connections_helper.rb index 331c3b563bc0..06d29c99ff95 100644 --- a/app/helpers/llm_connections_helper.rb +++ b/app/helpers/llm_connections_helper.rb @@ -29,11 +29,11 @@ #++ module LlmConnectionsHelper - # The tabs of the LLM settings page. A connection that is switched off has - # nothing to configure beyond the settings themselves, and a single tab says - # nothing, so the nav stays empty until the connection is enabled. - def llm_settings_tabs(connection) - return [] unless connection.enabled? + # The tabs of the LLM settings page. With the AI features switched off there + # is nothing to configure beyond the settings themselves, and a single tab says + # nothing, so the nav stays empty until they are switched on. + def llm_settings_tabs(_connection) + return [] unless Setting.llm_features_enabled? [ { name: "connection", path: llm_connection_path, label: t("admin.llm_connections.tabs.connection") }, diff --git a/app/views/admin/llm_connections/show.html.erb b/app/views/admin/llm_connections/show.html.erb index aa2e54c6f0f6..3cda79136b33 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { t("menus.admin.llm_connection") } header.with_description do - if @connection.enabled? + if Setting.llm_features_enabled? link_translate( "admin.llm_connections.show.description_enabled", links: { models_url: llm_models_path }, diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index effd4e8ae693..fc58cef31955 100644 --- a/spec/features/admin/llm_connection_spec.rb +++ b/spec/features/admin/llm_connection_spec.rb @@ -129,7 +129,7 @@ def choose_action(item) end context "with a configured connection" do - let!(:connection) { create(:llm_connection, :with_models, :enabled, base_url:) } + let!(:connection) { create(:llm_connection, :with_models, base_url:) } before { mock_llm_models_response(base_url) } @@ -173,7 +173,7 @@ def choose_action(item) click_on "Disconnect" end - wait_for { connection.reload.enabled? }.to be(false) + wait_for { Setting.llm_features_enabled? }.to be(false) expect(connection.api_key).to be_blank # The point of disconnecting rather than deleting. expect(connection.models.count).to eq(2) diff --git a/spec/requests/admin/llm_connections_spec.rb b/spec/requests/admin/llm_connections_spec.rb index a5e433225d27..da2d57dd3a59 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -93,8 +93,9 @@ expect(response.body).not_to include("review the models offered by the server") end - it "points at the LLMs tab once the connection is enabled" do - create(:llm_connection, :enabled, base_url:) + it "points at the LLMs tab once the features are on", + with_settings: { llm_features_enabled: true } do + create(:llm_connection, base_url:) get llm_connection_path @@ -102,8 +103,9 @@ expect(page).to have_css("a[href='#{llm_models_path}']", text: "LLMs") end - it "offers the LLMs tab once the connection is enabled" do - create(:llm_connection, :enabled, base_url:) + it "offers the LLMs tab once the features are on", + with_settings: { llm_features_enabled: true } do + create(:llm_connection, base_url:) get llm_connection_path diff --git a/spec/requests/admin/llm_models_spec.rb b/spec/requests/admin/llm_models_spec.rb index 3488a42d0c7e..89975d68cb1b 100644 --- a/spec/requests/admin/llm_models_spec.rb +++ b/spec/requests/admin/llm_models_spec.rb @@ -31,7 +31,8 @@ require "spec_helper" RSpec.describe "Admin LLM models", :llm_server_helpers, :skip_csrf, :webmock, - type: :rails_request, with_flag: { llm_connection: true } do + type: :rails_request, with_flag: { llm_connection: true }, + with_settings: { llm_features_enabled: true } do let(:admin) { create(:admin) } let(:base_url) { "https://example.com/v1" } @@ -59,7 +60,7 @@ before { login_as admin } it "lists the cached models without contacting the server" do - create(:llm_connection, :with_models, :enabled, base_url:) + create(:llm_connection, :with_models, base_url:) get llm_models_path @@ -69,7 +70,7 @@ end it "warns that the list predates the current settings" do - connection = create(:llm_connection, :with_models, :enabled, base_url:) + connection = create(:llm_connection, :with_models, base_url:) connection.update!(connection_fingerprint: connection.settings_fingerprint) connection.update!(api_key: "rotated") @@ -79,7 +80,7 @@ end it "does not warn while the list matches the settings" do - connection = create(:llm_connection, :with_models, :enabled, base_url:) + connection = create(:llm_connection, :with_models, base_url:) connection.update!(connection_fingerprint: connection.settings_fingerprint) get llm_models_path @@ -88,7 +89,7 @@ end it "shows every model as either a chat or an embedding model" do - connection = create(:llm_connection, :enabled, base_url:) + connection = create(:llm_connection, base_url:) create(:llm_model, llm_connection: connection, external_id: "bge-m3") create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") connection.capability_verdicts.create!(model_id: "bge-m3", capability: "embeddings", @@ -102,7 +103,7 @@ end it "keeps a long model name readable through the truncation" do - connection = create(:llm_connection, :enabled, base_url:) + connection = create(:llm_connection, base_url:) long_name = "publisher/a-very-long-model-name-that-does-not-fit-the-column-32b-instruct-2026-05" create(:llm_model, llm_connection: connection, external_id: long_name) @@ -113,7 +114,7 @@ end it "keeps the source label short and spells it out on hover" do - connection = create(:llm_connection, :enabled, base_url:) + connection = create(:llm_connection, base_url:) create(:llm_model, llm_connection: connection, external_id: "qwen3.6-27b") get llm_models_path @@ -124,7 +125,8 @@ expect(cell.find(".Label")[:title]).to eq("Reported by the server") end - it "sends the administrator to the settings while the connection is disabled" do + it "sends the administrator to the settings while the features are off", + with_settings: { llm_features_enabled: false } do create(:llm_connection, :with_models, base_url:) get llm_models_path @@ -142,7 +144,7 @@ end describe "POST /admin/llm_models/refresh" do - let!(:connection) { create(:llm_connection, :enabled, base_url:) } + let!(:connection) { create(:llm_connection, base_url:) } before { login_as admin } @@ -167,7 +169,7 @@ end describe "paginating the model list" do - let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } + let!(:connection) { create(:llm_connection, base_url: "https://example.com/v1") } before do login_as admin @@ -192,7 +194,7 @@ def rendered_rows(body) = body.scan(/model-\d{3}/).uniq.size end describe "filtering the model list" do - let!(:connection) { create(:llm_connection, :enabled, base_url: "https://example.com/v1") } + let!(:connection) { create(:llm_connection, base_url: "https://example.com/v1") } let(:filters) { [{ name: { operator: "~", values: ["bge"] } }].to_json } before do