Skip to content

[AI-3] Probe models for embedding support automatically - #24886

Open
tangopium wants to merge 8 commits into
feature/66020-pr07-model-curationfrom
feature/66020-pr08-embedding-detection
Open

[AI-3] Probe models for embedding support automatically#24886
tangopium wants to merge 8 commits into
feature/66020-pr07-model-curationfrom
feature/66020-pr08-embedding-detection

Conversation

@tangopium

@tangopium tangopium commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Ticket

AI-3

What are you trying to accomplish?

PR 8 of 11 in the AI-3 stack. The one behavioural probe worth writing: a single embeddings request per candidate model, whose 200 response also carries the vector dimension count. The background pass after a credential change is capped at ten models whose names suggest they embed, because a gateway can list hundreds and some providers bill per request. A definite refusal records unsupported, a vector records supported, and anything else records nothing, because servers silently drop unknown parameters and a 200 alone proves nothing. Administrator assertions are never overwritten.

Merge checklist

  • Added/updated tests
  • Behind the llm_connection feature flag

Stacked on #24885.

@github-actions

Copy link
Copy Markdown

Caution

The provided work package version does not match the core version

Details:

Please make sure that:

  • The work package version OR your pull request target branch is correct

@github-actions

Copy link
Copy Markdown

Caution

The Enterprise plan field is not set on the work package

Details:

Please make sure that:

  • The work package Enterprise plan field is set

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e475d8679

ℹ️ 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".

Comment thread app/services/llm_connections/detect_capabilities_service.rb Outdated
Comment thread app/services/llm_connections/detect_capabilities_service.rb Outdated
Comment thread app/services/llm_connections/detect_capabilities_service.rb Outdated
Comment thread app/services/llm/probes/embeddings_probe.rb Outdated
@tangopium
tangopium force-pushed the feature/66020-pr08-embedding-detection branch from 6e475d8 to 87cc4ee Compare August 22, 2026 15:05
@tangopium tangopium changed the title [#66020] Probe models for embedding support automatically [AI-3] Probe models for embedding support automatically Aug 22, 2026
@tangopium
tangopium force-pushed the feature/66020-pr08-embedding-detection branch from 87cc4ee to c83347e Compare August 22, 2026 15:26
@tangopium
tangopium force-pushed the feature/66020-pr08-embedding-detection branch from c83347e to 82901e2 Compare August 22, 2026 15:51
Adds the one behavioural probe worth writing: a single embeddings request per candidate model, whose 200 response also carries the vector dimension count. Probing every listed model would be wrong, since a gateway can list hundreds and some providers bill per request, so the background pass after a credential change is capped at ten models whose names suggest they embed. The name is only a hint for where to spend a probe, never a verdict in itself.

A definite refusal records unsupported, a vector records supported, and anything else records nothing at all, because servers silently drop unknown parameters and a 200 alone proves nothing. Administrator assertions are never overwritten.

Part 8 of the AI-3 stack.

https://community.openproject.org/work_packages/66020
@tangopium
tangopium force-pushed the feature/66020-pr08-embedding-detection branch from 82901e2 to d6182c5 Compare August 22, 2026 16:16
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]
🤖 Ask Copilot to investigate

Copy 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.

@copilot The following spec(s) are flaky in CI (first seen on PR #24886, linked for reference only):

- `rspec ./spec/features/work_packages/table/switch_types_spec.rb[1:1:1]`

Treat this as a standalone task, unrelated to PR #24886. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #24886 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @tangopium to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @tangopium, and request a review from @tangopium.
On every commit, set @tangopium as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@tangopium tangopium self-assigned this Aug 22, 2026
@tangopium
tangopium requested a review from thykel August 22, 2026 21:39

@thykel thykel left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we discussed potentially changing a lot of this stuff -- so for now I'm just leaving you with some Mr. Robot comments that look legit.


# The server understood the request and refused it for this model. Anything
# else -- 5xx, throttling -- says something about the server, not the model.
REFUSED_STATUSES = [400, 404, 405, 501].freeze

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Logic 🚨 Do all four statuses really mean the model refused?

A match here records :unsupported, which is the only state that blocks - and record refuses to soften a persisted definite verdict with a later :unknown. So a wrong reading is permanent until an administrator overrides it by hand.

Two of the four look like statements about the server, not the model:

  • 501 - this server implements no embeddings at all.
  • 405 - about the route, not the model.
  • 404 - ambiguous: no /embeddings route, or an unknown model.
  • 400 - can be about the request. The probe sends PROBE_INPUT as a bare string; a server that requires input as an array, or requires encoding_format, answers 400 for every model, including genuine embedding models.

Worth noting that the stack already reads three of these the opposite way: LlmServerValidator::MODELS_ENDPOINT_ABSENT = [404, 405, 501] treats them as "the endpoint is not here, conclude nothing bad".

The failure mode: point the connection at a gateway that routes chat completions and nothing else - the deployment manual model entry exists for - and every candidate is permanently marked incapable, so semantic search can never bind to any of them. In the models table that renders as a confident grey "Chat" label, indistinguishable from a correctly determined one.

Could we drop 404/405/501 to :unknown and keep 400 as the only per-model refusal? And if the first probe of a batch returns one of them, stop - the remaining nine billed requests buy nothing, which also serves the cost rationale behind BACKGROUND_LIMIT.

require "spec_helper"

RSpec.describe LlmConnections::DetectCapabilitiesService, :llm_server_helpers, :webmock do
subject(:service) { described_class.new(connection) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Testing ⚠️ The method the job calls has no test

All three examples call service.detect("bge-m3"). detect_likely_embedding_models - the entry point DetectCapabilitiesJob invokes - is never exercised, and it holds both decisions the PR description leads with:

  • the EMBEDDING_NAME_HINT filter, so non-matching models are skipped
  • BACKGROUND_LIMIT = 10

The cap guards real money, since each probe is a billed request on some providers. Could we add a case for each? Nothing would currently catch the cap being removed.


def record(model_id, result)
verdicts.transaction do
verdict = verdicts.lock.find_or_initialize_by(model_id:, capability: "embeddings")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Logic ⚠️ The row lock does not cover the first probe

FOR UPDATE locks a row that exists. On a first probe there is no row, so nothing is locked and find_or_initialize_by just builds a new record - the comment below promises more than the code delivers.

Two concurrent probes of the same model then both insert and hit the unique index on (llm_connection_id, model_id, capability), raising RecordNotUnique with nothing to catch it. That becomes reachable once the synchronous detect gets its caller in the feature bindings controller and can run alongside the background job.

Would create_or_find_by, or rescuing RecordNotUnique and re-reading, close it?

@probe ||= Llm::Probes::EmbeddingsProbe.new(connection)
end

def candidates

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Design ⚠️ Probing spends billed requests on models the administrator hid

available_model_ids is models.active, which includes rows an administrator has deactivated. The previous PR introduced selectable_model_ids for exactly this distinction.

Someone who switched a model off has said they do not want it used, so a speculative probe against it costs a request for no benefit. Is selectable_model_ids the right source here?

# connection does not wait on one request per candidate model.
class DetectCapabilitiesJob < ApplicationJob
def perform
connection = LlmConnection.first

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Design ℹ️ Third place the single-connection assumption is hard-coded

This is copied from SyncModelsJob, including the guard that cannot fire: configured? is base_url.present?, and base_url is null: false with a presence validation, so it is always true for a persisted row.

With Queries::LlmModels::LlmModelQuery#default_scope, that is now three places holding an assumption LlmConnection's own docstring says is a one-line change to lift. Could this iterate with find_each and leave the cardinality to the model's validation?

REFUSED_STATUSES = [400, 404, 405, 501].freeze

Result = Data.define(:state, :detail) do
def supported? = state == :supported

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Style ℹ️ Result#supported? has no caller

No reference in app/, none in the specs, and none later in the stack. Worth removing until something needs it?

class DetectCapabilitiesService
# Naming is a hint for which models are worth spending a probe on, never a
# verdict in itself.
EMBEDDING_NAME_HINT = /embed|bge|e5|gte|nomic|minilm/i

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #Style ℹ️ e5 and gte match anywhere in an identifier

Unanchored, these two match inside build hashes and version suffixes, so a chat model can be pulled into the candidate set and cost a request. The cost is bounded and the comment is clear that naming is only a hint, so this is minor - but anchoring on a separator, for example (^|[-_./])e5, would tighten it at no cost.

Live discovery hands us the server's own card, which for OpenRouter and the
gateways following it carries the model name and a context_length rather than
the max_model_len that vLLM reports. Both were dropped on the floor, so an
OpenRouter connection listed bare ids and an empty context window even though
the server had just told us better.

The card now names the model when the adapter cannot, its context_length fills
the window unless the operator's own limit is reported, and the registry only
fills in what neither the card nor the stored metadata already has, so a
refresh no longer overwrites a figure that came from the deployment itself.
The UX review with Tom made the model refresh manual, so hanging capability
detection off a settings change no longer matches when the list actually
changes. Detection now follows the sync itself, whichever caller asked for it:
the initial fill, the Refresh button on the AI models page and the environment
seeder's background sync all end in a probe pass.

The pass itself spends fewer requests. It reads the models an administrator
still offers rather than every active one, so a model switched off is not
probed, and the e5 and gte name hints only count on a separator, which keeps
build hashes out of the candidate set. The job iterates the stored connections
instead of taking the first one behind a guard that cannot fire.

Before the first probe of a model there is no row for FOR UPDATE to lock, so
two probes running at once both inserted one. The row is now claimed through
the unique index first and the verdict written under the lock as before.
Tom pointed out in the review that three of the four statuses the probe read as
a refusal are statements about the server. A 404, 405 or 501 says the
embeddings route is not there, which a gateway routing chat completions and
nothing else answers for every model alike. Since only an unsupported verdict
blocks, and a definite verdict is never softened again, that marked every
candidate permanently incapable and left semantic search with nothing to bind
to.

Those three are now inconclusive, in the same reading LlmServerValidator
already applies to the model list, and a batch that meets one stops: the
remaining probes are billed for the same answer. A 400 stays the one per-model
refusal.

Result#supported? goes with it; nothing ever called it.
Enrichment skipped the published window whenever the model already had one,
and an administrator's override counts as one. A refresh against a server
that lists bare model ids drops the registry figure from the metadata, so
clearing the override afterwards left the window unknown until the next
refresh.

The guard now looks at what the server reported, not at the effective
window. Reported in the review of the follow-up commits (finding 4).
The batch decided whether to stop from the verdict it had just recorded, but
an inconclusive probe never softens a definite verdict, so a model with a
cached supported or unsupported answer hid the 404 and the batch kept
spending requests up to its limit.

The decision now reads the probe result itself. Reported in the review of the
follow-up commits (finding 6).
The example was annotated with the claim that only registry-backed adapters
report a name. Since this part reads the name out of the live card as well, a
server speaking the OpenAI API does supply one whenever it lists more than ids.
The capability detection spec still asked the factory for a connection with
LLMs switched on, which was a column on the row before that switch became a
setting. The probe does not read it either way.
@tangopium
tangopium force-pushed the feature/66020-pr08-embedding-detection branch from d6182c5 to 861d35f Compare September 10, 2026 06:52
@tangopium

Copy link
Copy Markdown
Collaborator Author

The branch was rebuilt, so the commits your comments point at no longer exist and those threads now render as outdated. Nothing was dropped. Here is where each point landed.

  • Only a 400 counts as a model refusing to embed. 404, 405 and 501 are read as inconclusive, which matches how LlmServerValidator::MODELS_ENDPOINT_ABSENT already reads them.
  • The batch stops once the server answers that it has no embeddings route at all, so the remaining billed requests are not spent.
  • Probing uses the selectable set, so a model an administrator switched off costs nothing.
  • The first probe of a model no longer races: the row is claimed through the unique index rather than built in memory, and an administrator assertion made while a probe is in flight still wins.
  • The short name hints match only as whole segments, and the unused Result#supported? is gone.

CI has not started on the new commits: only the CLA check ran, and the workflow runs seem to need maintainer approval on this repository. The checks are waiting on that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants