Skip to content
41 changes: 41 additions & 0 deletions app/components/llm_connections/default_models_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.

OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

See COPYRIGHT and LICENSE files for more details.

++#%>

<%= component_wrapper(class: "mb-4") do %>
<%=
render(Primer::Beta::Subhead.new) do |component|
component.with_heading(tag: :h3) { t("admin.llm_models.defaults.heading") }
component.with_description { t("admin.llm_models.defaults.description") }
end
%>

<%= settings_primer_form_with(**form_options) do |f| %>
<%= render(LlmConnections::DefaultModelsForm.new(f)) %>
<% end %>
<% end %>
54 changes: 54 additions & 0 deletions app/components/llm_connections/default_models_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module LlmConnections
# The "Default models" section of the LLMs page.
class DefaultModelsComponent < ApplicationComponent
include ApplicationHelper
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable

alias_method :connection, :model

# Nothing to choose from, and the empty table right below says so.
def render? = connection.available_model_ids.any?

private

def form_options
{
model: connection,
url: url_helpers.defaults_llm_models_path,
method: :patch,
data: { test_selector: "llm-connection--defaults-form" }
}
end
end
end
33 changes: 32 additions & 1 deletion app/components/llm_connections/models_row_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,43 @@ def source
end

def source_label
return %i[accent source_manual] if llm_model.manual?
return %i[attention source_withdrawn] if llm_model.withdrawn?
return %i[accent source_manual] if llm_model.manual?

%i[secondary source_discovered]
end

# Whether a feature may choose this model. A withdrawn model has nothing to
# switch on -- the server stopped offering it -- so its toggle is inert
# rather than absent, which keeps the column aligned and says why.
def status
render(Primer::Alpha::ToggleSwitch.new(**toggle_options))
end

def toggle_options
options = {
checked: llm_model.selectable?,
enabled: togglable?,
size: :small,
# A bare ToggleSwitch has no accessible name, and axe fails without one.
aria: { label: I18n.t("admin.llm_models.index.toggle_aria_label", model: llm_model.name) },
test_selector: "llm-model--toggle-#{llm_model.id}"
}

togglable? ? options.merge(mutation_options) : options
end

def mutation_options
{
src: url_helpers.toggle_llm_model_path(llm_model),
csrf_token: helpers.form_authenticity_token,
turbo: true,
classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator"
}
end

def togglable? = llm_model.active?

def button_links
llm_model.manual? ? [edit_link, delete_link] : [edit_link]
end
Expand Down
5 changes: 3 additions & 2 deletions app/components/llm_connections/models_table_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ module LlmConnections
# Rendering never issues an HTTP request: the catalogue is refreshed explicitly
# through the "Refresh models" action.
class ModelsTableComponent < OpPrimer::BorderBoxTableComponent
columns :identifier, :kind, :context_window, :source
columns :identifier, :kind, :context_window, :source, :status

main_column :identifier

Expand Down Expand Up @@ -66,7 +66,8 @@ def headers
[:identifier, { caption: I18n.t("admin.llm_models.index.identifier") }],
[:kind, { caption: I18n.t("admin.llm_models.index.kind") }],
[:context_window, { caption: I18n.t("admin.llm_models.index.context_window") }],
[:source, { caption: I18n.t("admin.llm_models.index.source") }]
[:source, { caption: I18n.t("admin.llm_models.index.source") }],
[:status, { caption: I18n.t("admin.llm_models.index.status") }]
]
end

Expand Down
26 changes: 26 additions & 0 deletions app/contracts/llm_connections/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,40 @@ class BaseContract < ModelContract
validates :base_url, url: { message: :invalid_url }, unless: -> { base_url.blank? }

validate :features_require_connection
validate :default_models_offered_by_server
validate :default_chat_model_can_chat

private

# A model the server identifies as an embedding model is not a chat candidate.
def default_chat_model_can_chat
llm_model = model.default_chat_model
return if llm_model.blank?
return unless model.changed_attributes.include?("default_chat_model_id")

errors.add(:default_chat_model_id, :cannot_chat) if model.default_chat_model&.embedding?
end

def features_require_connection
return unless model.llm_features_enabled
return if model.base_url.present?

errors.add :llm_features_enabled, :requires_connection
end

# A designated default must be a model the server actually reported. Validated
# only when it changes, so a catalogue that shrinks underneath a stored
# selection does not block every unrelated save; the dangling state is
# surfaced in the UI instead.
def default_models_offered_by_server
LlmModel::CONNECTION_DEFAULTS.each do |attribute|
value = model.public_send(attribute)
next if value.blank?
next unless model.changed_attributes.include?(attribute.to_s)
next if model.models.active.exists?(id: value)

errors.add attribute, :not_available
end
end
end
end
32 changes: 32 additions & 0 deletions app/controllers/admin/llm_models_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,40 @@ def destroy
redirect_to llm_models_path, status: :see_other
end

def update_defaults
result = ::LlmConnections::UpdateService
.new(user: current_user, model: @connection)
.call(**default_model_params)

result.on_success { flash[:notice] = t("admin.llm_models.defaults.success") }
result.on_failure { flash[:error] = result.errors.full_messages.join(", ") }

redirect_to llm_models_path, status: :see_other
end

# Hides a model from the pickers, or puts it back. Deliberately does not
# touch +active+, which the catalogue sync owns and would overwrite.
def toggle
llm_model = @connection.models.find(params.expect(:id))

# A withdrawn model has nothing to switch on; its toggle is rendered
# disabled, and this refuses a request that got here anyway.
return render(json: {}, status: :unprocessable_entity) unless llm_model.active?

llm_model.update!(deactivated_at: llm_model.deactivated? ? nil : Time.current)

# The default pickers offer the models that are switched on, so they go
# stale the moment a toggle flips.
update_via_turbo_stream(component: ::LlmConnections::DefaultModelsComponent.new(@connection))
respond_with_turbo_streams
end

private

def default_model_params
params.expect(llm_connection: %i[default_chat_model_id]).to_h.symbolize_keys
end

def set_connection
@connection = LlmConnection.active_connection
end
Expand Down
68 changes: 68 additions & 0 deletions app/forms/llm_connections/default_models_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module LlmConnections
class DefaultModelsForm < ApplicationForm
form do |f|
# An autocompleter rather than a select: a gateway reports hundreds of
# models, and every one of them would otherwise be inlined as an option in
# the page body. decorated: true serialises the list into the element, so
# this needs no endpoint of its own.
f.autocompleter(
name: :default_chat_model_id,
label: LlmConnection.human_attribute_name(:default_chat_model_id),
caption: I18n.t("admin.llm_models.defaults.chat_caption"),
autocomplete_options: {
decorated: true,
inputValue: model.default_chat_model_id,
placeholder: I18n.t("label_none_parentheses")
}
) do |list|
list.option(label: I18n.t("label_none_parentheses"), value: "",
selected: model.default_chat_model_id.blank?)

default_chat_model_options.each do |llm_model|
list.option(label: llm_model.name, value: llm_model.id,
selected: model.default_chat_model_id == llm_model.id)
end
end

f.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary)
end

private

# The one already chosen is kept regardless of what the server offers today:
# dropping it would silently blank the field on the next save.
def default_chat_model_options
(model.chat_models + [model.default_chat_model]).compact.uniq
end
end
end
22 changes: 22 additions & 0 deletions app/models/llm_connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ def models_stale?
connection_fingerprint.present? && connection_fingerprint != settings_fingerprint
end

# What a picker should offer: the above, minus what an administrator has
# switched off.
def selectable_model_ids
models.selectable.by_identifier.pluck(:external_id)
end

def selectable_models
models.selectable.by_identifier
end

def chat_models
selectable_models.reject(&:embedding?)
end

def embedding_model_ids
embedding = capability_verdicts.for_capability(:embeddings).where(state: "supported").pluck(:model_id)

selectable_model_ids & embedding
end

def chat_model_ids = selectable_model_ids - embedding_model_ids

def server_flavour
options["server_flavour"].presence&.to_sym
end
Expand Down
13 changes: 13 additions & 0 deletions app/models/llm_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ class LlmModel < ApplicationRecord
scope :manual, -> { where(manual: true) }
scope :by_identifier, -> { order(:external_id) }

# What an administrator is willing to have chosen. Distinct from +active+,
# which the catalogue sync owns and rewrites on every refresh.
scope :deactivated, -> { where.not(deactivated_at: nil) }
scope :selectable, -> { active.where(deactivated_at: nil) }

def deactivated? = deactivated_at.present?

# Everything that points at a model does so by its identifier string, so a
# rename has to carry them along or it silently orphans them.
#
Expand All @@ -69,6 +76,12 @@ def cascade_delete!
clear_connection_defaults
end

# Offerable in a picker. Note that this is *not* what decides whether a model
# still resolves: a feature already bound to a deactivated model keeps working,
# and is surfaced as a warning instead. Switching a row off must never silently
# break a running feature.
def selectable? = active? && !deactivated?

def name = display_name.presence || external_id

def clear_connection_defaults
Expand Down
2 changes: 2 additions & 0 deletions app/views/admin/llm_models/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,7 @@ See COPYRIGHT and LICENSE files for more details.
%>
<% end %>

<%= render(LlmConnections::DefaultModelsComponent.new(@connection)) %>

<%= render(LlmConnections::Models::SubHeaderComponent.new(@query)) %>
<%= render(LlmConnections::Models::IndexComponent.new(@models, connection: @connection)) %>
Loading
Loading