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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ See COPYRIGHT and LICENSE files for more details.
end
%>

<% if connection.configured_from_env? %>
<%= render(Primer::Alpha::Banner.new(mb: 3, icon: :info)) { t("admin.banners.environment_configured_readonly") } %>
<% end %>

<%= settings_primer_form_with(**form_options) do |f| %>
<%= render(LlmConnections::DefaultModelsForm.new(f)) %>
<% end %>
Expand Down
8 changes: 8 additions & 0 deletions app/components/llm_connections/form_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
<%=
component_wrapper(tag: "turbo-frame", **wrapper_options) do
if connection.configured_from_env?
concat(
render(Primer::Alpha::Banner.new(mb: 3, icon: :info)) do
t("admin.banners.environment_configured_readonly")
end
)
end

concat(
settings_primer_form_with(**form_options) do |f|
render(LlmConnections::ConnectionForm.new(f))
Expand Down
7 changes: 7 additions & 0 deletions app/contracts/llm_connections/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ class BaseContract < ModelContract
validate :features_require_connection
validate :default_models_offered_by_server
validate :default_chat_model_can_chat
validate :not_configured_from_env

def not_configured_from_env
return unless model.configured_from_env?

errors.add :base, :configured_via_env
end

private

Expand Down
47 changes: 47 additions & 0 deletions app/contracts/llm_connections/environment_update_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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
# Used when the connection is provisioned from the environment.
#
# Inherits from BaseContract, not UpdateContract: seeding must never reach out
# to the LLM server, because the container it runs in may well start before the
# server does. It also lifts the "configured from environment is read-only"
# guard, since this is the code path that legitimately writes those values.
class EnvironmentUpdateContract < BaseContract
def not_configured_from_env = nil

# On a fresh installation the seed runs before any model synchronisation, so
# there is no catalogue to validate a default model against. A wrong id is
# surfaced afterwards, the same way as a model that vanished: the binding
# shows as no longer offered.
def default_models_offered_by_server = nil
end
end
7 changes: 7 additions & 0 deletions app/controllers/admin/llm_connections_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ def disconnect_dialog
# Clears the credential and switches the AI features off, keeping the endpoint
# and the catalogue. Deliberately not a destroy.
def disconnect
return redirect_with_error(t(".configured_from_env")) if @connection.configured_from_env?

ApplicationRecord.transaction do
@connection.update!(api_key: nil)
Setting.llm_features_enabled = false
Expand All @@ -69,7 +71,12 @@ def delete_api_key_dialog
respond_with_dialog LlmConnections::DeleteApiKeyDialogComponent.new(@connection)
end

# The environment guard is checked explicitly because this write bypasses
# the contract: removing a credential must always be possible, even against
# a server that would reject the resulting unauthenticated probe.
def delete_api_key
return redirect_with_error(t(".configured_from_env")) if @connection.configured_from_env?

@connection.update!(api_key: nil)

redirect_with_notice(t(".success"))
Expand Down
28 changes: 20 additions & 8 deletions app/forms/llm_connections/connection_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class ConnectionForm < ApplicationForm
name: :llm_features_enabled,
label: LlmConnection.human_attribute_name(:llm_features_enabled),
caption: I18n.t("admin.llm_connections.form.llm_features_enabled_caption"),
disabled: read_only?,
data: { target_name: "llm_features_enabled", show_when_checked_target: "cause" }
)

Expand All @@ -55,6 +56,7 @@ class ConnectionForm < ApplicationForm
caption: I18n.t("admin.llm_connections.form.api_format_caption"),
include_blank: false,
input_width: :medium,
disabled: read_only?,
data: { target_name: "llm_connection_api_format", show_when_value_selected_target: "cause" }
) do |select|
supported_formats.each do |format|
Expand All @@ -69,7 +71,8 @@ class ConnectionForm < ApplicationForm
placeholder: "https://example.com/v1",
required: true,
type: :url,
input_width: :large
input_width: :large,
disabled: read_only?
)

fg.group(layout: :horizontal) do |row|
Expand All @@ -85,10 +88,11 @@ class ConnectionForm < ApplicationForm
type: :password,
autocomplete: "off",
input_width: :large,
disabled: read_only?,
data: { "admin--llm-connection-form-target": "secretInput" }
)

if model.api_key_stored?
if model.api_key_stored? && !read_only?
row.button(
name: :remove_api_key,
tag: :a,
Expand All @@ -103,16 +107,22 @@ class ConnectionForm < ApplicationForm
end
end

f.submit(
name: :submit,
label: submit_label,
scheme: :primary,
data: { "admin--llm-connection-form-target": "submitButton" }
)
unless read_only?
f.submit(
name: :submit,
label: submit_label,
scheme: :primary,
data: { "admin--llm-connection-form-target": "submitButton" }
)
end
end

private

def read_only?
model.configured_from_env?
end

# Only formats a request can actually be sent in. The contract rejects the
# rest as a backstop, but they should not be offered in the first place.
def supported_formats
Expand Down Expand Up @@ -147,6 +157,8 @@ def submit_label
end

def api_key_caption
return I18n.t("admin.llm_connections.form.api_key_caption_env") if read_only?

I18n.t("admin.llm_connections.form.api_key_caption#{'_stored' if model.api_key_stored?}")
end

Expand Down
7 changes: 6 additions & 1 deletion app/forms/llm_connections/default_models_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class DefaultModelsForm < ApplicationForm
caption: I18n.t("admin.llm_models.defaults.chat_caption"),
autocomplete_options: {
decorated: true,
disabled: read_only?,
inputValue: model.default_chat_model_id,
placeholder: I18n.t("label_none_parentheses")
}
Expand All @@ -54,11 +55,15 @@ class DefaultModelsForm < ApplicationForm
end
end

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

private

def read_only?
model.configured_from_env?
end

# 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
Expand Down
4 changes: 4 additions & 0 deletions app/models/llm_connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ def configured?
base_url.present?
end

def configured_from_env?
Setting.llm_connection.present?
end

# Every model that can be addressed today: discovered and still offered, plus
# anything an administrator entered by hand.
#
Expand Down
89 changes: 89 additions & 0 deletions app/seeders/env_data/llm_connection_seeder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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 EnvData
# Provisions the LLM connection from OPENPROJECT_LLM__CONNECTION_* variables so
# a container comes up connected without anyone opening the administration UI.
#
# Never contacts the LLM server: the catalogue refresh is enqueued, so seeding
# succeeds even when the server starts after OpenProject does. It is enqueued
# only while nothing is stored: a re-seed against another server must not
# discard a list an administrator has curated.
class LlmConnectionSeeder < Seeder
KNOWN_KEYS = %w[base_url api_key default_chat_model default_embedding_model enabled].freeze

def seed_data!
print_status " ↳ Creating LLM connection" do
validate_options!(config)

result = LlmConnections::EnvSyncService.new(config).call
raise result.errors.full_messages.join(", ") if result.failure?

Llm::SyncModelsJob.perform_later if result.result.catalogue_fetched_at.nil?
end
end

def applicable?
config.present?
end

def not_applicable_message
"No LLM connection configured through environment variables."
end

private

def config
Setting.llm_connection
end

def validate_options!(options)
check_unknown_keys!(options, KNOWN_KEYS)
return if options["base_url"].present?

raise "LLM connection: #{env_form('base_url')} is required."
end

def check_unknown_keys!(options, known_keys)
unknown = options.keys - known_keys
return if unknown.empty?

raise <<~MSG.strip
LLM connection: unknown configuration key(s): #{unknown.map { |k| env_form(k) }.join(', ')}.
Accepted keys: #{known_keys.map { |k| env_form(k) }.join(', ')}.
Note: in environment variable names, single underscores split path segments and double underscores encode a literal underscore (e.g. BASE__URL, not BASE_URL).
MSG
end

def env_form(key)
key.gsub("_", "__").upcase
end
end
end
1 change: 1 addition & 0 deletions app/seeders/env_data_seeder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def data_seeder_classes
[
EnvData::CustomDesignSeeder,
EnvData::LdapSeeder,
EnvData::LlmConnectionSeeder,
EnvData::ScimClientSeeder,
EnvData::TokenSeeder
]
Expand Down
Loading
Loading