Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
6d1c73d
[#66020] Add LlmConnection model and llm_connection feature flag
tangopium Aug 6, 2026
cd3a910
[#66020] Add Llm::Client for OpenAI-compatible servers
tangopium Aug 6, 2026
e27705b
[#66020] Verify the LLM server inside the contract
tangopium Aug 6, 2026
6b06bee
[#66020] Provision the LLM connection from the environment
tangopium Aug 6, 2026
d78bcfb
[#66020] Add the LLM settings administration page
tangopium Aug 6, 2026
0b35abe
[#66020] Add specs for the LLM connection
tangopium Aug 6, 2026
39b9fe7
[#66020] Add a registry of LLM-consuming features
tangopium Aug 6, 2026
5e23b4f
[#66020] Store capability verdicts and feature bindings
tangopium Aug 7, 2026
1a000b1
[#66020] Detect which models can produce embeddings
tangopium Aug 7, 2026
1e49f10
[#66020] Add Llm::Runtime, the single model resolution point
tangopium Aug 7, 2026
9cc5b5f
[#66020] Add the AI models assignment page
tangopium Aug 7, 2026
59fc259
[#66020] Add specs for the resolver and the embeddings probe
tangopium Aug 7, 2026
8e85c20
[#66020] Distinguish a missing model list from an incompatible server
tangopium Aug 7, 2026
8b5cfb2
[#66020] Replace the jsonb catalogue with an llm_models table
tangopium Aug 10, 2026
08a6fab
[#66020] Let an administrator add a model by hand
tangopium Aug 10, 2026
ce987ed
[#66020] Add api_format and custom_headers to the connection
tangopium Aug 10, 2026
68d3e56
[#66020] Use RubyLLM for provider coverage and published capabilities
tangopium Aug 10, 2026
95571c1
[#66020] Let an administrator choose the endpoint format
tangopium Aug 10, 2026
424dddb
[#66020] Let an administrator set model capabilities by hand
tangopium Aug 10, 2026
5e07ae9
[#66020] Treat the model list as optional, not a precondition
tangopium Aug 10, 2026
55500e8
[#66020] Show and edit model type and context window
tangopium Aug 11, 2026
75c15df
[#66020] Follow OpenProject's admin UI conventions
tangopium Aug 11, 2026
0a252ac
[#66020] Send LLM requests through RubyLLM
tangopium Aug 11, 2026
098b8a7
[#66020] Let a resolved feature run a request
tangopium Aug 11, 2026
f20c25c
[#66020] Prepare the health report framework for a third consumer
tangopium Aug 11, 2026
e6ef88c
[#66020] Check and show an LLM connection's health
tangopium Aug 11, 2026
f2621fd
[#66020] Re-check the connection on a schedule, and prune old reports
tangopium Aug 12, 2026
23f8cbc
[#66020] Let an administrator curate which models may be used
tangopium Aug 12, 2026
21c6508
[#66020] Let an administrator remove the API key or disconnect
tangopium Aug 12, 2026
1b4c034
[#66020] Configure the vector settings an embedding feature indexes with
tangopium Aug 12, 2026
b244bac
[#66020] Make the model list usable at gateway scale
tangopium Aug 12, 2026
13db6c1
[#66020] Add a feature spec for the AI administration pages
tangopium Aug 12, 2026
fbce0c7
[#66020] Fix three inconsistencies found in review
tangopium Aug 13, 2026
d657ebe
[#66020] Stop claiming a connection we have not proven
tangopium Aug 14, 2026
8cf92bc
[#66020] Let a manually added model be renamed
tangopium Aug 14, 2026
89a512f
[#66020] Name the inherited capability value in the option itself
tangopium Aug 14, 2026
3018c7c
[#66020] Offer only models known to create embeddings
tangopium Aug 14, 2026
5791989
Merge remote-tracking branch 'origin/dev' into feature/66020-llm-conn…
tangopium Aug 20, 2026
75dbfa8
[#66020] Follow the SSRF error rename from dev
tangopium Aug 20, 2026
0f35ec1
[#66020] Treat an API format change as a connection change
tangopium Aug 20, 2026
1af327f
[#66020] Make environment provisioning complete and reversible
tangopium Aug 20, 2026
36b5e29
[#66020] Show the error when renaming a model to a taken id
tangopium Aug 20, 2026
58243fd
[#66020] Satisfy the linters and the environment docs guard
tangopium Aug 20, 2026
f6e13e3
[#66020] Order every llm locale key the way yamllint demands
tangopium Aug 20, 2026
7d8de2c
[#66020] Keep the heading order intact on the connection page
tangopium Aug 20, 2026
9b5582c
Merge remote-tracking branch 'origin/dev' into feature/66020-llm-conn…
tangopium Aug 22, 2026
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
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,12 @@ gem "turbo-rails", "~> 2.0.20"

gem "httpx", "~> 1.8.1"

# Provider adapters and a model metadata registry for the AI features. Used as
# transport and as a source of published model capabilities; what a given
# connection actually offers is tracked in llm_models / llm_capability_verdicts,
# never in RubyLLM's application-wide registry.
gem "ruby_llm", "~> 1.16"

# Brings actual deep-freezing to most ruby objects
gem "ice_nine"

Expand Down
25 changes: 25 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ GEM
escape_utils (1.3.0)
et-orbi (1.4.0)
tzinfo
event_stream_parser (1.0.0)
eventmachine (1.2.7)
eventmachine_httpserver (0.2.1)
excon (1.5.0)
Expand All @@ -586,8 +587,12 @@ GEM
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
ferrum (0.17.2)
addressable (~> 2.5)
base64 (~> 0.2)
Expand Down Expand Up @@ -848,6 +853,7 @@ GEM
prism (~> 1.5)
msgpack (1.8.4)
multi_json (1.21.1)
multipart-post (2.4.1)
mustermann (4.0.0)
net-http (0.9.1)
uri (>= 0.11.1)
Expand Down Expand Up @@ -1390,6 +1396,17 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby_llm (1.16.0)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
zeitwerk (~> 2)
ruby_llm-schema (0.4.0)
rubytree (2.2.1)
json (~> 2.0, >= 2.19.9)
rubyzip (2.4.1)
Expand Down Expand Up @@ -1746,6 +1763,7 @@ DEPENDENCIES
ruby-duration (~> 3.2.0)
ruby-prof
ruby-progressbar (~> 1.13.0)
ruby_llm (~> 1.16)
rubytree (~> 2.2.1)
sanitize (~> 7.0.0)
scimitar (~> 2.13)
Expand Down Expand Up @@ -1842,6 +1860,7 @@ CHECKSUMS
browser (6.2.0) sha256=281d5295788825c9396427c292c2d2be0a5c91875c93c390fde6e5d61a5ace2d
budgets (1.0.0)
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler (4.0.16) sha256=d6ca5dd440c24f9abce9844cf44cc8e18c6a553de65a47efb4544137af92c47d
byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
capybara-screenshot (1.0.27) sha256=afa1896cc23df77be1774e8d3b3ce3953bf060aeaa04ff87607b5daf689174f2
Expand Down Expand Up @@ -1909,14 +1928,17 @@ CHECKSUMS
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
escape_utils (1.3.0) sha256=dffb7010922880ace6ceed642156c64e2a64620f27e0849f43bc4f68fd3c2c09
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
eventmachine_httpserver (0.2.1) sha256=5db5e8a23754204d43592e5fcc2160457c57c870babe6307c4e61fc95019b809
excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50
factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89
factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
ferrum (0.17.2) sha256=2c2540a850b211a46f4d81de21bfd62048f507e4c327d1807225c3823c17e6ee
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
Expand Down Expand Up @@ -2016,6 +2038,7 @@ CHECKSUMS
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mustermann (4.0.0) sha256=91f67411bb208d1d93c41e6128cb3b0f8ddd9ec7c45966f1007e1c43c08040d7
my_page (1.0.0)
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
Expand Down Expand Up @@ -2238,6 +2261,8 @@ CHECKSUMS
ruby-rc4 (0.1.5) sha256=00cc40a39d20b53f5459e7ea006a92cf584e9bc275e2a6f7aa1515510e896c03
ruby-saml (1.18.1) sha256=1b0e7a44aef150b4197955f5e015d593672e242cfdc5d06aa7554ec2350b9107
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
ruby_llm (1.16.0) sha256=26bd5310cf2ce55f74a60f8aae0b0d0327b586ff4532c84828103c3b2b905a18
ruby_llm-schema (0.4.0) sha256=e930f5a5316f9301bff3fb7fe572e44727d05bb8e50621001bbb49a47d63b8da
rubytree (2.2.1) sha256=237f930795479fad48d8860cc8ec057534864b7da92dbee6a3c81470ad00c5c8
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
safety_net_attestation (0.5.0) sha256=c8cd01dd550dbe8553862918af6355a04672db11d218ec96104ce3955293f2aa
Expand Down
2 changes: 1 addition & 1 deletion app/components/health_reports/report_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ See COPYRIGHT and LICENSE files for more details.

result_group.results.each do |value|
box.with_row do
render(HealthReports::ResultComponent.new(group: result_group.key, result: value, i18n_scope:))
render(HealthReports::ResultComponent.new(group: result_group.key, result: value, i18n_scope:, docs_href:))
end
end
end
Expand Down
8 changes: 6 additions & 2 deletions app/components/health_reports/report_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,18 @@ class ReportComponent < ApplicationComponent

# The i18n_scope parameter defines the I18n scope that should be used to resolve
# names of groups, checks and error messages indicated by the results.
def initialize(*, i18n_scope:, **)
#
# docs_href overrides where each result's "More information" link points;
# without it, results link to the file storages troubleshooting page.
def initialize(*, i18n_scope:, docs_href: nil, **)
super(*, **)
@i18n_scope = i18n_scope
@docs_href = docs_href
end

private

attr_reader :i18n_scope
attr_reader :i18n_scope, :docs_href

def summary_scheme(check_tally)
case check_tally
Expand Down
12 changes: 7 additions & 5 deletions app/components/health_reports/result_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,20 @@ See COPYRIGHT and LICENSE files for more details.
line.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_size: :small, color: status_color)) { status_text }
end
if error_code.present?
if error_code.present? && docs_href.present?
line.with_column do
render(Primer::Beta::Label.new(scheme: status_color)) { error_code }
end
end
end

if error_code.present?
if error_code.present? && docs_href.present?
row.with_column do
helpers.static_link_to(href: docs_href,
label: I18n.t(:label_more_information),
underline: true)
helpers.static_link_to(
href: docs_href,
label: I18n.t(:label_more_information),
underline: true
)
end
end
end
Expand Down
16 changes: 14 additions & 2 deletions app/components/health_reports/result_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,18 @@ module HealthReports
class ResultComponent < ApplicationComponent
include OpPrimer::ComponentHelpers

def initialize(group:, result:, i18n_scope:)
# Where "More information" points. Defaults to the file storages
# documentation because that was this component's only consumer for a long
# time; a subject with its own troubleshooting page passes its own, and one
# with no page yet passes false to suppress the link rather than send an
# administrator somewhere unrelated.
DEFAULT_DOCS_HREF = -> { ::OpenProject::Static::Links.url_for(:storage_docs, :health_status) }

def initialize(group:, result:, i18n_scope:, docs_href: nil)
super(result)
@group = group
@i18n_scope = i18n_scope
@docs_href = docs_href
end

private
Expand All @@ -49,7 +57,11 @@ def error_text
I18n.t("errors.#{model.code}", scope: @i18n_scope, **model.context&.symbolize_keys)
end

def docs_href = ::OpenProject::Static::Links.url_for(:storage_docs, :health_status)
def docs_href
return @docs_href if @docs_href == false

@docs_href || DEFAULT_DOCS_HREF.call
end

def error_code
if model.failure?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<%=
render(
Primer::OpenProject::DangerDialog.new(
title: t("admin.llm_connections.delete_api_key.title"),
form_arguments:,
# Defaults to "Delete", which is wrong here: neither action deletes anything.
confirm_button_text: t("admin.llm_connections.delete_api_key.title"),
test_selector: TEST_SELECTOR
)
) do |dialog|
dialog.with_confirmation_message do |message|
message.with_heading(tag: :h2) { t("admin.llm_connections.delete_api_key.heading") }
message.with_description_content(
if loses_admin_verdicts?
t("admin.llm_connections.delete_api_key.description_verdicts")
else
t("admin.llm_connections.delete_api_key.description")
end
)
end
end
%>
57 changes: 57 additions & 0 deletions app/components/llm_connections/delete_api_key_dialog_component.rb
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
# Confirms removing the stored API key.
#
# No confirmation checkbox: the key itself can simply be pasted again. The
# dialog exists for what is *not* recoverable -- see #loses_admin_verdicts?.
class DeleteApiKeyDialogComponent < ApplicationComponent
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers

TEST_SELECTOR = "llm-connection--delete-api-key-dialog"

alias_method :connection, :model

def form_arguments
{ action: url_helpers.api_key_llm_connection_path, method: :delete }
end

# The catalogue sync fingerprints base_url and api_key together, so the next
# refresh after the key changes treats the endpoint as a different deployment
# and discards every capability verdict -- including the ones an
# administrator asserted by hand, which nothing else in the system throws
# away. Worth saying out loud before the key goes.
def loses_admin_verdicts?
connection.capability_verdicts.exists?(source: "admin")
end
end
end
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
%>
53 changes: 53 additions & 0 deletions app/components/llm_connections/delete_model_dialog_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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: features bound to this
# model stop resolving, rather than silently falling back to another one.
def bound_features
llm_model.llm_connection
.feature_bindings
.where(model_id: llm_model.external_id)
.filter_map { |binding| binding.feature&.label }
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<%=
render(
Primer::OpenProject::DangerDialog.new(
title: t("admin.llm_connections.disconnect.title"),
form_arguments:,
# Defaults to "Delete", which is wrong here: neither action deletes anything.
confirm_button_text: t("admin.llm_connections.disconnect.title"),
test_selector: TEST_SELECTOR
)
) do |dialog|
dialog.with_confirmation_message do |message|
message.with_heading(tag: :h2) { t("admin.llm_connections.disconnect.heading") }
message.with_description_content(t("admin.llm_connections.disconnect.description"))
end

dialog.with_additional_details do
render(Primer::OpenProject::FlexLayout.new) do |flex|
flex.with_row do
content_tag(:ul) do
safe_join(
[
content_tag(:li, t("admin.llm_connections.disconnect.keeps_settings")),
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
end
end
end
end
%>
Loading
Loading