-
Notifications
You must be signed in to change notification settings - Fork 3.5k
[AI-3] Let administrators add, edit and delete models by hand #24884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <%= | ||
| render( | ||
| Primer::OpenProject::DangerDialog.new( | ||
| title: t("admin.llm_models.destroy.title"), | ||
| form_arguments:, | ||
| test_selector: TEST_SELECTOR | ||
| ) | ||
| ) do |dialog| | ||
| dialog.with_confirmation_message do |message| | ||
| message.with_heading(tag: :h2) { t("admin.llm_models.destroy.heading", model: llm_model.external_id) } | ||
| message.with_description_content( | ||
| if bound_features.any? | ||
| t("admin.llm_models.destroy.description_bound", features: bound_features.to_sentence) | ||
| else | ||
| t("admin.llm_models.destroy.description") | ||
| end | ||
| ) | ||
| end | ||
| end | ||
| %> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # 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 DeleteModelDialogComponent < ApplicationComponent | ||
| include OpTurbo::Streamable | ||
| include OpPrimer::ComponentHelpers | ||
|
|
||
| TEST_SELECTOR = "llm-model--delete-dialog" | ||
|
|
||
| alias_method :llm_model, :model | ||
|
|
||
| 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. | ||
| def bound_features | ||
| affected_defaults | ||
| end | ||
|
|
||
| def affected_defaults | ||
| %i[default_chat_model_id default_embedding_model_id] | ||
| .select { |attribute| llm_model.llm_connection.public_send(attribute) == llm_model.external_id } | ||
| .map { |attribute| LlmConnection.human_attribute_name(attribute) } | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| # 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 | ||
| # Models an administrator enters by hand. | ||
| # | ||
| # Necessary because not every OpenAI-compatible server exposes a model list. | ||
| # A gateway may route /v1/chat/completions and nothing else, in which case the | ||
| # operator knows the model name and OpenProject cannot discover it. | ||
| class LlmModelsController < ApplicationController | ||
| include OpTurbo::ComponentStream | ||
|
|
||
| layout "admin" | ||
| menu_item :llm_connection | ||
|
|
||
| before_action :require_feature | ||
| before_action :require_admin | ||
| before_action :set_connection | ||
|
|
||
| def new | ||
| @llm_model = @connection.models.new | ||
| end | ||
|
|
||
| def edit | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please rescue & add a spec for an invalid PATCH request. |
||
| @llm_model = @connection.models.find(params.expect(:id)) | ||
| @verdicts = @connection.capability_verdicts.for_model(@llm_model.external_id).index_by(&:capability) | ||
| end | ||
|
|
||
| def create | ||
| llm_model = @connection.models.new(llm_model_params.except(*capability_param_names).merge(manual: true)) | ||
|
|
||
| if save_with_capabilities(llm_model) | ||
| flash[:notice] = t(".success", model: llm_model.external_id) | ||
| redirect_to llm_connection_path, status: :see_other | ||
| else | ||
| # Re-rendered rather than redirected so the Primer form shows the error | ||
| # inline against the field that caused it. | ||
| @llm_model = llm_model | ||
| render :new, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| def update | ||
| @llm_model = @connection.models.find(params.expect(:id)) | ||
|
|
||
| if update_with_capabilities(@llm_model) | ||
| flash[:notice] = t(".success", model: @llm_model.external_id) | ||
| redirect_to llm_connection_path, status: :see_other | ||
| else | ||
| # Re-rendered rather than redirected so the Primer form shows the error | ||
| # inline against the field that caused it, e.g. a rename that collides | ||
| # with an existing model id. | ||
| @verdicts = @connection.capability_verdicts.for_model(@llm_model.external_id_was).index_by(&:capability) | ||
| render :edit, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| def delete_dialog | ||
| llm_model = @connection.models.manual.find(params.expect(:id)) | ||
|
|
||
| respond_with_dialog LlmConnections::DeleteModelDialogComponent.new(llm_model) | ||
| end | ||
|
|
||
| def destroy | ||
| llm_model = @connection.models.manual.find(params.expect(:id)) | ||
| destroy_with_verdicts(llm_model) | ||
|
|
||
| flash[:notice] = t(".success", model: llm_model.external_id) | ||
| redirect_to llm_connection_path, status: :see_other | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_connection | ||
| @connection = LlmConnection.instance | ||
| end | ||
|
|
||
| # Verdicts are keyed by the identifier string, not by foreign key, so they | ||
| # would silently apply to a future model re-added under the same name. | ||
| def destroy_with_verdicts(llm_model) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 #Logic 🚨 Deleting a model leaves the connection defaults pointing at it
Confirmed on this branch: set The dialog does warn through |
||
| ActiveRecord::Base.transaction do | ||
| llm_model.destroy! | ||
| @connection.capability_verdicts.for_model(llm_model.external_id).delete_all | ||
| end | ||
| 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 update_with_capabilities(llm_model) | ||
| attributes = llm_model_params.except(*capability_param_names) | ||
| # A discovered model is named by the server; only a hand-entered one may be | ||
| # renamed here, and everything referencing the old name follows it. | ||
| attributes = attributes.except(:external_id) unless llm_model.manual? | ||
|
|
||
| saved = false | ||
| ActiveRecord::Base.transaction do | ||
| previous_external_id = llm_model.external_id | ||
| llm_model.assign_attributes(attributes) | ||
| raise ActiveRecord::Rollback unless llm_model.save | ||
|
|
||
| llm_model.cascade_rename!(previous_external_id) | ||
| apply_capabilities(llm_model) | ||
| saved = true | ||
| end | ||
| saved | ||
| end | ||
|
|
||
| # external_id is accepted on create, and on update for manually added models. | ||
| def llm_model_params | ||
| params.expect(llm_model: [:external_id, :display_name, :admin_context_window, *capability_param_names]) | ||
| end | ||
|
|
||
| def capability_param_names | ||
| Llm::Capabilities::ALL.map { |capability| :"capability_#{capability}" } | ||
| end | ||
|
|
||
| def save_with_capabilities(llm_model) | ||
| ActiveRecord::Base.transaction do | ||
| llm_model.save! | ||
| apply_capabilities(llm_model) | ||
| end | ||
|
|
||
| true | ||
| rescue ActiveRecord::RecordInvalid | ||
| false | ||
| end | ||
|
|
||
| # Stored as admin-sourced verdicts, which survive re-detection: an | ||
| # administrator knows things about their deployment that neither a published | ||
| # registry nor a probe can determine. | ||
| def apply_capabilities(llm_model) | ||
| submitted = llm_model_params | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 #Design ℹ️ apply_capabilities re-parses the request parameters Both callers have already built this hash before calling in. Could |
||
|
|
||
| Llm::Capabilities::ALL.each do |capability| | ||
| assert(llm_model.external_id, capability, submitted[:"capability_#{capability}"].presence) | ||
| end | ||
| end | ||
|
|
||
| def assert(model_id, capability, state) | ||
| verdict = @connection.capability_verdicts | ||
| .find_or_initialize_by(model_id:, capability: capability.to_s) | ||
|
|
||
| if state.blank? | ||
| # "Not specified" clears an assertion rather than recording ignorance as | ||
| # fact; detection may fill it in later. | ||
| verdict.destroy! if verdict.persisted? && verdict.source_admin? | ||
| else | ||
| verdict.update!(state:, source: "admin", checked_at: Time.current) | ||
| end | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 #Style ℹ️ This key names a link, not a submit, and duplicates create_submit
Two separate points on the same key, both worth a look alongside the suggestion already left on
create_submit:add_model_submitlabels this sub-header link, while the form's actual submit button usesadmin.llm_models.form.create_submit. Both currently read "Add model", so the same text appears on two different controls and the_submitsuffix sits on the one that is not a submit. Would it be clearer to rename this one (for exampleadd_model) and leavecreate_submitnaming the action the form performs?On the label itself: the closest analogues in the repo use a bare noun for a primary sub-header button with
leading_icon: :plus-+ Portfolioinportfolios/index_sub_header_component.html.erband+ Iteminadmin/custom_fields/hierarchy/items_component.html.erb. The other convention is a bare verb,+ Add, used by the projects index. Since the icon already supplies the plus, the literal "+" would not be needed in the string either.Separately,
admin.llm_connections.show.add_model_descriptionlooks like a second ghost key in this area - unreferenced since it was added, and superseded byadmin.llm_models.new.description.