Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/llm_connections/default_models_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
#++

module LlmConnections
# The "Default models" section of the LLMs page.
# The "Default models" section of the LLMs tab.
class DefaultModelsComponent < ApplicationComponent
include ApplicationHelper
include OpPrimer::ComponentHelpers
Expand Down
14 changes: 10 additions & 4 deletions app/components/llm_connections/delete_model_dialog_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,17 @@ def form_arguments
{ action: url_helpers.llm_model_path(llm_model), method: :delete }
end

# Named so the message says what is actually at stake. The connection
# defaults count as bindings here -- deleting their model breaks every
# feature that inherits them.
# Named so the message says what is actually at stake: features bound to this
# model stop resolving, rather than silently falling back to another one.
# The connection defaults count as bindings here -- deleting their model
# breaks every feature that inherits them.
def bound_features
affected_defaults
bindings = llm_model.llm_connection
.feature_bindings
.where(model_id: llm_model.external_id)
.filter_map { |binding| binding.feature&.label }

bindings + affected_defaults
end

def affected_defaults
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
safe_join(
[
content_tag(:li, t("admin.llm_connections.disconnect.keeps_settings")),
content_tag(:li, t("admin.llm_connections.disconnect.keeps_models"))
content_tag(:li, t("admin.llm_connections.disconnect.keeps_models")),
if bound_features.any?
content_tag(
:li,
t("admin.llm_connections.disconnect.keeps_bindings", features: bound_features.to_sentence)
)
end
].compact
)
end
Expand Down
4 changes: 4 additions & 0 deletions app/components/llm_connections/disconnect_dialog_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,9 @@ class DisconnectDialogComponent < ApplicationComponent
def form_arguments
{ action: url_helpers.disconnect_llm_connection_path, method: :post }
end

def bound_features
connection.feature_bindings.filter_map { |binding| binding.feature&.label if binding.model_id.present? }
end
end
end
53 changes: 53 additions & 0 deletions app/components/llm_connections/feature_binding_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<%= render(Primer::Box.new(border: true, border_radius: 2, p: 3, mb: 3)) do %>
<%= render(Primer::Beta::Text.new(tag: :h3, font_size: 4, font_weight: :bold, mb: 1)) { feature.label } %>

<% if feature.caption.present? %>
<%= render(Primer::Beta::Text.new(tag: :p, color: :muted, mb: 2)) { feature.caption } %>
<% end %>

<% if dangling? %>
<%= render(Primer::Alpha::Banner.new(scheme: :warning, mb: 2, icon: :alert)) do %>
<%= t("admin.llm_feature_bindings.dangling", model: binding.resolved_model_id) %>
<% end %>
<% end %>

<% if deactivated? %>
<%= render(Primer::Alpha::Banner.new(scheme: :warning, mb: 2, icon: :alert)) do %>
<%= t("admin.llm_feature_bindings.deactivated", model: binding.resolved_model_id) %>
<% end %>
<% end %>

<% if locked? %>
<%= render(Primer::Alpha::Banner.new(scheme: :warning, mb: 2, icon: :lock)) do %>
<%= t("admin.llm_feature_bindings.locked", model: binding.model_id) %>
<% end %>
<% end %>

<%= primer_form_with(model: form_model, url: form_url, method: :patch, scope: :llm_feature_binding) do |f| %>
<%= render(
LlmConnections::FeatureBindingForm.new(
f,
options: model_options,
inherit_label:,
feature_key: feature.key,
locked: locked?,
embedding: feature.embedding?,
selected_model_id: binding&.model_id,
dimensions_hint: probed_dimensions
)
) %>
<% end %>

<% if locked? && feature.embedding? %>
<%# Rendered as text rather than disabled inputs: a disabled input submits
nothing, so the values would arrive blank and wipe the columns. %>
<%= render(Primer::Beta::Text.new(tag: :p, font_weight: :bold, mt: 2, mb: 1)) do %>
<%= t("admin.llm_feature_bindings.locked_values_heading") %>
<% end %>
<% locked_values.each do |label, value| %>
<%= render(Primer::Beta::Text.new(tag: :p, color: :muted, mb: 0)) do %>
<%= "#{label}: #{value}" %>
<% end %>
<% end %>
<% end %>
<% end %>
107 changes: 107 additions & 0 deletions app/components/llm_connections/feature_binding_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# 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
# One feature's row on the model assignment page.
class FeatureBindingComponent < ApplicationComponent
include ApplicationHelper
include OpPrimer::ComponentHelpers

def initialize(feature:, connection:, binding: nil)
super(feature)
@feature = feature
@connection = connection
@binding = binding
end

# The record the select binds to. A feature without a stored binding still
# needs one so the form has a model_id to read.
def form_model
binding || connection.feature_bindings.new(feature_key: feature.key.to_s)
end

# Not named +options+: ApplicationComponent already owns that name and
# initialises it to an empty hash, which silently swallowed the memoisation.
def model_options
@model_options ||= SelectableModelsQuery.new(connection, feature).call
end

def inherit_label
if default_model_id.present?
I18n.t("admin.llm_feature_bindings.inherit_with_default", model: default_model_id)
else
I18n.t("admin.llm_feature_bindings.inherit_without_default")
end
end

def locked? = binding&.locked?

def dangling? = binding&.dangling?

# What the embeddings probe last saw, offered as information. Never filled
# into the field: the server decides the vector size at index time.
def probed_dimensions
return unless feature.embedding?

model_id = binding&.resolved_model_id
return if model_id.blank?

connection.capability_verdicts.for_model(model_id).for_capability(:embeddings).first&.dimensions
end

def locked_values
[
[LlmFeatureBinding.human_attribute_name(:model_id), binding.model_id],
[LlmFeatureBinding.human_attribute_name(:dimensions), binding.dimensions || "—"]
]
end

# Still resolvable, so not dangling -- but an administrator has hidden it
# from the pickers, so say so rather than let the choice look unremarkable.
def deactivated?
model_id = binding&.resolved_model_id
return false if model_id.blank?

connection.models.deactivated.exists?(external_id: model_id)
end

private

attr_reader :feature, :connection, :binding

def default_model_id
feature.embedding? ? connection.default_embedding_model_id : connection.default_chat_model_id
end

def form_url
url_helpers.llm_feature_binding_path(feature.key)
end
end
end
15 changes: 14 additions & 1 deletion app/contracts/llm_connections/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class BaseContract < ModelContract
validate :features_require_connection
validate :default_models_offered_by_server
validate :default_chat_model_can_chat
validate :default_embedding_model_can_embed
validate :not_configured_from_env

def not_configured_from_env
Expand All @@ -64,13 +65,25 @@ def not_configured_from_env

private

# Only a model the server has ruled out is refused. "We could not tell" is the
# normal state on a self-hosted server, and an environment-provisioned default
# would otherwise fail on a catalogue row nothing has probed yet. The picker
# still offers confirmed models only.
def default_embedding_model_can_embed
llm_model = model.default_embedding_model
return if llm_model.blank?
return unless model.changed_attributes.include?("default_embedding_model_id")

errors.add(:default_embedding_model_id, :cannot_embed) if llm_model.verdict_for(:embeddings)&.blocking?
end

# 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?
errors.add(:default_chat_model_id, :cannot_chat) if llm_model.embedding?
end

def features_require_connection
Expand Down
130 changes: 130 additions & 0 deletions app/controllers/admin/llm_feature_bindings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 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
# Assigns a model to each registered AI feature.
class LlmFeatureBindingsController < ApplicationController
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
@features = OpenProject::Llm::Features.available
@bindings = bindings_by_feature_key
end

def update
feature = OpenProject::Llm::Features[params[:id]]
assign(feature)

redirect_to llm_feature_bindings_path, status: :see_other
rescue OpenProject::Llm::UnknownFeature
render_404
end

private

def set_connection
@connection = LlmConnection.active_connection
end

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

# 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

def bindings_by_feature_key
@connection.feature_bindings.index_by(&:feature_key)
end

def binding_for(feature)
@connection.feature_bindings.find_or_initialize_by(feature_key: feature.key.to_s)
end

def assign(feature)
binding = build_binding(feature)

unless binding.save
flash[:error] = binding.errors.full_messages.join(", ")
return
end

confirm_assignment(feature, probe_capabilities(feature, binding))
end

# The probe may just have proven the chosen model cannot do what the feature
# requires; confirming that save would report a working configuration that
# Llm::Runtime immediately resolves as incapable.
def confirm_assignment(feature, verdict)
if verdict&.blocking?
flash[:error] = t("admin.llm_feature_bindings.update.model_incapable",
feature: feature.label,
capability: Llm::Capabilities.label(:embeddings))
else
flash[:notice] = t("admin.llm_feature_bindings.update.success", feature: feature.label)
end
end

def build_binding(feature)
binding = binding_for(feature)
binding.model_id = params.dig(:llm_feature_binding, :model_id).presence

# Only ever accepted for the kind of feature it describes; the model
# rejects it elsewhere, and it is not read at all for a chat feature.
assign_embedding_settings(binding) if feature.embedding?

binding
end

def assign_embedding_settings(binding)
binding.dimensions = params.dig(:llm_feature_binding, :dimensions).presence
end

# The verdict that actually matters is the one for the model an administrator
# just chose, so it is fetched now rather than left unknown until first use.
def probe_capabilities(feature, binding)
return if feature.requires.empty? || binding.model_id.blank?

LlmConnections::DetectCapabilitiesService.new(@connection).detect(binding.model_id).result
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/admin/llm_models_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def toggle
private

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

def set_connection
Expand Down
Loading
Loading