Support provider-qualified model fallback selectors - #668
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates Fabro’s model reference handling so fallback selectors can be provider-qualified using the canonical provider:selector syntax (while continuing to accept legacy provider/model inputs), and extends provider-scoped selection to support provider API IDs that may contain / and additional : characters. These changes propagate through catalog construction/lookup, workflow fallback resolution, server session selection, OpenAPI + generated client types, and public documentation.
Changes:
- Introduce canonical
provider:selectorparsing/serialization forModelRefwhile retaining legacyprovider/modelinput. - Extend provider-scoped catalog lookup/indexing to include provider API IDs (and reject ambiguous provider-local identifiers during catalog build).
- Update server/session selection, workflow fallback resolution, OpenAPI schema, generated TypeScript client docs, and public docs to reflect the new selector format.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/packages/fabro-api-client/src/models/create-run-session-request.ts | Updates generated client docs for model to describe provider:selector and legacy compatibility. |
| lib/foundation/fabro-types/src/settings/model_ref.rs | Implements provider:selector parsing + canonical display and updates tests (but needs a fix for legacy parsing with colons). |
| lib/foundation/fabro-model/src/catalog.rs | Indexes provider API IDs per offering, updates provider-scoped lookup semantics, and adds catalog build/lookup tests. |
| lib/foundation/fabro-dev/src/commands/docs_options_reference.rs | Updates docs generator example to use openrouter:kimi-k3 + gpt-terra. |
| lib/foundation/fabro-config/src/tests/combine.rs | Adjusts config layering tests to validate rendered fallback ordering with new model ref rendering. |
| lib/foundation/fabro-config/src/layers/run.rs | Updates fallbacks field documentation to describe provider:selector + legacy acceptance. |
| lib/components/fabro-workflow/src/operations/start.rs | Updates fallback-chain resolution to use selector and adds workflow-level tests for OpenRouter/Kimi + API ID canonicalization. |
| lib/apps/fabro-server/src/server/handler/sessions.rs | Updates session model canonicalization to accept colon-qualified selectors and adds tests for provider-scoped API IDs containing /. |
| docs/public/reference/user-configuration.mdx | Updates reference docs for fallbacks semantics and examples. |
| docs/public/execution/run-configuration.mdx | Expands run configuration docs with provider:selector details and examples (including selectors with / and :). |
| docs/public/execution/failures.mdx | Updates failure/fallback docs to describe provider:selector and provider API ID support. |
| docs/public/core-concepts/models.mdx | Updates models concept docs to describe the new fallback selector format and compatibility notes. |
| docs/public/api-reference/fabro-api.yaml | Updates schema descriptions and adds ModelRef schema documentation for new selector format + legacy acceptance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Follow-up cleanup on the provider-qualified selector work. - build_model_indexes now takes the paired (Model, CatalogModelSettings) slice it is built from, instead of a separate settings map. This drops a per-model map lookup with two cloned key components and removes the expect() panic path for an invariant the caller already guarantees. - get_on_provider expresses the exact-then-legacy lookup as one closure applied twice, rather than a nested then/flatten chain. - ModelRef::from_str selects the separator first and then checks both sides once, so the empty-side check is no longer duplicated across two branches and the slash split no longer allocates a Vec. - Shorten the TooManySlashes message to the action the user should take. - Merge the two near-identical fallback chain tests into one that runs both qualified selector forms through the same assertion. - The fallbacks splice test now asserts through the existing Serialize impl instead of hand-rolling the ModelRefOrSplice rendering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran a simplification pass over the branch and pushed 8a41d46.
No behavior change. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
lib/foundation/fabro-types/src/settings/model_ref.rs:418
- The serde JSON test no longer exercises the documented legacy
provider/modelinput behavior. SinceModelRefaccepts legacy slash-qualified references but serializes them to the canonicalprovider:selectorform, it would be good to cover that canonicalization in this test to prevent regressions.
let input = r#"{"m":"openrouter:moonshotai/kimi-k3"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert!(matches!(
parsed.m,
ModelRef::Qualified { ref provider, ref selector }
if provider == "openrouter" && selector == "moonshotai/kimi-k3"
));
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
catalog_from_settings_rejects_duplicate_provider_api_ids was a copy of catalog_from_settings_rejects_duplicate_model_aliases with the alias declaration swapped for an api_id. Fold them into one table-driven test so the shared invariant is stated once: canonical IDs, aliases, and API IDs occupy a single identifier namespace per provider. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up: found a behavior regression on this branch while reviewing. Flagging rather than fixing, since the fix is a design call. Bare model IDs containing
|
| branch | result |
|---|---|
origin/main |
Ok((openrouter, "future-model:latest")) |
| this branch | Err(400 "unknown model provider 'future-model'") |
The passthrough it breaks is deliberate and tested — catalog.rs maps UnknownSelectorOnProvider to SelectedModel { provider, model: <verbatim selector> }, and canonical_session_model_preserves_unknown_passthrough_on_selected_provider pins it.
This matters because colon-bearing model IDs are normal:
- Ollama IDs are universally
name:tag(ollama.tomldocumentsapi_id = "qwen3.5:latest"in a commented example). - Five built-in bedrock api_ids end in
:0, e.g.us.anthropic.claude-haiku-4-5-20251001-v1:0. - Bedrock ARNs (
arn:aws:bedrock:...) name custom inference profiles.
Same shape hits run.model.fallbacks: a colon-bearing bare entry resolves canonical_provider_id to an unknown ID and fails run start with ProviderUnavailable, where it previously pushed a passthrough target.
Note the qualified form is fine and is tested — bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0 works, because the split takes only the first colon. Only the bare form regresses.
Two ways out:
- Keep
FromStrproducingBarefor colon strings and do the split insideresolve(), guarded byregistry.is_provider(prefix). This matches the module header, which already says ambiguity resolution against a known registry happens at consumption time. - Keep the parse, but at the two consumption sites treat an unknown provider on a
Qualifiedref as a bare selector ofprovider:selectorrather than erroring.
Either way worth a test pinning bare "llama3:8b" with an ollama pin.
Also pushed b294251: the duplicate-api_id catalog test was a copy of the duplicate-alias one, so they are now a single table-driven test.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
lib/foundation/fabro-types/src/settings/model_ref.rs:79
ModelRefparsing currently always prefers:when present. Inputs likeopenai/gpt-5.6-sol:0(legacy slash-qualified with a:in the selector) will be mis-parsed as provider=openai/gpt-5.6-soland selector=0, which is a backwards-compatibility regression and yields confusing downstream errors. Prefer whichever separator (:or/) appears first, and only treat:as the qualifier when it precedes any/.
// A `:` splits provider from selector. The selector keeps any further
// `:` or `/`, so provider API IDs survive intact. Without a `:`, a
// single `/` is the legacy qualified form.
let (provider, selector) = match trimmed.split_once(':') {
Some(qualified) => qualified,
lib/foundation/fabro-types/src/settings/model_ref.rs:278
- The new parsing rules around
:vs legacy/separators would benefit from a regression test that covers a legacy slash-qualified selector containing:(e.g.openai/gpt-5.6-sol:0). This ensures the parser chooses the first separator and that serialization still canonicalizes toprovider:selector.
fn colon_qualified_selector_may_contain_slashes_and_colons() {
assert_eq!(
"openrouter:moonshotai/kimi-k3".parse::<ModelRef>().unwrap(),
ModelRef::Qualified {
provider: "openrouter".into(),
Splitting on the first colon in FromStr broke bare model IDs that legitimately contain one. A reference like "llama3:8b" parsed as provider "llama3" selector "8b", and since "llama3" is not a provider the lookup failed instead of passing the ID through to the pinned provider. Verified against origin/main: canonical_session_model with "future-model:latest" pinned to openrouter returned the passthrough before and a 400 after. This is not fixable by choosing a different separator. Bedrock inference-profile ARNs contain both colons and slashes, and docs/public/integrations/bedrock.mdx tells users to put arbitrary inference-profile IDs in api_id. Only the registry can tell a provider prefix from a model ID that happens to contain the separator. FromStr now leaves colon-bearing tokens bare, and ModelRef::qualify promotes only those whose prefix names a known provider. resolve() applies it, so the fallback path is covered; sessions.rs applies it before its own match so it keeps its tailored ambiguity messages. ModelRegistry is now implemented for Catalog in fabro-types, replacing the CatalogModelRegistry wrapper that existed only in start.rs, so both call sites share one registry view. Covered by regression tests at both surfaces, plus qualify unit tests for ollama tags and Bedrock ARNs. The pre-existing passthrough test canonical_session_model_preserves_unknown_passthrough_on_selected_provider passes again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Fixed in 2103e3f — went with the registry-guarded split.
Reasoning for that approach over patching the call sites: no separator is safe to split on by shape. Bedrock inference-profile ARNs contain both Same probe as before, now on the fix:
The first row matches Also in this commit:
Full workspace suite green: 7408 tests, plus Still open from the review, not addressed here: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
lib/foundation/fabro-model/src/catalog.rs:900
get_on_providerdoes a second lookup usingnormalize_legacy_builtin_selector(selector)even when normalization is a no-op (the common case for non-legacy selectors). That repeats the same HashMap lookup/allocation work when the selector is simply unknown. Consider only attempting the normalized lookup when the normalized value differs from the original selector.
let index =
lookup(selector).or_else(|| lookup(&normalize_legacy_builtin_selector(selector)))?;
Addresses Copilot review feedback on #668. Checking for `:` before the legacy `/` form broke `provider/model` references whose selector contains a colon, which Bedrock API IDs do: `bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0` parsed as the whole path up to the last colon, then `0`. On main it is a pin to bedrock with the full ID as the selector. Now whichever separator appears first decides. A `/` before any `:` is the legacy pin and its selector may contain colons. Otherwise the token stays bare and `qualify` promotes it only when the prefix names a provider, so `openrouter:moonshotai/kimi-k3` still qualifies. Adds the regression test Copilot asked for, covering both Bedrock-style legacy input and the colon-before-slash case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Two follow-ups from this round. Declined: the
|
| result | |
|---|---|
origin/main |
passes in 0.5–0.8s |
| this branch | 14–20s, intermittently hitting nextest's 20s timeout and failing the run |
The cause is GET /api/v1/system/df and GET /api/v1/system/resources. Probing get_system_df:
storage_dir |
build_disk_usage_response |
|
|---|---|---|
origin/main |
/var/folders/.../T/fabro-test-<ULID>/storage |
161µs |
| this branch | /Users/<me>/.fabro/storage |
39.8s |
So on this branch the test escapes its temp dir and walks the developer's real ~/.fabro/storage — 194MB here. That is worth fixing beyond the speed: server tests reading real user storage is a test-isolation problem, and it makes the timing machine-dependent, which is probably why CI has not flagged it.
server_storage_dir() is server_settings().server.storage.root, whose default is Home::from_env().root().join("storage"). On main something is pointing that at a temp root for this test and on the branch it is not. I did not root-cause which change flips it — none of the 13 changed files touches storage or config resolution, so it is likely indirect. Reproduce with:
cargo test -p fabro-server --features test-support --test it -- \
openapi_conformance::all_spec_routes_are_routable --nocapture
Leaving this one to you rather than guessing at a fix.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
docs/public/reference/user-configuration.mdx:389
- The public docs reference
super::splice_array, which is a Rust module path and doesn’t resolve in the Mintlify docs. Consider removing the link and describing the splice marker in plain language.
| `fallbacks` | array<string> | [] | Ordered fallback references: bare providers, bare model IDs or aliases,<br />or provider-qualified `provider:selector` values. A qualified selector<br />may be a model ID, alias, or provider API ID. Legacy `provider/model`<br />values remain accepted. Supports the `...` splice marker at layering<br />time — see [`super::splice_array`]. |
Summary
provider:selectormodel references while retaining legacyprovider/modelinputExample
openrouter:moonshotai/kimi-k3is also accepted and resolves to the same canonical OpenRouter offering.Testing
cargo nextest run -p fabro-model -p fabro-config -p fabro-workflow— 1,666 passedcargo nextest run -p fabro-server— 770 passedcargo nextest run -p fabro-api— 202 passed