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
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 @@ -20,7 +20,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 @@ -52,5 +52,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 %>
111 changes: 111 additions & 0 deletions app/components/llm_connections/feature_binding_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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

# Quoted so a trailing space -- load-bearing for the E5 and BGE families --
# is visible rather than invisible.
def locked_values
[
[LlmFeatureBinding.human_attribute_name(:model_id), binding.model_id],
[LlmFeatureBinding.human_attribute_name(:dimensions), binding.dimensions || "—"],
[LlmFeatureBinding.human_attribute_name(:input_prefix), binding.input_prefix.to_s.inspect],
[LlmFeatureBinding.human_attribute_name(:query_prefix), binding.query_prefix.to_s.inspect]
]
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
19 changes: 17 additions & 2 deletions app/contracts/llm_connections/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ class BaseContract < ModelContract
attribute :api_key
attribute :default_chat_model_id
attribute :default_embedding_model_id
attribute :default_chat_model_id
attribute :default_embedding_model_id

validates :base_url, presence: true
validates :api_format, inclusion: { in: Llm::Adapters::FORMATS }
Expand All @@ -56,6 +54,7 @@ class BaseContract < ModelContract
validate :enabled_requires_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 @@ -66,6 +65,22 @@ def not_configured_from_env

private

# A model the server has positively told us cannot embed is not a candidate
# for the embedding default, however it got submitted. An unknown verdict
# does not block: that is the normal state for a server that publishes
# nothing about its models.
def default_embedding_model_can_embed
model_id = model.default_embedding_model_id
return if model_id.blank?
return unless model.changed_attributes.include?("default_embedding_model_id")

unsupported = model.capability_verdicts
.for_capability(:embeddings)
.exists?(model_id:, state: "unsupported")

errors.add(:default_embedding_model_id, :cannot_embed) if unsupported
end

# The mirror image of default_embedding_model_can_embed: a model the server
# positively identifies as an embedding model is not a chat candidate.
def default_chat_model_can_chat
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/admin/llm_connections_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def redirect_with_error(message)
# saved value, so submitting it unchanged posts an empty string.
def llm_connection_params
permitted = params.expect(
llm_connection: %i[enabled api_format base_url api_key default_chat_model_id]
llm_connection: %i[enabled api_format base_url api_key default_chat_model_id default_embedding_model_id]
)
permitted.delete(:api_key) if permitted[:api_key].blank?
permitted.to_h.symbolize_keys
Expand Down
129 changes: 129 additions & 0 deletions app/controllers/admin/llm_feature_bindings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 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_feature_bindings

before_action :require_feature
before_action :require_admin
before_action :set_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.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

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 they describe; the model
# rejects them elsewhere, and they are not read at all for a chat feature.
assign_embedding_settings(binding) if feature.embedding?

binding
end

# The prefixes are stored exactly as typed. The trailing space in "passage: "
# is load-bearing for the E5 and BGE families, so stripping would silently
# degrade retrieval.
def assign_embedding_settings(binding)
settings = params.fetch(:llm_feature_binding, {})

binding.dimensions = settings[:dimensions].presence
binding.input_prefix = settings[:input_prefix]
binding.query_prefix = settings[:query_prefix]
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
Loading
Loading