[AI-3] Add the LLM connection record and discovery transport - #24879
[AI-3] Add the LLM connection record and discovery transport#24879tangopium wants to merge 1 commit into
Conversation
|
Caution The provided work package version does not match the core version Details:
Please make sure that:
|
|
Caution The Enterprise plan field is not set on the work package Details:
Please make sure that:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ee9d96d32
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
5ee9d96 to
4d9b1c8
Compare
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
The persistence and transport layer for connecting OpenProject to an LLM provider, with no user interface yet. Everything sits behind the new llm_connection feature flag, which is off in production, so this changes nothing for existing instances until the flag is flipped. LlmConnection stores the endpoint: API format, base URL, API key and the designated default models. The key is ciphered through Redmine::Ciphering, matching LdapAuthSource; note this is a no-op unless database_cipher_key is configured. Only one connection is supported, enforced by a validation rather than the schema, so lifting the restriction later needs no migration. Discovery and inference speak through two seams. Llm::Adapters resolves the api_format to a discovery strategy: OpenAI-compatible endpoints are queried live, so the list is what that endpoint actually serves, while the formats RubyLLM knows discover from its registry, which is a published catalogue rather than a live query. Llm::Session wraps RubyLLM for inference with per-connection isolated configuration, and Llm::Errors is the one error taxonomy every caller maps from -- response bodies never surface in messages, because gateways echo credentials and internal hostnames in error payloads. Llm::Client remains the plain-HTTP path for OpenAI-compatible servers. Two of its lessons are worth keeping: transport failures are HTTPX::ErrorResponse instances while a 4xx also populates #error, so the response class is the discriminator; and bodies are parsed directly rather than through #json, which insists on a content type self-hosted proxies do not reliably set. Part 1 of the AI-3 stack. https://community.openproject.org/work_packages/66020
4d9b1c8 to
da7c284
Compare
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
thykel
left a comment
There was a problem hiding this comment.
@tangopium This is coming up pretty well! Here's my first pass.
NB: Splitting up that massive PR definitely helped readability, but the PR stack is a bit of a Jenga tower. There are lots of base assumptions laid out in this first PR, and I want to challenge some of them, which will inevitably affect some other parts of the stack.
On the other hand, I get that this is also down to a personal preference, and LLM tools nowadays make it easier to "refresh" the rest of the stack after a structural change. And sometimes, seeing the code prototype right away can give a better idea compared to a vague diagram.
Still, for next time, I'd rather discuss the high-level technical design upfront (even with a simple ASCII diagram). This way, we can quickly identify any structural clashes that would have otherwise required burning more tokens just to rewrite a bunch of PRs.
| end | ||
| end | ||
|
|
||
| def embeddings(model_id:, input:) |
| # every association is already scoped by +llm_connection_id+ and the STI +type+ | ||
| # column is in place. | ||
| class LlmConnection < ApplicationRecord | ||
| include Redmine::Ciphering |
There was a problem hiding this comment.
🤔 We don't usually do encryption at rest and the only other code paths that use this method are leftovers from Redmine. So, this doesn't look like something we want to lean into.
I'm gonna do a bit more skulking and then get back to you.
There was a problem hiding this comment.
@tangopium Let's drop the attribute encryption altogether please -- the current consensus is that we do not support it, for various good reasons.
| # transport and as a source of published model capabilities; what a given | ||
| # connection actually offers is tracked per connection, never in RubyLLM's | ||
| # application-wide registry. | ||
| gem "ruby_llm", "~> 1.16" |
There was a problem hiding this comment.
This seems to drag in a deprecation warning.
!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0. Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/
Please make sure it's cleaned up -- looks like it just needs a little configuration tweak.
|
|
||
| SINGLETON_NAME = "default" | ||
|
|
||
| has_many :health_reports, as: :subject, dependent: :delete_all |
There was a problem hiding this comment.
@NobodysNightmare How do we feel about this one? Is that HealthReport framework reusable for arbitrary needs (such as LLM connection tracking)?
There was a problem hiding this comment.
Uh totally. I am happy if we start using it in more places, though it's good to be aware that more consumers exist, because it's a "naturally grown abstraction", not one that was designed before it was implemented.
I didn't look into this usage here, but if there is:
- a health check/self test that's triggered by an admin
- and the health check tests whether the connection to an external service is working the way we expect it to work
- and maybe some of the tests can only be performed if others succeeded
then it sounds like a good fit to me.
| class CreateLlmConnections < ActiveRecord::Migration[8.1] | ||
| def change | ||
| create_table :llm_connections do |t| | ||
| t.string :name, null: false, index: { unique: true } |
There was a problem hiding this comment.
"name" doesn't come off very restrictive, and I wouldn't have expected this field to serve as the de-facto ID column.
We already have an arguably better convention around here:
| t.string :name, null: false, index: { unique: true } | |
| t.string :identifier, null: false, index: { unique: true } |
| # Only a single connection is supported today. That is enforced by a validation | ||
| # rather than by the schema, so lifting the restriction later is a one-line change: | ||
| # every association is already scoped by +llm_connection_id+ and the STI +type+ | ||
| # column is in place. |
There was a problem hiding this comment.
Do we already have plans to support multiple connections? If they are definitely coming, we might want to consider dropping that singleton approach, and just relying on a simple active flag in the database, capped by a single occurrence by a validation + index (for now).
| def available? | ||
| OpenProject::FeatureDecisions.llm_connection_active? && | ||
| enabled? && | ||
| instance.configured? | ||
| end |
There was a problem hiding this comment.
The enabled field currently seems misplaced, especially if we consider extending this table with more connections later.
The OpenProject::FeatureDecisions toggles (reachable at admin/settings/experimental) are the pre-release "feature flags" -- we use such flag for a feature that's currently in development. Once it's finalized and ready to roll out to everyone, we generally just drop it (or force it to active).
The intent behind that enabled flag ("toggle all AI features") should probably live in the Setting framework instead -- you already introduce it in a later PR, but it's actually worth doing that at the beginning.
Then, I would suggest enabled to be reused as an "availability" flag for the LLM connection, once/if we remove that singleton restraint. Hence, for now, this would simply be true by default at all times.
| def change | ||
| create_table :llm_connections do |t| | ||
| t.string :name, null: false, index: { unique: true } | ||
| t.string :type, null: false, index: true |
There was a problem hiding this comment.
I'm not sure why we keep this in and needlessly (?) activate STI. It seems good to drop from my POV.
| # Model references are strings, never foreign keys: a selection must survive | ||
| # the model disappearing from the remote catalogue. |
There was a problem hiding this comment.
Why do we care about the remote catalogue, if we actually mirror models locally starting from #24882? It seems these might as well be proper references. (Created either by a later separate migration, or by shuffling the stack so that the table already gets introduced now.)
| # RubyLLM providers whose model lists come from its registry. | ||
| OPENAI_COMPATIBLE = "openai" | ||
|
|
||
| FORMATS = %w[ |
There was a problem hiding this comment.
So this is currently more or less a mirror of a similar listing in RubyLLM::Provider.providers.keys.
I guess it makes sense to have it here to be more explicit. But in that case, I think we need a way to detect any drift after gem updates. Something like:
it "offers only providers the gem still ships" do
expect(Llm::Adapters::FORMATS).to all(be_in(RubyLLM::Provider.providers.keys.map(&:to_s)))
end
Ticket
AI-3
What are you trying to accomplish?
This is PR 1 of 11: the stacked split of the original implementation PR #24851, which was too large to review in one piece. Each PR in the stack covers one domain; nothing is user-visible until the
llm_connectionfeature flag is flipped in a later PR.This part lays the foundation: the
llm_connectionstable andLlmConnectionrecord (singleton by validation, schema ready for more), theruby_llmgem with the provider-headers patch,Llm::Sessionas the one wrapper around it,Llm::Clientand the error hierarchy, and the adapter layer that either queries an OpenAI-compatible server's/v1/modelslive or reads a registry-backed provider's catalogue. The API key is ciphered at rest and the feature flag plus its environment documentation entry are added here because the flag defines a setting.Merge checklist
llm_connectionkeeps everything off in production