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/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..299ae7f56238 --- /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_models.index.filter_label"), + visually_hide_label: true, + value: filter_input_value, + placeholder: t("admin.llm_models.index.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_models.index.refresh"), + mobile_label: t("admin.llm_models.index.refresh"), + mobile_icon: :sync, + leading_icon: :sync, + tag: :a, + href: helpers.refresh_llm_models_path, + data: { turbo_method: :post, controller: "disable-when-clicked" }, + test_selector: "llm-model--refresh-button" + ) { 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 new file mode 100644 index 000000000000..98e2513d0993 --- /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_llm_models_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..d54c34941055 --- /dev/null +++ b/app/components/llm_connections/models_row_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 + # 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 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 } + end + 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 everything else is a chat + # model. + def kind + if table.embeddings_states[llm_model.external_id] == "supported" + render(Primer::Beta::Label.new(scheme: :success)) { I18n.t("llm.model_kinds.embedding") } + else + render(Primer::Beta::Label.new(scheme: :secondary)) { I18n.t("llm.model_kinds.chat") } + end + end + + def source + scheme, key = source_label + + 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 + 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..714ee88d322b --- /dev/null +++ b/app/components/llm_connections/models_table_component.rb @@ -0,0 +1,86 @@ +# 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 + + main_column :identifier + + 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. + 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.tabs.models") + + # The row class is otherwise derived by convention as LlmConnections::RowComponent. + def row_class = ModelsRowComponent + + def headers + [ + [:identifier, { caption: I18n.t("admin.llm_models.index.identifier") }], + [:kind, { caption: I18n.t("admin.llm_models.index.kind") }], + [:context_window, { caption: I18n.t("admin.llm_models.index.context_window") }], + [:source, { caption: I18n.t("admin.llm_models.index.source") }] + ] + 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_models.index.blank_title") + + def blank_description = I18n.t("admin.llm_models.index.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..50061aebde98 100644 --- a/app/controllers/admin/llm_connections_controller.rb +++ b/app/controllers/admin/llm_connections_controller.rb @@ -105,10 +105,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..fb71883d28e1 --- /dev/null +++ b/app/controllers/admin/llm_models_controller.rb @@ -0,0 +1,91 @@ +# 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_connection + + before_action :require_feature + before_action :require_admin + before_action :set_connection + before_action :require_enabled_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.active_connection + 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 + + # The models are a tab of the LLM settings, and that tab is offered only + # while the AI features are switched on and a server is configured. + def require_enabled_connection + return if Setting.llm_features_enabled? && @connection.configured? + + flash[:notice] = t("admin.llm_connections.disabled_notice") + redirect_to llm_connection_path, status: :see_other + 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..06d29c99ff95 --- /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. 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") }, + { name: "models", path: llm_models_path, label: t("admin.llm_connections.tabs.models") } + ] + end +end 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..b280f524c3ba --- /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_models.index.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..3cda79136b33 100644 --- a/app/views/admin/llm_connections/show.html.erb +++ b/app/views/admin/llm_connections/show.html.erb @@ -32,12 +32,23 @@ 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 + if Setting.llm_features_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) }, { 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 new file mode 100644 index 000000000000..23e6a4e445b2 --- /dev/null +++ b/app/views/admin/llm_models/index.html.erb @@ -0,0 +1,65 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_administration), t("menus.admin.llm_connection"), t("admin.llm_connections.tabs.models") %> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + 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)) + 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_connection")] + ) + render_tab_header_nav(header, llm_settings_tabs(@connection), test_selector: "llm-settings--tabs") + end +%> + +<% 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)) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 1526773cc569..054c695a6654 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1776,6 +1776,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?" @@ -1798,10 +1799,36 @@ en: server_group: "Server" show: 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" 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)." + 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" + 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_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." + success: "The model list has been refreshed." 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." @@ -4371,6 +4398,9 @@ en: perplexity: "Perplexity" vertexai: "Google Vertex AI" xai: "xAI" + model_kinds: + chat: "Chat" + embedding: "Embedding" 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 e1815a127f05..b7704598a78c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -782,6 +782,13 @@ post :disconnect 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 collection do post :multi_update diff --git a/spec/features/admin/llm_connection_spec.rb b/spec/features/admin/llm_connection_spec.rb index 72ce9ba658a7..1e23e5605d15 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 @@ -111,4 +129,61 @@ 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, base_url:) } + + # Written for real rather than through with_settings:, which stubs + # Setting.[] and would hide the write the disconnect example asserts. + before do + Setting.llm_features_enabled = true + mock_llm_models_response(base_url) + end + + 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") + 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_field("Host URL") + + choose_action("llm-connection--delete-api-key") + + within_test_selector("llm-connection--delete-api-key-dialog") do + # 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 + + 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_field("Host URL") + + choose_action("llm-connection--disconnect") + + within_test_selector("llm-connection--disconnect-dialog") do + expect(page).to be_axe_clean.skipping("color-contrast") + click_on "Disconnect" + end + + wait_for { Setting.llm_features_enabled? }.to be(false) + expect(connection.reload.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..da2d57dd3a59 100644 --- a/spec/requests/admin/llm_connections_spec.rb +++ b/spec/requests/admin/llm_connections_spec.rb @@ -76,6 +76,43 @@ 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 "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 features are on", + with_settings: { llm_features_enabled: true } do + create(:llm_connection, 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 features are on", + with_settings: { llm_features_enabled: true } do + create(:llm_connection, 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 new file mode 100644 index 000000000000..89975d68cb1b --- /dev/null +++ b/spec/requests/admin/llm_models_spec.rb @@ -0,0 +1,237 @@ +# 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 }, + with_settings: { llm_features_enabled: 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 "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 "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" + 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 "keeps the source label short and spells it out on hover" do + connection = create(:llm_connection, 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 features are off", + with_settings: { llm_features_enabled: false } do + create(:llm_connection, :with_models, base_url:) + + 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 + 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:) } + + 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, 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, 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