From 6b75acd07d3b83d20f1cc469867562a123ffa33c Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 23 Jul 2026 03:41:01 +0000 Subject: [PATCH] =?UTF-8?q?checkpoint=20=E2=9A=92=EF=B8=8F=20Generated=20w?= =?UTF-8?q?ith=20[Fabro](https://fabro.sh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.json | 360 +- stages/005-implement@1/diff.patch | 4329 +++++++++++++++++ stages/005-implement@1/status.json | 6 + stages/006-simplify_fable@1/prompt.md | 386 ++ .../006-simplify_fable@1/provider_used.json | 6 + 5 files changed, 5079 insertions(+), 8 deletions(-) create mode 100644 stages/005-implement@1/diff.patch create mode 100644 stages/005-implement@1/status.json create mode 100644 stages/006-simplify_fable@1/prompt.md create mode 100644 stages/006-simplify_fable@1/provider_used.json diff --git a/run.json b/run.json index c9f95d44e..641685c49 100644 --- a/run.json +++ b/run.json @@ -478,7 +478,7 @@ "kind": "running" }, "status_updated_at": "2026-07-23T02:53:40.324596262Z", - "last_event_at": "2026-07-23T03:40:57.488757296Z", + "last_event_at": "2026-07-23T03:41:01.621060682Z", "pending_control": null, "checkpoints": [ { @@ -758,9 +758,9 @@ } }, { - "seq": 0, + "seq": 3070, "checkpoint": { - "timestamp": "2026-07-23T03:40:57.539454606Z", + "timestamp": "2026-07-23T03:41:01.199945571Z", "current_node": "implement", "completed_nodes": [ "start", @@ -771,8 +771,126 @@ ], "node_retries": {}, "context_values": { + "thread.preflight_lint.current_node": "implement", + "internal.retry_count.preflight_lint": 0, "internal.work_dir": "/home/daytona/workspace/fabro", + "graph.goal": "# Provider-aware model aliases and API IDs\n\n## Outcome\n\nFabro workflows can name a model with one stable model slug or alias and run unchanged against whichever provider the operator has available. A model offering is identified by `(provider, ModelId)`, so the same `ModelId` and the same alias may appear on multiple providers. For an unqualified selector, Fabro filters to ready providers and then uses provider priority to choose one offering deterministically.\n\nThe motivating behavior is:\n\n| Ready providers | Selector | Selected offering |\n| --- | --- | --- |\n| OpenAI only | `gpt-56-sol` | OpenAI's `gpt-5.6-sol` |\n| OpenRouter only | `gpt-56-sol` | OpenRouter's `gpt-5.6-sol` offering |\n| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because its provider priority is higher |\n| OpenAI and OpenRouter, explicit `provider = \"openrouter\"` | `gpt-56-sol` | OpenRouter, because an explicit provider is a pin |\n\nThe provider-facing API identifier remains an implementation detail of the selected offering. It defaults to the canonical model slug and can be overridden with `api_id` when a provider uses another convention.\n\n## Scope and design decisions\n\n### Vocabulary and identity\n\n- `ProviderId` identifies who serves the request, such as `openai` or `openrouter`.\n- `ModelId` is the canonical, human-facing model slug, such as `gpt-5.6-sol` or `claude-opus-4-8`. It never means an alias.\n- An alias is an alternate user-facing selector, such as `gpt-56-sol` or `opus`.\n- An offering is one provider's route to one `ModelId`. Its stable identity is `(ProviderId, ModelId)`.\n- `api_id` is the opaque string sent to that offering's provider API.\n- `family` remains model metadata used for display and matching; it is not a routing namespace and is not combined with `provider` or `api_id`.\n\nDo not add a separate runtime `LogicalModel` type. Use the existing `Model` as the provider-specific offering and use the existing `ModelId` newtype for its canonical ID. Internally, tuple keys `(ProviderId, ModelId)` are enough; do not add an `OfferingId` type unless implementation pressure demonstrates a real invariant it would protect.\n\n### Canonical configuration shape\n\nMove model declarations under their provider, but keep the human model slug as the model table key:\n\n```toml\n[llm.providers.openai]\npriority = 90\n\n[llm.providers.openai.models.\"gpt-5.6-sol\"]\ndisplay_name = \"GPT-5.6 Sol\"\nfamily = \"gpt-5\"\naliases = [\"gpt-56-sol\"]\ndefault = true\n\n[llm.providers.openrouter]\npriority = 25\n\n[llm.providers.openrouter.models.\"gpt-5.6-sol\"]\napi_id = \"openai/gpt-5.6-sol\"\ndisplay_name = \"GPT-5.6 Sol (via OpenRouter)\"\nfamily = \"gpt-5\"\naliases = [\"gpt-56-sol\"]\ndefault = true\n```\n\nThis shape provides a natural unique key without making humans author an API identifier or repeat `provider = \"...\"` inside every model. Model settings continue to field-merge by provider and model slug across configuration layers.\n\nAt catalog build time:\n\n```text\neffective_api_id = configured api_id, otherwise ModelId's exact slug\n```\n\nReject an explicitly empty `api_id`. Do not perform provider-specific string rewrites, prefix inference, or template expansion. A future template feature may be authoring sugar that produces the same resolved `api_id`, but it is not part of this change.\n\n### Alias and selection semantics\n\nBuild candidate sets rather than a global `identifier -> one model` map:\n\n- Canonical model IDs may repeat across providers.\n- Aliases may repeat across providers and may point to different canonical model IDs on different providers. This supports both strict synonyms and portable role-like aliases.\n- Within one provider, a canonical ID or alias must identify exactly one offering. Reject two models on the same provider that claim the same alias.\n- Across providers, an alias may collide with a canonical `ModelId`; the canonical-before-alias check order keeps canonical IDs reliable pins, and the shadowed alias stays reachable through its provider-qualified form. Within one provider, the previous rule already rejects the collision.\n- An explicit provider restricts lookup to that provider and bypasses provider priority.\n- An unqualified selector considers only eligible providers, then sorts by provider priority descending and canonical provider ID ascending.\n- A canonical `ModelId` match is checked before alias matches.\n- Disabled providers and disabled offerings are absent from candidate sets.\n\n\"Eligible\" must be supplied by the caller rather than inferred inside the catalog:\n\n- Runtime calls use providers whose adapters registered successfully. This accounts for credentials and adapter initialization, not merely an enabled catalog row.\n- Static validation explicitly uses all enabled catalog providers and proves that at least one candidate exists without claiming that credentials are available.\n- An explicit but unavailable provider remains a pin and produces a clear unavailable-provider error; Fabro must not silently switch it.\n\nWhen a run is created, resolve every implicit selector once and persist the chosen canonical model ID and provider. Resume uses that materialized choice; it does not reconsider provider priority because credentials changed. Runtime fallbacks remain the mechanism for handling a later provider failure.\n\nPreserve the existing passthrough behavior for uncatalogued models: when a provider is explicit, send the unknown model string unchanged and use the provider's default route policy. An unknown unqualified model may use the runtime's default ready provider as it does today, but it cannot participate in alias-based provider selection.\n\n## Implementation plan\n\n### 1. Make configuration provider-scoped\n\nFiles centered on:\n\n- `lib/crates/fabro-config/src/layers/llm.rs`\n- `lib/crates/fabro-config/src/builders.rs`\n- `lib/crates/fabro-model/src/catalog.rs`\n- `lib/crates/fabro-model/src/catalog/providers/*.toml`\n\nChanges:\n\n1. Add `models: MergeMap` to `ProviderSettings` and the equivalent model map to `ProviderCatalogSettings`.\n2. Remove `provider` from the canonical model-row shape; the containing provider supplies it.\n3. Normalize catalog data into provider/model pairs before catalog building, preserving layer precedence independently for each pair.\n4. Convert every built-in provider TOML to `[providers..models.\"\"]`.\n5. Re-key OpenRouter, Bedrock, and other aggregator offerings by Fabro's model slug rather than their provider API ID. Retain explicit `api_id` overrides for `author/model`, Bedrock profile IDs, deployment names, and other exceptions.\n6. Remove redundant `api_id` fields where they equal the model slug.\n7. Do not add an unverified provider offering merely to match the motivating example; exercise the exact example with a catalog fixture and use existing verified cross-provider models in the built-in catalog.\n\nCompatibility:\n\n- Accept the current `[llm.models.\"\"]` plus `provider = \"...\"` form as a temporary input shape. A row that omits provider adopts the provider of the unique known offering matching its id or alias; if none or several match, fail with an error naming the row.\n- Normalize each source layer into the canonical provider-scoped form before combining layers, so old and new definitions retain correct precedence.\n- Reject a single source that defines the same `(provider, model)` through both syntaxes instead of choosing silently.\n- Keep built-ins and documentation exclusively on the new syntax. Do not add a filesystem rewrite migration yet because LLM catalog layers can come from more places than one owned settings file; the compatibility parser covers all of those boundaries safely.\n- Ship a retired-identifier map for re-keyed built-in ids (old catalog key to provider plus new slug). Any selector or persisted model reference matching a retired id fails with a typed error naming the new address; nothing silently re-routes. One mechanism covers old config references, workflow graphs, and resumed pre-change runs.\n\n### 2. Rebuild catalog identity and indexes\n\nFiles centered on:\n\n- `lib/crates/fabro-model/src/ids.rs`\n- `lib/crates/fabro-model/src/types.rs`\n- `lib/crates/fabro-model/src/catalog.rs`\n- `lib/crates/fabro-model/src/model_ref.rs`\n- `lib/crates/fabro-model/src/billing.rs`\n\nChanges:\n\n1. Change `Model.id` from `String` to the transparent `ModelId` newtype and correct `ModelId` documentation so aliases are not described as model IDs. JSON remains a plain string.\n2. Key resolved model settings by `(ProviderId, ModelId)` rather than model ID alone.\n3. Replace the one-to-one `model_index` with:\n - an offering index keyed by `(ProviderId, ModelId)`;\n - canonical-ID candidates keyed by `ModelId`;\n - alias candidates keyed by alias string.\n4. Pre-sort candidate vectors with the catalog's provider ordering so every caller receives the same priority and tie-break behavior.\n5. Replace global `Catalog::get`-style assumptions with explicit methods:\n - lookup on a named provider;\n - selection from an eligible-provider set;\n - lookup of settings from a resolved `Model` offering;\n - listing every offering, optionally by provider.\n6. Make pricing, billing, codec, profile, probe, default, and closest-model lookups use the composite identity. Resolve the run-level default model with the same selection algorithm (default-flagged candidates from eligible providers, ordered by provider priority) without requiring providers to agree on their defaults.\n7. Replace `DuplicateModelIdentifier` with provider-scoped validation errors that name the provider, selector, and conflicting model IDs.\n8. Add a typed selection error that distinguishes an unknown selector from a known selector with no eligible offering. Preserve error sources and render strings only at CLI/API boundaries.\n\n### 3. Centralize provider-aware resolution\n\nFiles centered on:\n\n- `lib/crates/fabro-model/src/catalog.rs`\n- `lib/crates/fabro-types/src/settings/model_ref.rs`\n- `lib/crates/fabro-workflow/src/handler/llm/routing.rs`\n- `lib/crates/fabro-workflow/src/transforms/model_resolution.rs`\n- `lib/crates/fabro-workflow/src/run_materialization.rs`\n- `lib/crates/fabro-workflow/src/operations/start.rs`\n\nChanges:\n\n1. Implement one catalog selection algorithm taking a selector, optional explicit provider, and eligible provider IDs.\n2. Make generic `ModelRef` parsing classify bare versus provider-qualified input only. It must not try to infer a unique provider for a bare alias, because a valid alias may now have several provider candidates.\n3. Keep the existing `provider/model` qualified syntax in this change. The model slug never contains the provider API ID, so OpenRouter's slash is no longer part of the user-facing model address.\n4. Update workflow graph model resolution and run materialization to receive the ready-provider snapshot already collected during run creation.\n5. Materialize aliases to canonical `(provider, ModelId)` values in both node attributes and run defaults before persistence.\n6. Keep static validation credential-independent by resolving against all enabled candidates only for existence/capability checks.\n7. Update fallback resolution so:\n - a provider-only fallback still selects the closest compatible model;\n - a provider-qualified model/alias resolves within that provider;\n - a bare model/alias uses the fallback-time eligible set and provider priority;\n - provider-name/model-name ambiguity becomes a user-facing typed error; today AmbiguousModelRef is silently swallowed by fallback resolution, so pin this behavior change with a test.\n\n### 4. Resolve the offering before LLM dispatch\n\nFiles centered on:\n\n- `lib/crates/fabro-llm/src/client.rs`\n- `lib/crates/fabro-llm/src/adapter_registry.rs`\n- `lib/crates/fabro-llm/src/providers/common.rs`\n- provider adapter modules under `lib/crates/fabro-llm/src/providers/`\n\nChanges:\n\n1. For requests without an explicit provider, select among the client's successfully registered providers using catalog priority.\n2. For requests with an explicit provider, resolve the model or alias only on that provider and fail if the adapter is unavailable.\n3. Canonicalize a cloned request to the selected `ModelId` before validation, costing, and dispatch; leave caller-owned request data unchanged.\n4. Resolve route metadata and `api_id` from the selected composite offering. Provider adapters must pass their own canonical provider ID into catalog lookups rather than looking up settings by model string alone.\n5. Ensure response costing and billing use the same resolved offering that was dispatched.\n6. Keep explicit-provider unknown-model passthrough intact.\n\n### 5. Update server, API, CLI, and web identities\n\nFiles centered on:\n\n- `docs/public/api-reference/fabro-api.yaml`\n- `lib/crates/fabro-server/src/server/handler/models.rs`\n- `lib/crates/fabro-server/src/server/handler/sessions.rs`\n- `lib/crates/fabro-cli/src/commands/model.rs`\n- `apps/fabro-web/app/routes/settings-models.tsx`\n- generated clients in `lib/crates/fabro-api` and `lib/packages/fabro-api-client`\n\nChanges:\n\n1. Continue returning one `Model` row per offering from `GET /models`. Document that `id` is unique within a provider and that `(provider, id)` is the resource identity.\n2. Add an optional `provider` query parameter to `POST /models/{id}/test`. With a provider it tests that exact offering; without one it selects among ready providers by priority.\n3. Include `provider` in `ModelTestResult` so the tested offering is explicit.\n4. Make model-test lookup, auth issues, and probing use the selected offering rather than a global first match.\n5. Update the CLI so bulk tests always pass each row's provider, and an explicit `--provider` plus `--model` remains pinned. Match returned results by `(provider, id)`.\n6. Update the settings models page to key row state by `(provider, id)` and send the provider when testing a row; duplicate IDs must render and update independently.\n7. Update session/playground/completion resolution to use ready provider IDs and persist or return the selected provider alongside the canonical model. Enumerate the OpenAPI schema changes this implies for session, playground, and completion resources; sessions currently store only a bare model-id string.\n8. Regenerate Rust and TypeScript API clients from the OpenAPI source after changing the contract.\n\n### 6. Document the mental model\n\nFiles centered on:\n\n- `lib/crates/fabro-dev/src/commands/docs_options_reference.rs`\n- `docs/public/reference/user-configuration.mdx` (generated region)\n- `docs/public/core-concepts/models.mdx`\n- `docs/public/execution/run-configuration.mdx`\n- `docs/public/execution/failures.mdx`\n\nDocument:\n\n1. Provider, model slug, family metadata, alias, and API ID as distinct terms.\n2. Provider-scoped model configuration and the `api_id = model slug` default.\n3. The OpenAI/OpenRouter portability example and the priority table from this plan.\n4. Explicit provider selection as a pin and unqualified selection as availability plus priority.\n5. Alias reuse across providers, including the same-provider ambiguity rule.\n6. Resolution-once behavior for persisted runs and the separate role of runtime fallback chains.\n7. API IDs as opaque provider wire values that workflows should not reference.\n\nRun `cargo dev docs refresh` after editing the generator-owned reference.\n\n## Test plan\n\n### Catalog and configuration tests\n\nAdd focused unit tests proving:\n\n- two providers can declare the same canonical `ModelId`;\n- two providers can declare the same alias;\n- only OpenAI eligible selects OpenAI;\n- only OpenRouter eligible selects OpenRouter and its overridden API ID;\n- both eligible select the higher-priority provider;\n- equal priorities use canonical provider ID as the tie-breaker;\n- an explicit provider overrides priority;\n- a disabled or ineligible provider is not selected;\n- two different models on one provider cannot claim the same alias;\n- an unqualified selector matching both a canonical ID and another provider's alias selects the canonical model, while the alias offering stays reachable provider-qualified;\n- omitted `api_id` resolves to the exact model slug;\n- explicit `api_id` is preserved and an empty override is rejected;\n- provider/model layer merges do not overwrite the same slug on another provider;\n- the temporary old config shape normalizes correctly and a same-source old/new collision errors clearly.\n- a provider-less legacy row adopts the unique matching offering's provider, and a retired built-in id fails with the typed error naming its replacement.\n\n### Routing and wire tests\n\nAdd `fabro-llm` tests with fake registered providers or local capture servers that submit the same alias under three availability configurations. Assert the selected adapter and the exact wire model value, including OpenRouter's `author/model` override. Also cover explicit provider, unknown passthrough, request-control validation, and cost lookup on duplicate model IDs.\n\n### Workflow tests\n\nAdd crate-level workflow tests that create the same workflow with:\n\n- only the direct provider ready;\n- only the aggregator ready;\n- both ready;\n- an explicit lower-priority provider.\n\nAssert the persisted graph and run settings contain the selected canonical model and provider. Add a resume-oriented test showing that changing the ready provider set does not re-resolve a materialized run. Add fallback tests for a shared bare alias and a provider-qualified alias, including propagation of a provider/model ambiguity error.\n\n### API, CLI, and web tests\n\n- Server: list two rows with the same ID but different providers; filter by provider; test each exact offering; test priority selection when provider is omitted.\n- CLI: bulk model tests do not conflate duplicate IDs, and JSON output includes the selected provider.\n- Web: duplicate-ID rows have independent React keys and test-result state, and each request includes the row provider.\n- API generation: retain the existing `Model` Rust type replacement and add or update JSON parity/type-identity coverage as required by the API policy.\n\nUse unit/crate integration tests for catalog and routing behavior. Use the existing command/API test layers only for their public contracts; no live provider credentials are required.\n\n## Verification\n\nRun, in this order:\n\n```sh\ncargo build -p fabro-api\ncd lib/packages/fabro-api-client && bun run generate\ncargo dev docs refresh\ncargo nextest run -p fabro-model\ncargo nextest run -p fabro-config\ncargo nextest run -p fabro-llm\ncargo nextest run -p fabro-workflow\ncargo nextest run -p fabro-server\ncd apps/fabro-web && bun test\ncd apps/fabro-web && bun run typecheck\ncargo dev docs check\ncargo +nightly-2026-04-14 fmt --check --all\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\nulimit -n 4096 && cargo nextest run --workspace\ncargo build --workspace\n```\n\nBefore accepting any changed snapshots, run `cargo insta pending-snapshots` and inspect the complete pending set.\n\n## Completion criteria\n\n- A workflow using one shared alias runs unchanged for an OpenAI-only operator and an OpenRouter-only operator.\n- When both are ready, provider priority selects deterministically.\n- Explicit provider selection always pins the provider.\n- The selected offering's exact `api_id` reaches the provider wire request.\n- No catalog, routing, billing, API, CLI, or UI lookup treats model ID alone as a globally unique offering identity.\n- Built-ins and public documentation use provider-scoped model-slug keys and omit redundant API IDs.\n- Existing user catalog syntax remains readable through the compatibility normalization path.\n\n## Unresolved questions\n\n- What release or date should end support for the legacy top-level `[llm.models]` syntax? This does not block implementation; the plan keeps it as a compatibility input and makes the new provider-scoped form canonical.\n", + "internal.node_visit_count": 1, + "internal.retry_count.implement": 0, + "internal.retry_count.start": 0, + "thread.preflight_compile.current_node": "preflight_lint", + "internal.retry_count.toolchain": 0, + "failure_class": "deterministic", "failure_signature": "implement|deterministic|api_deterministic|openrouter|invalid_request", + "internal.retry_count.preflight_compile": 0, + "internal.run_id": "01KY6E8S0YA6KAR5ZF53X7QMWZ", + "internal.thread_id": "preflight_lint", + "thread.toolchain.current_node": "preflight_compile", + "outcome": "failed", + "graph.rankdir": "LR", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "current_node": "implement", + "thread.start.current_node": "toolchain", + "internal.fidelity": "compact" + }, + "node_outcomes": { + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 145187, + "active_time_ms": 145187 + } + }, + "implement": { + "status": "failed", + "failure": { + "message": "LLM error: Invalid request to openrouter: This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 46521. To increase, visit https://openrouter.ai/settings/credits and add more credits", + "category": "deterministic", + "signature": "api_deterministic|openrouter|invalid_request" + }, + "usage": null + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 160248, + "active_time_ms": 160248 + } + }, + "start": { + "status": "succeeded", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 1266, + "active_time_ms": 1266 + } + } + }, + "next_node_id": "simplify_fable", + "git_commit_sha": "85a7690acff82db107c0efafcd21b588f4c5f5d8", + "loop_failure_signatures": { + "implement|deterministic|api_deterministic|openrouter|invalid_request": 1 + }, + "node_visits": { + "preflight_lint": 1, + "implement": 1, + "preflight_compile": 1, + "start": 1, + "toolchain": 1 + } + }, + "diff": { + "patch": "diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx\nindex 7bda6217b..c466e3f96 100644\n--- a/docs/public/core-concepts/models.mdx\n+++ b/docs/public/core-concepts/models.mdx\n@@ -9,6 +9,43 @@ No single model is best at everything. Fabro lets you assign the right model to\n \"Ensemble\n \n \n+## How model selection works\n+\n+Fabro separates the name a workflow uses from the value a provider expects on\n+the wire:\n+\n+| Term | Meaning |\n+|---|---|\n+| **Provider ID** | Who serves the request, such as `openai` or `openrouter`. |\n+| **Model slug** | The canonical, human-facing model ID, such as `gpt-5.6-sol`. |\n+| **Alias** | An alternate user-facing selector, such as `gpt-56-sol`. |\n+| **Offering** | One provider's route to one model slug. Its identity is `(provider, model slug)`. |\n+| **Family** | Metadata used for display and compatible-model matching, not a routing namespace. |\n+| **API ID** | The opaque string sent to the selected provider API. Workflows do not reference it. |\n+\n+A model slug is unique within a provider, not across the whole catalog. Two\n+providers can offer the same slug and reuse the same alias, so a workflow can\n+use one stable selector wherever either provider is available.\n+\n+For an unqualified selector, Fabro first finds matching offerings on **ready\n+providers**—providers whose adapters registered successfully with usable\n+credentials and configuration. It then chooses the provider with the highest\n+`priority`; equal priorities use canonical provider ID in ascending order. A\n+canonical model-slug match is considered before alias matches.\n+\n+| Ready providers | Selector | Selected offering |\n+|---|---|---|\n+| OpenAI only | `gpt-56-sol` | OpenAI's `gpt-5.6-sol` |\n+| OpenRouter only | `gpt-56-sol` | OpenRouter's `gpt-5.6-sol` offering |\n+| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because its provider priority is higher |\n+| Both, with `provider = \"openrouter\"` | `gpt-56-sol` | OpenRouter, because an explicit provider is a pin |\n+\n+\n+An explicit provider restricts lookup to that provider. If the pinned provider\n+is unavailable or does not offer the selector, Fabro reports the error instead\n+of silently switching providers.\n+\n+\n ## Model catalog\n \n | Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed |\n@@ -46,13 +83,14 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi\n \n ## Configuring providers and models\n \n-Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release.\n+Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release. Declare each model under the provider that serves it:\n \n ```toml title=\"settings.toml\"\n [llm.providers.proxy]\n display_name = \"Acme Gateway\"\n adapter = \"openai_compatible\"\n base_url = \"https://llm-gateway.example.com/v1\"\n+priority = 50\n aliases = [\"gateway\"]\n \n [llm.providers.proxy.auth]\n@@ -62,8 +100,7 @@ credentials = [\"env:ACME_GATEWAY_API_KEY\", \"vault:ACME_GATEWAY_API_KEY\"]\n x-portkey-api-key = \"{{ env.PORTKEY_API_KEY }}\"\n x-portkey-config = \"@bedrock-prod\"\n \n-[llm.models.\"team-code-large\"]\n-provider = \"proxy\"\n+[llm.providers.proxy.models.\"team-code-large\"]\n api_id = \"provider-wire-model-name\"\n agent_profile = \"anthropic\"\n display_name = \"Team Code Large\"\n@@ -73,32 +110,33 @@ small_default = true\n aliases = [\"team-code\"]\n estimated_output_tps = 80\n \n-[llm.models.\"team-code-large\".limits]\n+[llm.providers.proxy.models.\"team-code-large\".limits]\n context_window = 200000\n max_output = 32000\n \n-[llm.models.\"team-code-large\".features]\n+[llm.providers.proxy.models.\"team-code-large\".features]\n tools = true\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n-effort = true\n \n-[llm.models.\"team-code-large\".controls]\n+[llm.providers.proxy.models.\"team-code-large\".controls]\n reasoning_effort = [\"low\", \"medium\", \"high\"]\n speed = [\"fast\"]\n \n-[llm.models.\"team-code-large\".costs]\n+[llm.providers.proxy.models.\"team-code-large\".costs]\n input_cost_per_mtok = 1.50\n output_cost_per_mtok = 8.00\n cache_input_cost_per_mtok = 0.30\n \n-[llm.models.\"team-code-large\".costs.speed.fast]\n+[llm.providers.proxy.models.\"team-code-large\".costs.speed.fast]\n input_cost_per_mtok = 3.00\n output_cost_per_mtok = 16.00\n cache_input_cost_per_mtok = 0.60\n ```\n \n+The table key (`team-code-large`) is the model slug that workflows select. `api_id` is an opaque provider-facing wire value. It defaults to the exact model slug when omitted, so configure it only when the provider expects a different string, such as a deployment name, `author/model` slug, or Bedrock inference-profile ID. Fabro does not parse it for provider routing, add prefixes, or otherwise infer meaning from it; an explicitly empty `api_id` is invalid.\n+\n For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Enable it in settings and declare the models your proxy exposes:\n \n ```toml title=\"settings.toml\"\n@@ -106,24 +144,27 @@ For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Ena\n enabled = true\n base_url = \"http://localhost:4000/v1\"\n \n-[llm.models.\"litellm-gpt-5\"]\n-provider = \"litellm\"\n+[llm.providers.litellm.models.\"litellm-gpt-5\"]\n api_id = \"gpt-5\"\n display_name = \"LiteLLM GPT-5\"\n family = \"litellm\"\n default = true\n \n-[llm.models.\"litellm-gpt-5\".limits]\n+[llm.providers.litellm.models.\"litellm-gpt-5\".limits]\n context_window = 128000\n max_output = 8192\n \n-[llm.models.\"litellm-gpt-5\".features]\n+[llm.providers.litellm.models.\"litellm-gpt-5\".features]\n tools = true\n vision = false\n reasoning = false\n ```\n \n-`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.\n+### Reusing aliases across providers\n+\n+Aliases are scoped to a provider. An alias or canonical slug must identify exactly one model within that provider, so two models under `proxy` cannot both claim `team-code`. The same slug or alias may be reused by another provider; that reuse is what makes unqualified selectors portable. Across providers, an exact canonical-slug match takes precedence over an alias match. You can still reach a shadowed alias by pinning its provider.\n+\n+The older `[llm.models.]` form remains readable as a compatibility input, but new configuration and built-in catalog entries should use `[llm.providers..models.]`.\n \n Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.\n \n@@ -169,7 +210,7 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it\n enabled = true\n ```\n \n-Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.models.]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.\n+Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add an explicit `[llm.providers.ollama.models.]` block for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`.\n \n ## Default models\n \n@@ -217,11 +258,12 @@ Model stylesheets set per-node models inside the workflow graph, but you can als\n Pass `--model` and optionally `--provider` to `fabro run`:\n \n ```bash\n-fabro run docs/internal/demo/01-hello.fabro --model claude-opus-4-6\n+fabro run docs/internal/demo/01-hello.fabro --model gpt-56-sol\n+fabro run docs/internal/demo/01-hello.fabro --model gpt-56-sol --provider openrouter\n fabro run docs/internal/demo/04-pipeline.fabro --model gemini-3.1-pro-preview\n ```\n \n-These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. The provider is automatically inferred from the model catalog — you only need `--provider` for models not in the catalog or to force a specific provider.\n+These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. Without `--provider`, the selector is portable across ready offerings and provider priority decides. `--provider` is an explicit pin, including for models not in the catalog.\n \n ### Run config TOML\n \n@@ -247,7 +289,13 @@ Then launch with:\n fabro run run.toml\n ```\n \n-The `fallbacks` array is optional. Each entry may be a bare provider token (like `\"gemini\"`), a bare model alias (like `\"gpt-5.4\"`), or a qualified `\"provider/model\"` reference. Fabro tries them in order when the primary provider is unavailable.\n+The `fallbacks` array is optional. Each entry may be a bare provider token (like `\"gemini\"`), a bare model alias (like `\"gpt-5.4\"`), or a qualified `\"provider/model\"` reference. Fabro resolves each entry to a concrete provider and canonical model, then tries that persisted chain in order after a failover-eligible error. Provider-only entries choose the closest compatible model; qualified model entries stay pinned to their named provider.\n+\n+### Resolution is stable for a run\n+\n+When Fabro creates a run, it resolves every implicit model selector against the ready-provider snapshot and persists the chosen canonical `(provider, model slug)` in the run. Resuming that run uses the materialized choice—it does not re-rank providers because credentials or priorities changed later.\n+\n+This resolve-once behavior makes a run reproducible; the fallback chain is the separate mechanism for handling a provider that fails after creation. A newly created run can choose a different ready offering from the same portable selector.\n \n \n The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win.\ndiff --git a/docs/public/execution/failures.mdx b/docs/public/execution/failures.mdx\nindex 6884d5708..7113ac20a 100644\n--- a/docs/public/execution/failures.mdx\n+++ b/docs/public/execution/failures.mdx\n@@ -121,7 +121,9 @@ provider = \"anthropic\"\n fallbacks = [\"gemini\", \"openai\"]\n ```\n \n-When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `\"gemini\"`), a bare model alias (like `\"gpt-5.4\"`), or a qualified `\"provider/model\"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference.\n+When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `\"gemini\"`), a bare model alias (like `\"gpt-5.4\"`), or a qualified `\"provider/model\"` reference. Provider-only entries select the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. Qualified model entries are provider pins; bare models and aliases select among ready providers by priority.\n+\n+Fabro resolves the primary and fallback selectors to concrete provider/model offerings when it creates the run and persists the result. Resume reuses that materialized chain rather than re-ranking providers after credentials or priorities change. Runtime fallback is the mechanism for a provider failure that happens after creation.\n \n ### What triggers failover\n \ndiff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx\nindex 1b51a9161..20001cd86 100644\n--- a/docs/public/execution/run-configuration.mdx\n+++ b/docs/public/execution/run-configuration.mdx\n@@ -138,11 +138,11 @@ name = \"claude-sonnet-4-5\"\n \n | Field | Description |\n |---|---|\n-| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |\n-| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. |\n-| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`\"openai\"`), bare model aliases, or qualified `\"provider/model\"` references. |\n+| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). |\n+| `provider` | Optional provider pin. When omitted, Fabro selects a matching offering from ready providers by provider priority. When set, lookup is restricted to that provider and unavailability is an error. |\n+| `fallbacks` | Ordered list of model references to try after a failover-eligible error. Entries can be bare provider tokens (`\"openai\"`), bare model aliases, or qualified `\"provider/model\"` references. |\n \n-Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.]`.\n+Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.]`. A qualified fallback such as `\"openrouter/gpt-56-sol\"` is pinned to that provider; a bare alias can select among ready fallback offerings by priority.\n \n #### `[run.model.controls]`\n \n@@ -163,6 +163,12 @@ speed = \"fast\"\n | `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `\"low\"`, `\"medium\"`, `\"high\"`, `\"xhigh\"`, or `\"max\"`. |\n | `speed` | Native speed value to request when the selected model declares it, such as `\"fast\"`. The standard speed is implicit and does not need to be set. |\n \n+#### Resolution and fallback behavior\n+\n+At run creation, Fabro resolves unqualified primary and fallback selectors to concrete provider and canonical-model pairs using the ready-provider snapshot, then persists those choices. Resume reuses the materialized routing and does not reconsider provider priority if credentials or configuration changed. Runtime failover walks the persisted fallback chain; create a new run to reselect from current provider availability.\n+\n+Provider-only fallbacks choose the closest compatible model on that provider. A provider-qualified model or alias resolves only within that provider, while a bare model or alias uses ready providers and priority.\n+\n #### Fallbacks with splice\n \n Use the reserved `\"...\"` marker in `fallbacks` to splice in the inherited list from lower-precedence layers:\ndiff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx\nindex 7f1891f8d..f7acf9fd4 100644\n--- a/docs/public/integrations/litellm.mdx\n+++ b/docs/public/integrations/litellm.mdx\n@@ -24,24 +24,23 @@ _version = 1\n enabled = true\n base_url = \"http://localhost:4000/v1\"\n \n-[llm.models.\"litellm-gpt-5\"]\n-provider = \"litellm\"\n+[llm.providers.litellm.models.\"litellm-gpt-5\"]\n api_id = \"gpt-5\"\n display_name = \"LiteLLM GPT-5\"\n family = \"litellm\"\n default = true\n \n-[llm.models.\"litellm-gpt-5\".limits]\n+[llm.providers.litellm.models.\"litellm-gpt-5\".limits]\n context_window = 128000\n max_output = 8192\n \n-[llm.models.\"litellm-gpt-5\".features]\n+[llm.providers.litellm.models.\"litellm-gpt-5\".features]\n tools = true\n vision = false\n reasoning = false\n ```\n \n-`api_id` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy.\n+`api_id` is the opaque model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. When the provider-facing name is the same as the Fabro model slug, omit `api_id`; it defaults to the slug.\n \n ## Configure credentials\n \ndiff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs\nindex 1458ba0b9..78de24414 100644\n--- a/lib/crates/fabro-config/src/builders.rs\n+++ b/lib/crates/fabro-config/src/builders.rs\n@@ -16,9 +16,9 @@ use crate::resolve::{\n };\n use crate::user::load_settings_config;\n use crate::{\n- CliLayer, Combine, CostRates, EnvironmentLayer, Error, LlmLayer, LlmModelFeatures,\n- LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings, ProviderSettings,\n- Result, RunLayer, ServerLayer, SettingsLayer, run,\n+ CliLayer, Combine, CostRates, EnvironmentLayer, Error, LegacyModelSettings, LlmLayer,\n+ LlmModelFeatures, LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings,\n+ ProviderSettings, Result, RunLayer, ServerLayer, SettingsLayer, run,\n };\n \n #[derive(Debug, Clone, PartialEq, Eq)]\n@@ -321,7 +321,7 @@ fn llm_layer_to_catalog_settings(llm: LlmLayer) -> model_catalog::LlmCatalogSett\n .models\n .into_inner()\n .into_iter()\n- .map(|(id, settings)| (id, model_settings_to_catalog(settings)))\n+ .map(|(id, settings)| (id, legacy_model_settings_to_catalog(settings)))\n .collect(),\n }\n }\n@@ -341,6 +341,12 @@ fn provider_settings_to_catalog(\n .collect()\n });\n model_catalog::ProviderCatalogSettings {\n+ models: settings\n+ .models\n+ .into_inner()\n+ .into_iter()\n+ .map(|(id, settings)| (id, model_settings_to_catalog(settings)))\n+ .collect(),\n display_name: settings.display_name,\n adapter: settings.adapter,\n codec: settings.codec,\n@@ -356,9 +362,17 @@ fn provider_settings_to_catalog(\n }\n }\n \n+fn legacy_model_settings_to_catalog(\n+ settings: LegacyModelSettings,\n+) -> model_catalog::ModelCatalogSettings {\n+ let LegacyModelSettings { provider, model } = settings;\n+ let mut settings = model_settings_to_catalog(model);\n+ settings.provider = provider;\n+ settings\n+}\n+\n fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCatalogSettings {\n let ModelSettings {\n- provider,\n api_id,\n codec,\n billing_policy,\n@@ -379,7 +393,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat\n costs,\n } = settings;\n model_catalog::ModelCatalogSettings {\n- provider,\n+ provider: None,\n api_id,\n codec,\n billing_policy,\n@@ -820,7 +834,7 @@ provider = \"docker\"\n }\n \n #[test]\n- fn server_runtime_settings_preserves_llm_catalog_overrides() {\n+ fn server_runtime_settings_preserves_provider_scoped_llm_catalog_overrides() {\n let settings = server_runtime_settings_from_toml(\n r#\"\n _version = 1\n@@ -837,17 +851,16 @@ agent_profile = \"anthropic\"\n [llm.providers.acme.auth]\n credentials = [\"env:ACME_API_KEY\"]\n \n-[llm.models.\"acme-large\"]\n-provider = \"acme\"\n+[llm.providers.acme.models.\"acme-large\"]\n display_name = \"Acme Large\"\n family = \"acme\"\n default = true\n agent_profile = \"gemini\"\n \n-[llm.models.\"acme-large\".limits]\n+[llm.providers.acme.models.\"acme-large\".limits]\n context_window = 128000\n \n-[llm.models.\"acme-large\".features]\n+[llm.providers.acme.models.\"acme-large\".features]\n tools = true\n vision = false\n reasoning = false\n@@ -857,20 +870,69 @@ reasoning = false\n )\n .expect(\"server runtime settings should resolve\");\n \n- let catalog =\n- fabro_model::Catalog::from_builtin_with_overrides(&settings.llm_catalog_settings)\n- .expect(\"catalog overrides should build\");\n+ let provider = settings\n+ .llm_catalog_settings\n+ .providers\n+ .get(\"acme\")\n+ .expect(\"provider settings should be present\");\n+ let model = provider\n+ .models\n+ .get(\"acme-large\")\n+ .expect(\"provider-scoped model settings should be present\");\n+ assert_eq!(model.display_name.as_deref(), Some(\"Acme Large\"));\n+ assert_eq!(model.agent_profile, Some(fabro_model::AgentProfileKind::Gemini));\n+ assert!(settings.llm_catalog_settings.models.is_empty());\n+ }\n+\n+ #[test]\n+ fn server_runtime_settings_converts_legacy_models_to_provider_catalog_shape() {\n+ let settings = server_runtime_settings_from_toml(\n+ r#\"\n+_version = 1\n+\n+[server.auth]\n+methods = [\"dev-token\"]\n+\n+[llm.models.\"acme-large\"]\n+provider = \"acme\"\n+display_name = \"Acme Large\"\n+\"#,\n+ None,\n+ None,\n+ )\n+ .expect(\"legacy catalog settings should resolve\");\n \n assert_eq!(\n- catalog\n- .get(\"acme-large\")\n- .map(|model| model.provider.clone()),\n- Some(fabro_model::ProviderId::new(\"acme\"))\n+ settings.llm_catalog_settings.providers[\"acme\"].models[\"acme-large\"]\n+ .display_name\n+ .as_deref(),\n+ Some(\"Acme Large\")\n );\n+ assert!(settings.llm_catalog_settings.models.is_empty());\n+ }\n+\n+ #[test]\n+ fn server_runtime_settings_retains_providerless_legacy_models_for_catalog_adoption() {\n+ let settings = server_runtime_settings_from_toml(\n+ r#\"\n+_version = 1\n+\n+[server.auth]\n+methods = [\"dev-token\"]\n+\n+[llm.models.\"known-model\"]\n+display_name = \"Renamed Known Model\"\n+\"#,\n+ None,\n+ None,\n+ )\n+ .expect(\"provider-less legacy catalog settings should resolve\");\n+\n assert_eq!(\n- catalog\n- .effective_agent_profile(&fabro_model::ProviderId::new(\"acme\"), Some(\"acme-large\")),\n- Some(fabro_model::AgentProfileKind::Gemini)\n+ settings.llm_catalog_settings.models[\"known-model\"]\n+ .display_name\n+ .as_deref(),\n+ Some(\"Renamed Known Model\")\n );\n }\n \ndiff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs\nindex 5fb8a0a64..00fc79e35 100644\n--- a/lib/crates/fabro-config/src/layers/llm.rs\n+++ b/lib/crates/fabro-config/src/layers/llm.rs\n@@ -12,11 +12,15 @@\n //! enabled = true\n //! aliases = [\"moonshot\"]\n //!\n-//! [llm.models.\"kimi-k2.5\"]\n-//! provider = \"kimi\"\n+//! [llm.providers.kimi.models.\"kimi-k2.5\"]\n //! ...\n //! ```\n //!\n+//! Legacy top-level `[llm.models.]` rows remain accepted by the settings\n+//! parser. Rows with a `provider` are normalized into the canonical provider\n+//! scope before layers combine; provider-less rows are retained for\n+//! catalog-aware compatibility handling.\n+//!\n //! Per-provider and per-model entries field-merge across layers (default →\n //! user → server → project → workflow/run). Inner arrays such as\n //! `auth.credentials`, `aliases`, `controls.reasoning_effort`, and\n@@ -43,15 +47,22 @@ pub struct LlmLayer {\n /// Provider definitions keyed by provider ID.\n #[serde(default, skip_serializing_if = \"MergeMap::is_empty\")]\n pub providers: MergeMap,\n- /// Model definitions keyed by canonical model ID.\n+ /// Legacy top-level model definitions keyed by canonical model ID.\n+ ///\n+ /// Provider-qualified rows are moved into [`ProviderSettings::models`] by\n+ /// the settings parser. Rows without a provider remain here until the\n+ /// built-in catalog can adopt them unambiguously.\n #[serde(default, skip_serializing_if = \"MergeMap::is_empty\")]\n- pub models: MergeMap,\n+ pub models: MergeMap,\n }\n \n /// One entry in `[llm.providers.]`.\n #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]\n #[serde(deny_unknown_fields)]\n pub struct ProviderSettings {\n+ /// Model definitions owned by this provider, keyed by canonical model ID.\n+ #[serde(default, skip_serializing_if = \"MergeMap::is_empty\")]\n+ pub models: MergeMap,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub display_name: Option,\n /// Adapter registry key (e.g. `\"openai_compatible\"`).\n@@ -89,13 +100,10 @@ pub struct ProviderSettings {\n pub aliases: Option>,\n }\n \n-/// One entry in `[llm.models.]`.\n+/// One entry in `[llm.providers..models.]`.\n #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]\n #[serde(deny_unknown_fields)]\n pub struct ModelSettings {\n- /// Provider ID this model belongs to.\n- #[serde(default, skip_serializing_if = \"Option::is_none\")]\n- pub provider: Option,\n /// Identifier sent to the provider API. Defaults to the catalog model ID\n /// when omitted.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n@@ -155,6 +163,38 @@ pub struct ModelSettings {\n pub costs: Option,\n }\n \n+/// Input-only compatibility row for the legacy `[llm.models.]` shape.\n+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]\n+#[serde(deny_unknown_fields)]\n+pub struct LegacyModelSettings {\n+ /// Provider ID used to move this row into the canonical provider scope.\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub provider: Option,\n+ #[serde(flatten)]\n+ pub model: ModelSettings,\n+}\n+\n+impl LegacyModelSettings {\n+ #[must_use]\n+ pub(crate) fn into_model(self) -> ModelSettings {\n+ self.model\n+ }\n+}\n+\n+impl std::ops::Deref for LegacyModelSettings {\n+ type Target = ModelSettings;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.model\n+ }\n+}\n+\n+impl std::ops::DerefMut for LegacyModelSettings {\n+ fn deref_mut(&mut self) -> &mut Self::Target {\n+ &mut self.model\n+ }\n+}\n+\n #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]\n #[serde(deny_unknown_fields)]\n pub struct ModelLimits {\ndiff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs\nindex c3fa1632c..aae4c99a6 100644\n--- a/lib/crates/fabro-config/src/layers/mod.rs\n+++ b/lib/crates/fabro-config/src/layers/mod.rs\n@@ -21,8 +21,8 @@ pub use environment::{\n EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer,\n };\n pub use llm::{\n- CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable,\n- ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings,\n+ CostRates, CredentialRef, CredentialRefParseError, LegacyModelSettings, LlmLayer, ModelControls,\n+ ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings,\n ProviderSettings, ReasoningEffortFeature,\n };\n pub use log_filter::LogFilter;\ndiff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs\nindex f6097b9be..fd7d48608 100644\n--- a/lib/crates/fabro-config/src/lib.rs\n+++ b/lib/crates/fabro-config/src/lib.rs\n@@ -46,8 +46,9 @@ pub use layers::{\n CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,\n EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer,\n GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,\n- InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter,\n- McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,\n+ InterviewProviderLayer, InterviewsLayer, LegacyModelSettings, LlmLayer, LlmModelFeatures,\n+ LlmModelLimits, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable,\n+ ModelRefOrSplice, ModelSettings,\n NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,\n PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,\n RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer,\ndiff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs\nindex b4e008791..35b7f4709 100644\n--- a/lib/crates/fabro-config/src/parse.rs\n+++ b/lib/crates/fabro-config/src/parse.rs\n@@ -39,6 +39,13 @@ pub enum ParseError {\n path: String,\n source: SettingsSource,\n },\n+ ConflictingLlmModelDefinitions {\n+ provider: String,\n+ model: String,\n+ },\n+ InvalidLegacyLlmModelProvider {\n+ model: String,\n+ },\n }\n \n impl fmt::Display for ParseError {\n@@ -60,6 +67,14 @@ impl fmt::Display for ParseError {\n f,\n \"`{path}` is server-managed and cannot be set in {source} settings; configure cwd on a server-managed environment instead.\"\n ),\n+ Self::ConflictingLlmModelDefinitions { provider, model } => write!(\n+ f,\n+ \"model `{model}` on provider `{provider}` is defined in both `llm.models.{model}` and `llm.providers.{provider}.models.{model}` in the same settings source\"\n+ ),\n+ Self::InvalidLegacyLlmModelProvider { model } => write!(\n+ f,\n+ \"legacy model `llm.models.{model}` has an empty provider; omit it for catalog-aware adoption or set a non-empty provider ID\"\n+ ),\n }\n }\n }\n@@ -118,8 +133,36 @@ pub(crate) fn parse_settings(input: &str) -> Result {\n }\n }\n \n- raw.try_into::()\n- .map_err(|e| ParseError::Toml(e.to_string()))\n+ let mut layer = raw\n+ .try_into::()\n+ .map_err(|e| ParseError::Toml(e.to_string()))?;\n+ normalize_legacy_llm_models(&mut layer)?;\n+ Ok(layer)\n+}\n+\n+fn normalize_legacy_llm_models(layer: &mut SettingsLayer) -> Result<(), ParseError> {\n+ let Some(llm) = layer.llm.as_mut() else {\n+ return Ok(());\n+ };\n+\n+ let legacy_models = std::mem::take(&mut llm.models).into_inner();\n+ for (model, legacy) in legacy_models {\n+ let Some(provider) = legacy.provider.as_deref() else {\n+ llm.models.insert(model, legacy);\n+ continue;\n+ };\n+ if provider.is_empty() {\n+ return Err(ParseError::InvalidLegacyLlmModelProvider { model });\n+ }\n+\n+ let provider = provider.to_string();\n+ let provider_settings = llm.providers.entry(provider.clone()).or_default();\n+ if provider_settings.models.contains_key(&model) {\n+ return Err(ParseError::ConflictingLlmModelDefinitions { provider, model });\n+ }\n+ provider_settings.models.insert(model, legacy.into_model());\n+ }\n+ Ok(())\n }\n \n #[derive(Debug, Clone, Copy, PartialEq, Eq)]\n@@ -289,11 +332,91 @@ mod tests {\n }\n \n #[test]\n- fn accepts_new_llm_models_subtree() {\n- let parsed = \"[llm.models.\\\"foo\\\"]\\nprovider = \\\"kimi\\\"\\n\"\n+ fn accepts_provider_scoped_models_subtree() {\n+ let parsed = \"[llm.providers.kimi.models.\\\"foo\\\"]\\ndisplay_name = \\\"Foo\\\"\\n\"\n .parse::()\n- .unwrap();\n- assert!(parsed.llm.unwrap().models.contains_key(\"foo\"));\n+ .expect(\"provider-scoped model should parse\");\n+ let llm = parsed.llm.expect(\"llm layer should be present\");\n+\n+ assert_eq!(\n+ llm.providers[\"kimi\"].models[\"foo\"].display_name.as_deref(),\n+ Some(\"Foo\")\n+ );\n+ assert!(llm.models.is_empty());\n+ }\n+\n+ #[test]\n+ fn normalizes_legacy_llm_model_with_provider_into_provider_scope() {\n+ let parsed = r#\"\n+[llm.models.foo]\n+provider = \"kimi\"\n+display_name = \"Foo\"\n+\"#\n+ .parse::()\n+ .expect(\"legacy model should parse\");\n+ let llm = parsed.llm.expect(\"llm layer should be present\");\n+\n+ assert_eq!(\n+ llm.providers[\"kimi\"].models[\"foo\"].display_name.as_deref(),\n+ Some(\"Foo\")\n+ );\n+ assert!(llm.models.is_empty());\n+ }\n+\n+ #[test]\n+ fn retains_providerless_legacy_llm_model_for_catalog_aware_adoption() {\n+ let parsed = r#\"\n+[llm.models.foo]\n+display_name = \"Renamed Foo\"\n+\"#\n+ .parse::()\n+ .expect(\"provider-less legacy model should remain compatible\");\n+ let llm = parsed.llm.expect(\"llm layer should be present\");\n+\n+ assert_eq!(\n+ llm.models[\"foo\"].display_name.as_deref(),\n+ Some(\"Renamed Foo\")\n+ );\n+ assert!(llm.providers.is_empty());\n+ }\n+\n+ #[test]\n+ fn rejects_same_source_legacy_and_provider_scoped_model_pair() {\n+ let error = r#\"\n+[llm.providers.kimi.models.foo]\n+display_name = \"Canonical Foo\"\n+\n+[llm.models.foo]\n+provider = \"kimi\"\n+display_name = \"Legacy Foo\"\n+\"#\n+ .parse::()\n+ .expect_err(\"same pair in both syntaxes should be rejected\");\n+\n+ assert_eq!(\n+ error,\n+ ParseError::ConflictingLlmModelDefinitions {\n+ provider: \"kimi\".to_string(),\n+ model: \"foo\".to_string(),\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn rejects_empty_legacy_llm_model_provider_with_typed_error() {\n+ let error = r#\"\n+[llm.models.foo]\n+provider = \"\"\n+\"#\n+ .parse::()\n+ .expect_err(\"empty legacy provider should be rejected\");\n+\n+ assert_eq!(\n+ error,\n+ ParseError::InvalidLegacyLlmModelProvider {\n+ model: \"foo\".to_string(),\n+ }\n+ );\n }\n \n #[test]\ndiff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs\nindex ff95a8aec..2a6c487e7 100644\n--- a/lib/crates/fabro-config/src/tests/combine.rs\n+++ b/lib/crates/fabro-config/src/tests/combine.rs\n@@ -318,3 +318,117 @@ bucket = \"higher-bucket\"\n assert_eq!(s3.bucket, Some(\"higher-bucket\".to_string()));\n assert_eq!(s3.region, None);\n }\n+\n+#[test]\n+fn provider_and_model_rows_field_merge_independently() {\n+ let lower = parse(\n+ r#\"\n+[llm.providers.acme]\n+display_name = \"Acme\"\n+adapter = \"openai_compatible\"\n+base_url = \"https://lower.example/v1\"\n+\n+[llm.providers.acme.models.large]\n+display_name = \"Acme Large\"\n+family = \"acme\"\n+\n+[llm.providers.acme.models.large.limits]\n+context_window = 128000\n+max_output = 32000\n+\"#,\n+ );\n+ let higher = parse(\n+ r#\"\n+[llm.providers.acme]\n+base_url = \"https://higher.example/v1\"\n+\n+[llm.providers.acme.models.large]\n+display_name = \"Acme Large v2\"\n+\n+[llm.providers.acme.models.large.limits]\n+max_output = 64000\n+\"#,\n+ );\n+\n+ let merged = higher.combine(lower);\n+ let acme = &merged.llm.expect(\"llm layer should be present\").providers[\"acme\"];\n+ assert_eq!(acme.display_name.as_deref(), Some(\"Acme\"));\n+ assert_eq!(acme.adapter.as_deref(), Some(\"openai_compatible\"));\n+ assert_eq!(acme.base_url.as_deref(), Some(\"https://higher.example/v1\"));\n+\n+ let model = &acme.models[\"large\"];\n+ assert_eq!(model.display_name.as_deref(), Some(\"Acme Large v2\"));\n+ assert_eq!(model.family.as_deref(), Some(\"acme\"));\n+ assert_eq!(\n+ model.limits.as_ref().and_then(|limits| limits.context_window),\n+ Some(128_000)\n+ );\n+ assert_eq!(\n+ model.limits.as_ref().and_then(|limits| limits.max_output),\n+ Some(64_000)\n+ );\n+}\n+\n+#[test]\n+fn legacy_model_is_normalized_before_cross_source_combine() {\n+ let lower = parse(\n+ r#\"\n+[llm.models.large]\n+provider = \"acme\"\n+family = \"acme\"\n+\n+[llm.models.large.limits]\n+context_window = 128000\n+\"#,\n+ );\n+ let higher = parse(\n+ r#\"\n+[llm.providers.acme.models.large]\n+display_name = \"Acme Large\"\n+\n+[llm.providers.acme.models.large.limits]\n+max_output = 64000\n+\"#,\n+ );\n+\n+ let merged = higher.combine(lower);\n+ let llm = merged.llm.expect(\"llm layer should be present\");\n+ let model = &llm.providers[\"acme\"].models[\"large\"];\n+\n+ assert_eq!(model.display_name.as_deref(), Some(\"Acme Large\"));\n+ assert_eq!(model.family.as_deref(), Some(\"acme\"));\n+ assert_eq!(\n+ model.limits.as_ref().and_then(|limits| limits.context_window),\n+ Some(128_000)\n+ );\n+ assert_eq!(\n+ model.limits.as_ref().and_then(|limits| limits.max_output),\n+ Some(64_000)\n+ );\n+ assert!(llm.models.is_empty());\n+}\n+\n+#[test]\n+fn same_model_id_on_different_providers_stays_independent() {\n+ let merged = parse(\n+ r#\"\n+[llm.providers.openai.models.shared]\n+api_id = \"shared\"\n+\n+[llm.providers.openrouter.models.shared]\n+api_id = \"openai/shared\"\n+\"#,\n+ );\n+ let llm = merged.llm.expect(\"llm layer should be present\");\n+\n+ assert_eq!(\n+ llm.providers[\"openai\"].models[\"shared\"].api_id.as_deref(),\n+ Some(\"shared\")\n+ );\n+ assert_eq!(\n+ llm.providers[\"openrouter\"].models[\"shared\"]\n+ .api_id\n+ .as_deref(),\n+ Some(\"openai/shared\")\n+ );\n+}\ndiff --git a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs\nindex aacc08575..a446d048a 100644\n--- a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs\n+++ b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs\n@@ -247,19 +247,20 @@ x-team-secret = \"{{ secrets.gateway_team_secret }}\"\n | `auth.credentials` | array | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |\n | `auth.header` | `\"bearer\"` or `{ custom = \"Header-Name\" }` | `\"bearer\"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |\n | `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. |\n-| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |\n+| `priority` | integer | `0` | Higher-priority ready providers win unqualified model selection; ties use canonical provider ID in ascending order. |\n | `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |\n | `aliases` | array | `[]` | Additional provider names accepted by model routing and fallback config. |\n \n-## `[llm.models.]`\n+## `[llm.providers..models.]`\n \n-Define or override a model in the catalog. The table key is the canonical\n-model ID Fabro users reference; `api_id` is the model string sent to the\n-provider API.\n+Define or override one provider-specific model offering. The containing table\n+supplies the provider ID, and `` is the canonical, human-facing model\n+slug used by workflows. The stable identity of an offering is the pair\n+`(provider, model)`; the same model slug and aliases may be reused by other\n+providers.\n \n ```toml title=\"settings.toml\"\n-[llm.models.\"team-code-large\"]\n-provider = \"proxy\"\n+[llm.providers.proxy.models.\"team-code-large\"]\n api_id = \"provider-wire-model-name\"\n agent_profile = \"anthropic\"\n display_name = \"Team Code Large\"\n@@ -270,56 +271,66 @@ enabled = true\n aliases = [\"team-code\"]\n estimated_output_tps = 80\n \n-[llm.models.\"team-code-large\".limits]\n+[llm.providers.proxy.models.\"team-code-large\".limits]\n context_window = 200000\n max_output = 32000\n \n-[llm.models.\"team-code-large\".features]\n+[llm.providers.proxy.models.\"team-code-large\".features]\n tools = true\n vision = false\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[llm.models.\"team-code-large\".controls]\n+[llm.providers.proxy.models.\"team-code-large\".controls]\n reasoning_effort = [\"low\", \"medium\", \"high\"]\n speed = [\"fast\"]\n \n-[llm.models.\"team-code-large\".costs]\n+[llm.providers.proxy.models.\"team-code-large\".costs]\n input_cost_per_mtok = 1.50\n output_cost_per_mtok = 8.00\n cache_input_cost_per_mtok = 0.30\n \n-[llm.models.\"team-code-large\".costs.speed.fast]\n+[llm.providers.proxy.models.\"team-code-large\".costs.speed.fast]\n input_cost_per_mtok = 3.00\n output_cost_per_mtok = 16.00\n cache_input_cost_per_mtok = 0.60\n ```\n \n+`api_id` is an opaque provider wire identifier, not a workflow selector or a\n+routing namespace. When omitted, it defaults to the exact canonical model\n+slug. Set it only when the provider expects a different value; an explicitly\n+empty value is invalid.\n+\n+Unqualified model selectors consider ready providers and then choose the\n+highest provider `priority`, with canonical provider ID as the deterministic\n+tie-breaker. Supplying a provider pins lookup to that provider. An alias must\n+identify only one model within a provider, but reusing it on another provider\n+is valid and enables portable workflow selectors.\n+\n | Key | Type / values | Default | Description |\n |---|---|---|---|\n-| `provider` | string | None | Provider ID this model belongs to. |\n-| `api_id` | string | model ID | Identifier sent to the provider API. |\n+| `api_id` | string | canonical model slug | Opaque identifier sent to this offering's provider API. It is not parsed for routing. |\n | `agent_profile` | `\"anthropic\"` \\| `\"openai\"` \\| `\"gemini\"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. |\n | `billing_policy` | `\"openai\"` \\| `\"anthropic\"` \\| `\"gemini\"` \\| `\"none\"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). |\n-| `display_name` | string | model ID | Human-readable model name. |\n-| `family` | string | model ID | Family label used for catalog display and matching. |\n+| `display_name` | string | model slug | Human-readable model name. |\n+| `family` | string | model slug | Family metadata used for catalog display and matching; it is not a routing namespace. |\n | `training` | string | None | Training data cutoff label. |\n | `knowledge_cutoff` | string or TOML date | None | Public knowledge cutoff label; TOML dates normalize to `YYYY-MM-DD`. |\n | `default` | boolean | `false` | Whether this is the provider default model. |\n | `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. |\n | `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |\n-| `aliases` | array | `[]` | Additional model names accepted by routing and fallback config. |\n+| `aliases` | array | `[]` | Additional user-facing selectors. Each selector must be unique within this provider but may be reused by other providers. |\n | `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |\n \n-## `[llm.models..limits]`\n+## `[llm.providers..models..limits]`\n \n | Key | Type / values | Default | Description |\n |---|---|---|---|\n | `context_window` | integer | None | Maximum context window size in tokens. |\n | `max_output` | integer | None | Maximum output tokens, if known. |\n \n-## `[llm.models..features]`\n+## `[llm.providers..models..features]`\n \n | Key | Type / values | Default | Description |\n |---|---|---|---|\n@@ -330,14 +341,14 @@ cache_input_cost_per_mtok = 0.60\n | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |\n | `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |\n \n-## `[llm.models..controls]`\n+## `[llm.providers..models..controls]`\n \n | Key | Type / values | Default | Description |\n |---|---|---|---|\n | `reasoning_effort` | array | all standard levels when feature is `\"levels\"` or `\"always_adaptive\"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |\n | `speed` | array | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |\n \n-## `[llm.models..costs]`\n+## `[llm.providers..models..costs]`\n \n | Key | Type / values | Default | Description |\n |---|---|---|---|\n@@ -345,11 +356,12 @@ cache_input_cost_per_mtok = 0.60\n | `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. |\n | `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. |\n \n-## `[llm.models..costs.speed.]`\n+## `[llm.providers..models..costs.speed.]`\n \n-Per-speed cost overrides use the same keys as `[llm.models..costs]`.\n-Each `` key must be declared in `[llm.models..controls].speed`.\n-The `standard` speed is implicit and always uses the base cost table.\n+Per-speed cost overrides use the same keys as\n+`[llm.providers..models..costs]`. Each `` key must be\n+declared in `[llm.providers..models..controls].speed`. The\n+`standard` speed is implicit and always uses the base cost table.\n \n \"#,\n );\n@@ -389,3 +401,21 @@ See [MCP](/agents/mcp) for transport-specific examples.\n fn normalize_doc(doc: &str) -> String {\n doc.trim().trim_end_matches('.').to_string()\n }\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn llm_catalog_reference_teaches_provider_scoped_portable_models() {\n+ let reference = render_options_reference();\n+\n+ assert!(reference.contains(\"## `[llm.providers..models.]`\"));\n+ assert!(reference.contains(\"[llm.providers.proxy.models.\\\"team-code-large\\\"]\"));\n+ assert!(reference.contains(\"defaults to the exact canonical model\\nslug\"));\n+ assert!(reference.contains(\"Supplying a provider pins lookup to that provider\"));\n+ assert!(reference.contains(\"reusing it on another provider\\nis valid\"));\n+ assert!(reference.contains(\"opaque provider wire identifier\"));\n+ assert!(!reference.contains(\"## `[llm.models.]`\"));\n+ }\n+}\ndiff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs\nindex c72e9ea50..7fe5011e2 100644\n--- a/lib/crates/fabro-llm/src/client.rs\n+++ b/lib/crates/fabro-llm/src/client.rs\n@@ -597,6 +597,7 @@ fn format_additional_speeds(values: &[Speed]) -> String {\n \n #[cfg(test)]\n mod tests {\n+ use std::sync::Mutex;\n use std::sync::atomic::{AtomicUsize, Ordering};\n \n use async_trait::async_trait;\ndiff --git a/lib/crates/fabro-llm/src/cost.rs b/lib/crates/fabro-llm/src/cost.rs\nindex e03ead5de..00371919c 100644\n--- a/lib/crates/fabro-llm/src/cost.rs\n+++ b/lib/crates/fabro-llm/src/cost.rs\n@@ -25,11 +25,11 @@ pub(crate) fn estimate_cost_usd(\n let catalog = catalog?;\n // The billing machinery compares ModelRefs against the catalog's\n // canonical identity, so resolve model aliases and provider names first.\n- let model = catalog.get(model)?;\n let provider = catalog.provider(&ProviderId::new(provider))?;\n+ let model = catalog.model_on_provider(&provider.id, model)?;\n let model_ref = ModelRef {\n provider: provider.id.clone(),\n- model_id: model.id.clone(),\n+ model_id: model.id.to_string(),\n speed,\n };\n let micros = catalog.price_tokens(&model_ref, tokens)?;\ndiff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs\nindex 47bca353c..09dc1e632 100644\n--- a/lib/crates/fabro-llm/src/model_test.rs\n+++ b/lib/crates/fabro-llm/src/model_test.rs\n@@ -132,7 +132,7 @@ fn build_deep_test_params(info: &Model, client: Arc) -> Option) -> ModelRef {\n ModelRef {\n provider: self.provider.clone(),\n- model_id: self.id.clone(),\n+ model_id: self.id.to_string(),\n speed,\n }\n }\n@@ -544,7 +544,7 @@ fn pricing_for_model_costs(\n Some(ModelPricing {\n model: ModelRef {\n provider: provider_id,\n- model_id: model.id.clone(),\n+ model_id: model.id.to_string(),\n speed,\n },\n policy,\ndiff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs\nindex 9ae07fc8b..0cc3fa0a8 100644\n--- a/lib/crates/fabro-model/src/catalog.rs\n+++ b/lib/crates/fabro-model/src/catalog.rs\n@@ -12,7 +12,7 @@ use tracing::warn;\n use crate::Speed;\n use crate::adapter::{AdapterKind, AgentProfileKind};\n use crate::codec::CodecKind;\n-use crate::ids::ProviderId;\n+use crate::ids::{ModelId, ProviderId};\n use crate::provider::Provider;\n use crate::reasoning::ReasoningEffort;\n use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature};\n@@ -39,6 +39,9 @@ pub struct LlmCatalogSettings {\n #[derive(Debug, Clone, Default, PartialEq, Deserialize)]\n #[serde(deny_unknown_fields)]\n pub struct ProviderCatalogSettings {\n+ /// Provider-scoped model rows keyed by canonical human-facing model slug.\n+ #[serde(default)]\n+ pub models: HashMap,\n #[serde(default)]\n pub display_name: Option,\n #[serde(default)]\n@@ -382,6 +385,16 @@ static GLOBAL_CATALOG: LazyLock = LazyLock::new(|| {\n Catalog::from_builtin_toml().expect(\"embedded provider TOML files must build a valid catalog\")\n });\n \n+/// A built-in model identifier that was replaced by a provider-scoped model\n+/// slug. Retired identifiers are errors rather than aliases: silently accepting\n+/// one could route a persisted reference to a different provider.\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub struct RetiredModelIdentifier {\n+ pub identifier: String,\n+ pub provider: ProviderId,\n+ pub model: ModelId,\n+}\n+\n /// A resolved fallback target: provider name + model ID.\n #[derive(Debug, Clone, PartialEq, Eq)]\n pub struct FallbackTarget {\n@@ -528,12 +541,24 @@ pub enum CatalogBuildError {\n model: String,\n provider: ProviderId,\n },\n- #[error(\"model identifier '{identifier}' is declared by both '{first}' and '{second}'\")]\n- DuplicateModelIdentifier {\n+ #[error(\n+ \"provider '{provider}' model identifier '{identifier}' is declared by both '{first}' and '{second}'\"\n+ )]\n+ DuplicateProviderModelIdentifier {\n+ provider: ProviderId,\n identifier: String,\n- first: String,\n- second: String,\n+ first: ModelId,\n+ second: ModelId,\n+ },\n+ #[error(\"provider '{provider}' model '{model}' configures an empty api_id\")]\n+ EmptyModelApiId {\n+ provider: ProviderId,\n+ model: ModelId,\n },\n+ #[error(\n+ \"legacy model row '{model}' does not name a provider and does not uniquely match a built-in offering\"\n+ )]\n+ AmbiguousLegacyModelProvider { model: String },\n #[error(\"provider '{provider}' has multiple default models: {models:?}\")]\n MultipleProviderDefaults {\n provider: ProviderId,\n@@ -574,17 +599,44 @@ pub enum CatalogBuildError {\n UndeclaredSpeedCost { model: String, speed: Speed },\n }\n \n+/// Failure to select one offering for a user-facing model selector.\n+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]\n+pub enum ModelSelectionError {\n+ #[error(\n+ \"model identifier '{identifier}' was retired; use provider '{provider}' with model '{model}'\"\n+ )]\n+ RetiredIdentifier {\n+ identifier: String,\n+ provider: ProviderId,\n+ model: ModelId,\n+ },\n+ #[error(\"unknown model selector '{selector}'\")]\n+ UnknownSelector { selector: String },\n+ #[error(\"model selector '{selector}' has no offering on an eligible provider\")]\n+ NoEligibleOffering { selector: String },\n+ #[error(\"provider '{provider}' is not available\")]\n+ UnavailableProvider { provider: ProviderId },\n+ #[error(\"provider '{provider}' has no model matching selector '{selector}'\")]\n+ UnknownSelectorOnProvider {\n+ provider: ProviderId,\n+ selector: String,\n+ },\n+}\n+\n /// Typed model catalog backed by a `Vec`.\n ///\n /// Use [`Catalog::builtin()`] for the embedded settings-backed catalog.\n #[derive(Debug)]\n pub struct Catalog {\n- models: Vec,\n- providers: Vec,\n- model_settings: HashMap,\n- model_index: HashMap,\n- provider_aliases: HashMap,\n- provider_index: HashMap,\n+ models: Vec,\n+ providers: Vec,\n+ model_settings: HashMap<(ProviderId, ModelId), CatalogModelSettings>,\n+ offering_index: HashMap<(ProviderId, ModelId), usize>,\n+ canonical_candidates: HashMap>,\n+ alias_candidates: HashMap>,\n+ provider_aliases: HashMap,\n+ provider_index: HashMap,\n+ retired_identifiers: HashMap,\n }\n \n impl Catalog {\n@@ -617,27 +669,25 @@ impl Catalog {\n .collect();\n \n let mut models_with_settings = Vec::new();\n- let mut model_identifiers = BTreeMap::::new();\n+ let mut model_identifiers = HashMap::>::new();\n let mut defaults_by_provider = HashMap::>::new();\n let mut small_defaults_by_provider = HashMap::>::new();\n \n- let mut model_ids = settings.models.keys().cloned().collect::>();\n- model_ids.sort_unstable();\n- for model_id in model_ids {\n- let model_settings = settings\n- .models\n- .get(&model_id)\n- .expect(\"model ID came from settings map keys\");\n+ let normalized_models = normalized_model_settings(settings)?;\n+ let mut model_keys = normalized_models.keys().cloned().collect::>();\n+ model_keys.sort_unstable();\n+ for (provider_id, model_id) in model_keys {\n+ let model_settings = normalized_models\n+ .get(&(provider_id.clone(), model_id.clone()))\n+ .expect(\"model key came from normalized settings\");\n if model_settings.enabled == Some(false) {\n continue;\n }\n \n- let provider_id =\n- required_model_string(&model_id, model_settings.provider.as_ref(), \"provider\")?;\n if !known_providers.contains(provider_id.as_str()) {\n return Err(CatalogBuildError::UnknownModelProvider {\n- model: model_id,\n- provider: ProviderId::from(provider_id),\n+ model: model_id,\n+ provider: provider_id,\n });\n }\n if !enabled_providers.contains(provider_id.as_str()) {\n@@ -649,22 +699,33 @@ impl Catalog {\n .expect(\"enabled provider ID should have provider metadata\");\n let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?;\n \n- register_model_identifier(&mut model_identifiers, model.id.clone(), model.id.clone())?;\n+ let provider_identifiers = model_identifiers.entry(provider_id.clone()).or_default();\n+ register_model_identifier(\n+ provider_identifiers,\n+ model.id.as_str().to_string(),\n+ model.id.clone(),\n+ &provider_id,\n+ )?;\n for alias in &model.aliases {\n- register_model_identifier(&mut model_identifiers, alias.clone(), model.id.clone())?;\n+ register_model_identifier(\n+ provider_identifiers,\n+ alias.clone(),\n+ model.id.clone(),\n+ &provider_id,\n+ )?;\n }\n \n if model.default {\n defaults_by_provider\n .entry(model.provider.clone())\n .or_default()\n- .push(model.id.clone());\n+ .push(model.id.to_string());\n }\n if model.small_default {\n small_defaults_by_provider\n .entry(model.provider.clone())\n .or_default()\n- .push(model.id.clone());\n+ .push(model.id.to_string());\n }\n models_with_settings.push((model, resolved_settings));\n }\n@@ -691,21 +752,25 @@ impl Catalog {\n \n models_with_settings.sort_by(|(left, _), (right, _)| model_order(left, right));\n warn_multiple_probe_models(&models_with_settings);\n- let mut model_settings_by_id = HashMap::new();\n+ let mut model_settings = HashMap::new();\n let mut models = Vec::new();\n for (model, settings) in models_with_settings {\n- model_settings_by_id.insert(model.id.clone(), settings);\n+ model_settings.insert((model.provider.clone(), model.id.clone()), settings);\n models.push(model);\n }\n- let model_index = build_model_index(&models);\n+ let (offering_index, canonical_candidates, alias_candidates) =\n+ build_model_indexes(&models);\n \n Ok(Self {\n models,\n providers,\n- model_settings: model_settings_by_id,\n- model_index,\n+ model_settings,\n+ offering_index,\n+ canonical_candidates,\n+ alias_candidates,\n provider_aliases,\n provider_index,\n+ retired_identifiers: HashMap::new(),\n })\n }\n \n@@ -713,8 +778,11 @@ impl Catalog {\n overrides: &LlmCatalogSettings,\n ) -> Result {\n let builtins = Self::builtin_settings()?;\n- let settings = merge_catalog_settings(overrides.clone(), builtins);\n- Self::from_settings(&settings)\n+ let overrides = adopt_legacy_models(overrides.clone(), &builtins)?;\n+ let settings = merge_catalog_settings(overrides, builtins);\n+ let mut catalog = Self::from_settings(&settings)?;\n+ catalog.retired_identifiers = builtin_retired_identifiers();\n+ Ok(catalog)\n }\n \n /// Builds a fresh catalog from embedded provider TOML without user\n@@ -749,7 +817,13 @@ impl Catalog {\n source,\n })?;\n validate_builtin_fragment(&path, &fragment)?;\n- layer.providers.extend(fragment.providers);\n+ for (id, provider) in fragment.providers {\n+ let provider = match layer.providers.remove(&id) {\n+ Some(existing) => merge_provider_settings(provider, existing),\n+ None => provider,\n+ };\n+ layer.providers.insert(id, provider);\n+ }\n layer.models.extend(fragment.models);\n }\n \n@@ -757,17 +831,116 @@ impl Catalog {\n }\n \n fn from_builtin_toml() -> Result {\n- Self::from_settings(&Self::builtin_settings()?)\n+ let mut catalog = Self::from_settings(&Self::builtin_settings()?)?;\n+ catalog.retired_identifiers = builtin_retired_identifiers();\n+ Ok(catalog)\n }\n \n- /// Look up a model by ID or alias.\n+ /// Look up a selector across all enabled providers using catalog provider\n+ /// priority. Runtime callers should prefer [`Self::select_model`] and pass\n+ /// their ready-provider set explicitly.\n #[must_use]\n- pub fn get(&self, id: &str) -> Option<&Model> {\n- self.model_index\n- .get(id)\n+ pub fn get(&self, selector: &str) -> Option<&Model> {\n+ self.select_candidates(selector)\n+ .and_then(|candidates| candidates.first())\n .and_then(|idx| self.models.get(*idx))\n }\n \n+ /// Resolve a canonical model ID or alias only on the named provider.\n+ #[must_use]\n+ pub fn model_on_provider(\n+ &self,\n+ provider_id: &ProviderId,\n+ selector: &str,\n+ ) -> Option<&Model> {\n+ let provider = self.provider(provider_id)?;\n+ if let Some(idx) = self\n+ .offering_index\n+ .get(&(provider.id.clone(), ModelId::new(selector)))\n+ {\n+ return self.models.get(*idx);\n+ }\n+ self.alias_candidates\n+ .get(selector)?\n+ .iter()\n+ .filter_map(|idx| self.models.get(*idx))\n+ .find(|model| model.provider == provider.id)\n+ }\n+\n+ /// Return the replacement address for a retired built-in identifier.\n+ #[must_use]\n+ pub fn retired_identifier(&self, identifier: &str) -> Option {\n+ let (provider, model) = self.retired_identifiers.get(identifier)?;\n+ Some(RetiredModelIdentifier {\n+ identifier: identifier.to_string(),\n+ provider: provider.clone(),\n+ model: model.clone(),\n+ })\n+ }\n+\n+ /// Select one offering for a selector from caller-supplied eligible\n+ /// providers. Canonical ID candidates are considered before aliases;\n+ /// candidates are pre-sorted by provider priority descending and canonical\n+ /// provider ID ascending.\n+ pub fn select_model(\n+ &self,\n+ selector: &str,\n+ explicit_provider: Option<&ProviderId>,\n+ eligible_providers: &[ProviderId],\n+ ) -> Result<&Model, ModelSelectionError> {\n+ if let Some(retired) = self.retired_identifier(selector) {\n+ return Err(ModelSelectionError::RetiredIdentifier {\n+ identifier: retired.identifier,\n+ provider: retired.provider,\n+ model: retired.model,\n+ });\n+ }\n+\n+ let eligible = eligible_providers\n+ .iter()\n+ .filter_map(|id| self.provider(id).map(|provider| provider.id.clone()))\n+ .collect::>();\n+\n+ if let Some(explicit_provider) = explicit_provider {\n+ let provider = self\n+ .provider(explicit_provider)\n+ .ok_or_else(|| ModelSelectionError::UnknownSelectorOnProvider {\n+ provider: explicit_provider.clone(),\n+ selector: selector.to_string(),\n+ })?;\n+ if !eligible.contains(&provider.id) {\n+ return Err(ModelSelectionError::UnavailableProvider {\n+ provider: provider.id.clone(),\n+ });\n+ }\n+ return self.model_on_provider(&provider.id, selector).ok_or_else(|| {\n+ ModelSelectionError::UnknownSelectorOnProvider {\n+ provider: provider.id.clone(),\n+ selector: selector.to_string(),\n+ }\n+ });\n+ }\n+\n+ let candidates = self\n+ .select_candidates(selector)\n+ .ok_or_else(|| ModelSelectionError::UnknownSelector {\n+ selector: selector.to_string(),\n+ })?;\n+ candidates\n+ .iter()\n+ .filter_map(|idx| self.models.get(*idx))\n+ .find(|model| eligible.contains(&model.provider))\n+ .ok_or_else(|| ModelSelectionError::NoEligibleOffering {\n+ selector: selector.to_string(),\n+ })\n+ }\n+\n+ fn select_candidates(&self, selector: &str) -> Option<&Vec> {\n+ self.canonical_candidates\n+ .get(selector)\n+ .or_else(|| self.alias_candidates.get(selector))\n+ }\n+\n #[must_use]\n pub fn providers(&self) -> &[CatalogProvider] {\n &self.providers\n@@ -786,7 +959,7 @@ impl Catalog {\n let stats = stats_by_provider.entry(model.provider.clone()).or_default();\n stats.model_count = stats.model_count.saturating_add(1);\n if model.default {\n- stats.default_model = Some(model.id.clone());\n+ stats.default_model = Some(model.id.to_string());\n }\n }\n \n@@ -817,10 +990,29 @@ impl Catalog {\n self.provider(id)?.vault_secret_name()\n }\n \n+ /// Resolve settings for the highest-priority offering matching a selector.\n+ /// Prefer [`Self::model_settings_on_provider`] or\n+ /// [`Self::model_settings_for`] when provider identity is known.\n+ #[must_use]\n+ pub fn model_settings(&self, selector: &str) -> Option<&CatalogModelSettings> {\n+ let model = self.get(selector)?;\n+ self.model_settings_for(model)\n+ }\n+\n #[must_use]\n- pub fn model_settings(&self, id: &str) -> Option<&CatalogModelSettings> {\n- let model = self.get(id)?;\n- self.model_settings.get(&model.id)\n+ pub fn model_settings_on_provider(\n+ &self,\n+ provider: &ProviderId,\n+ selector: &str,\n+ ) -> Option<&CatalogModelSettings> {\n+ let model = self.model_on_provider(provider, selector)?;\n+ self.model_settings_for(model)\n+ }\n+\n+ #[must_use]\n+ pub fn model_settings_for(&self, model: &Model) -> Option<&CatalogModelSettings> {\n+ self.model_settings\n+ .get(&(model.provider.clone(), model.id.clone()))\n }\n \n #[must_use]\n@@ -831,9 +1023,8 @@ impl Catalog {\n ) -> Option {\n let provider = self.provider(provider_id)?;\n let model_profile = model_id_or_alias\n- .and_then(|model_id| self.get(model_id))\n- .filter(|model| model.provider == provider.id)\n- .and_then(|model| self.model_settings.get(&model.id))\n+ .and_then(|model_id| self.model_on_provider(&provider.id, model_id))\n+ .and_then(|model| self.model_settings_for(model))\n .map(|settings| settings.agent_profile);\n Some(model_profile.unwrap_or(provider.agent_profile))\n }\n@@ -849,9 +1040,8 @@ impl Catalog {\n ) -> Option {\n let provider = self.provider(provider_id)?;\n let model_codec = model_id_or_alias\n- .and_then(|model_id| self.get(model_id))\n- .filter(|model| model.provider == provider.id)\n- .and_then(|model| self.model_settings.get(&model.id))\n+ .and_then(|model_id| self.model_on_provider(&provider.id, model_id))\n+ .and_then(|model| self.model_settings_for(model))\n .map(|settings| settings.codec);\n Some(model_codec.unwrap_or(provider.codec))\n }\n@@ -867,9 +1057,8 @@ impl Catalog {\n ) -> Option {\n let provider = self.provider(provider_id)?;\n let model_policy = model_id_or_alias\n- .and_then(|model_id| self.get(model_id))\n- .filter(|model| model.provider == provider.id)\n- .and_then(|model| self.model_settings.get(&model.id))\n+ .and_then(|model_id| self.model_on_provider(&provider.id, model_id))\n+ .and_then(|model| self.model_settings_for(model))\n .map(|settings| settings.billing_policy);\n Some(model_policy.unwrap_or(provider.billing_policy))\n }\n@@ -993,8 +1182,7 @@ impl Catalog {\n if let Some(model) = self.models.iter().find(|model| {\n &model.provider == provider_id\n && self\n- .model_settings\n- .get(&model.id)\n+ .model_settings_for(model)\n .is_some_and(|settings| settings.probe)\n }) {\n return Some(model);\n@@ -1043,7 +1231,7 @@ impl Catalog {\n model: &str,\n fallbacks: &HashMap>,\n ) -> Vec {\n- let Some(reference) = self.get(model) else {\n+ let Some(reference) = self.model_on_provider(primary, model) else {\n return Vec::new();\n };\n \n@@ -1057,22 +1245,250 @@ impl Catalog {\n let provider = ProviderId::from(provider_str.clone());\n self.closest(&provider, reference).map(|m| FallbackTarget {\n provider: provider_str.clone(),\n- model: m.id.clone(),\n+ model: m.id.to_string(),\n })\n })\n .collect()\n }\n }\n \n-fn build_model_index(models: &[Model]) -> HashMap {\n- let mut index = HashMap::new();\n+type OfferingIndex = HashMap<(ProviderId, ModelId), usize>;\n+type CanonicalCandidates = HashMap>;\n+type AliasCandidates = HashMap>;\n+\n+fn builtin_retired_identifiers() -> HashMap {\n+ const RETIRED: &[(&str, &str, &str)] = &[\n+ (\"openai.gpt-5.5\", \"bedrock-openai\", \"gpt-5.5\"),\n+ (\"openai.gpt-5.4\", \"bedrock-openai\", \"gpt-5.4\"),\n+ (\n+ \"us.anthropic.claude-sonnet-4-6\",\n+ \"bedrock\",\n+ \"claude-sonnet-4-6\",\n+ ),\n+ (\n+ \"us.anthropic.claude-opus-4-8\",\n+ \"bedrock\",\n+ \"claude-opus-4-8\",\n+ ),\n+ (\n+ \"us.anthropic.claude-haiku-4-5\",\n+ \"bedrock\",\n+ \"claude-haiku-4-5\",\n+ ),\n+ (\"openai.gpt-oss-120b\", \"bedrock\", \"gpt-oss-120b\"),\n+ (\"openai.gpt-oss-20b\", \"bedrock\", \"gpt-oss-20b\"),\n+ (\"amazon.nova-2-lite\", \"bedrock\", \"nova-2-lite\"),\n+ (\"meta.llama4-maverick\", \"bedrock\", \"llama-4-maverick\"),\n+ (\n+ \"mistral.mistral-large-3\",\n+ \"bedrock\",\n+ \"mistral-large-3\",\n+ ),\n+ (\"mistral.devstral-2\", \"bedrock\", \"devstral-2\"),\n+ (\"deepseek.v3-2\", \"bedrock\", \"deepseek-v3.2\"),\n+ (\"moonshotai.kimi-k2.5\", \"bedrock\", \"kimi-k2.5\"),\n+ (\"zai.glm-5\", \"bedrock\", \"glm-5\"),\n+ (\"minimax.minimax-m2.5\", \"bedrock\", \"minimax-m2.5\"),\n+ (\n+ \"nvidia.nemotron-3-super\",\n+ \"bedrock\",\n+ \"nemotron-3-super\",\n+ ),\n+ (\n+ \"us.anthropic.claude-fable-5\",\n+ \"bedrock\",\n+ \"claude-fable-5\",\n+ ),\n+ (\n+ \"anthropic/claude-opus-4-7\",\n+ \"openrouter\",\n+ \"claude-opus-4-7\",\n+ ),\n+ (\n+ \"anthropic/claude-sonnet-4-6\",\n+ \"openrouter\",\n+ \"claude-sonnet-4-6\",\n+ ),\n+ (\n+ \"anthropic/claude-haiku-4-5\",\n+ \"openrouter\",\n+ \"claude-haiku-4-5\",\n+ ),\n+ (\"openai/gpt-5.4\", \"openrouter\", \"gpt-5.4\"),\n+ (\"openai/gpt-5.5\", \"openrouter\", \"gpt-5.5\"),\n+ (\n+ \"google/gemini-3.1-pro-preview\",\n+ \"openrouter\",\n+ \"gemini-3.1-pro-preview\",\n+ ),\n+ (\n+ \"google/gemini-3.5-flash\",\n+ \"openrouter\",\n+ \"gemini-3.5-flash\",\n+ ),\n+ (\"xiaomi/mimo-v2.5-pro\", \"openrouter\", \"mimo-v2.5-pro\"),\n+ (\n+ \"minimax/minimax-m2.7\",\n+ \"openrouter\",\n+ \"minimax-m2.7\",\n+ ),\n+ (\n+ \"deepseek/deepseek-v4-pro\",\n+ \"openrouter\",\n+ \"deepseek-v4-pro\",\n+ ),\n+ (\n+ \"deepseek/deepseek-v4-flash\",\n+ \"openrouter\",\n+ \"deepseek-v4-flash\",\n+ ),\n+ (\"moonshotai/kimi-k2.6\", \"openrouter\", \"kimi-k2.6\"),\n+ (\"moonshotai/kimi-k3\", \"openrouter\", \"kimi-k3\"),\n+ (\n+ \"poolside/laguna-s-2.1\",\n+ \"openrouter\",\n+ \"laguna-s-2.1\",\n+ ),\n+ (\n+ \"poolside/laguna-xs-2.1\",\n+ \"openrouter\",\n+ \"laguna-xs-2.1\",\n+ ),\n+ (\"qwen/qwen3-coder\", \"openrouter\", \"qwen3-coder\"),\n+ (\"qwen/qwen3.6-flash\", \"openrouter\", \"qwen3.6-flash\"),\n+ (\"z-ai/glm-5.2\", \"openrouter\", \"glm-5.2\"),\n+ (\"z-ai/glm-4.6\", \"openrouter\", \"glm-4.6\"),\n+ (\n+ \"nvidia/nemotron-3-super-120b-a12b\",\n+ \"openrouter\",\n+ \"nemotron-3-super\",\n+ ),\n+ (\"mistralai/devstral-2512\", \"openrouter\", \"devstral-2\"),\n+ ];\n+\n+ RETIRED\n+ .iter()\n+ .map(|(identifier, provider, model)| {\n+ (\n+ (*identifier).to_string(),\n+ (ProviderId::new(*provider), ModelId::new(*model)),\n+ )\n+ })\n+ .collect()\n+}\n+\n+fn build_model_indexes(\n+ models: &[Model],\n+) -> (OfferingIndex, CanonicalCandidates, AliasCandidates) {\n+ let mut offering_index = HashMap::new();\n+ let mut canonical_candidates = HashMap::>::new();\n+ let mut alias_candidates = HashMap::>::new();\n for (idx, model) in models.iter().enumerate() {\n- index.insert(model.id.clone(), idx);\n+ offering_index.insert((model.provider.clone(), model.id.clone()), idx);\n+ canonical_candidates\n+ .entry(model.id.clone())\n+ .or_default()\n+ .push(idx);\n for alias in &model.aliases {\n- index.insert(alias.clone(), idx);\n+ alias_candidates.entry(alias.clone()).or_default().push(idx);\n+ }\n+ }\n+ (offering_index, canonical_candidates, alias_candidates)\n+}\n+\n+fn adopt_legacy_models(\n+ mut overrides: LlmCatalogSettings,\n+ builtins: &LlmCatalogSettings,\n+) -> Result {\n+ let legacy_models = std::mem::take(&mut overrides.models);\n+ for (identifier, mut settings) in legacy_models {\n+ let provider = match settings.provider.take() {\n+ Some(provider) if !provider.is_empty() => ProviderId::new(provider),\n+ _ => unique_builtin_provider_for_identifier(builtins, &identifier)\n+ .ok_or_else(|| CatalogBuildError::AmbiguousLegacyModelProvider {\n+ model: identifier.clone(),\n+ })?,\n+ };\n+ let canonical_model = builtins\n+ .providers\n+ .get(provider.as_str())\n+ .and_then(|provider_settings| {\n+ provider_settings\n+ .models\n+ .iter()\n+ .find(|(model_id, model_settings)| {\n+ model_id.as_str() == identifier\n+ || model_settings\n+ .aliases\n+ .as_ref()\n+ .is_some_and(|aliases| aliases.iter().any(|alias| alias == &identifier))\n+ })\n+ .map(|(model_id, _)| model_id.clone())\n+ })\n+ .unwrap_or(identifier);\n+ let provider_settings = overrides\n+ .providers\n+ .entry(provider.into_inner())\n+ .or_default();\n+ let merged = match provider_settings.models.remove(&canonical_model) {\n+ Some(scoped) => merge_model_settings(settings, scoped),\n+ None => settings,\n+ };\n+ provider_settings.models.insert(canonical_model, merged);\n+ }\n+ Ok(overrides)\n+}\n+\n+fn unique_builtin_provider_for_identifier(\n+ builtins: &LlmCatalogSettings,\n+ identifier: &str,\n+) -> Option {\n+ let mut matches = builtins.providers.iter().filter_map(|(provider, settings)| {\n+ settings\n+ .models\n+ .iter()\n+ .any(|(model_id, model)| {\n+ model_id == identifier\n+ || model\n+ .aliases\n+ .as_ref()\n+ .is_some_and(|aliases| aliases.iter().any(|alias| alias == identifier))\n+ })\n+ .then(|| ProviderId::new(provider))\n+ });\n+ let provider = matches.next()?;\n+ matches.next().is_none().then_some(provider)\n+}\n+\n+fn normalized_model_settings(\n+ settings: &LlmCatalogSettings,\n+) -> Result, CatalogBuildError> {\n+ let mut normalized = HashMap::new();\n+\n+ for (provider, provider_settings) in &settings.providers {\n+ let provider_id = ProviderId::new(provider);\n+ for (model_id, model_settings) in &provider_settings.models {\n+ normalized.insert(\n+ (provider_id.clone(), model_id.clone()),\n+ model_settings.clone(),\n+ );\n }\n }\n- index\n+\n+ // Legacy top-level rows remain an input-only compatibility shape. A\n+ // provider is required here; fabro-config performs catalog-aware adoption\n+ // for provider-less rows before constructing these settings.\n+ for (model_id, model_settings) in &settings.models {\n+ let provider = required_model_string(model_id, model_settings.provider.as_ref(), \"provider\")?;\n+ let key = (ProviderId::new(provider), model_id.clone());\n+ let merged = match normalized.remove(&key) {\n+ Some(scoped) => merge_model_settings(model_settings.clone(), scoped),\n+ None => model_settings.clone(),\n+ };\n+ normalized.insert(key, merged);\n+ }\n+\n+ Ok(normalized)\n }\n \n fn merge_catalog_settings(\n@@ -1115,9 +1531,24 @@ fn merge_provider_settings(\n priority: higher.priority.or(fallback.priority),\n enabled: higher.enabled.or(fallback.enabled),\n aliases: higher.aliases.or(fallback.aliases),\n+ models: merge_model_maps(higher.models, fallback.models),\n }\n }\n \n+fn merge_model_maps(\n+ higher: HashMap,\n+ mut fallback: HashMap,\n+) -> HashMap {\n+ for (id, model) in higher {\n+ let model = match fallback.remove(&id) {\n+ Some(fallback_model) => merge_model_settings(model, fallback_model),\n+ None => model,\n+ };\n+ fallback.insert(id, model);\n+ }\n+ fallback\n+}\n+\n fn merge_model_settings(\n higher: ModelCatalogSettings,\n fallback: ModelCatalogSettings,\n@@ -1418,7 +1849,7 @@ fn build_model(\n let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?;\n \n let model = Model {\n- id: model_id.to_string(),\n+ id: ModelId::new(model_id),\n provider: provider.id.clone(),\n family,\n display_name,\n@@ -1436,6 +1867,12 @@ fn build_model(\n small_default: settings.small_default.unwrap_or_default(),\n configured: false,\n };\n+ if settings.api_id.as_deref() == Some(\"\") {\n+ return Err(CatalogBuildError::EmptyModelApiId {\n+ provider: provider.id.clone(),\n+ model: ModelId::new(model_id),\n+ });\n+ }\n let catalog_settings = CatalogModelSettings {\n api_id: settings\n .api_id\n@@ -1458,7 +1895,7 @@ fn warn_multiple_probe_models(models_with_settings: &[(Model, CatalogModelSettin\n probes_by_provider\n .entry(model.provider.clone())\n .or_default()\n- .push(model.id.clone());\n+ .push(model.id.to_string());\n }\n }\n \n@@ -1675,16 +2112,20 @@ fn register_provider_identifier(\n }\n \n fn register_model_identifier(\n- identifiers: &mut BTreeMap,\n+ identifiers: &mut BTreeMap,\n identifier: String,\n- owner: String,\n+ owner: ModelId,\n+ provider: &ProviderId,\n ) -> Result<(), CatalogBuildError> {\n match identifiers.get(&identifier) {\n- Some(existing) if existing != &owner => Err(CatalogBuildError::DuplicateModelIdentifier {\n- identifier,\n- first: existing.clone(),\n- second: owner,\n- }),\n+ Some(existing) if existing != &owner => {\n+ Err(CatalogBuildError::DuplicateProviderModelIdentifier {\n+ provider: provider.clone(),\n+ identifier,\n+ first: existing.clone(),\n+ second: owner,\n+ })\n+ }\n _ => {\n identifiers.insert(identifier, owner);\n Ok(())\n@@ -1733,6 +2174,20 @@ fn validate_builtin_fragment(\n });\n }\n }\n+ let provider = fragment\n+ .providers\n+ .get(expected)\n+ .expect(\"provider count and ID were validated\");\n+ if !fragment.models.is_empty() && !provider.models.is_empty() {\n+ // Embedded fragments are canonical output rather than compatibility\n+ // inputs; mixing shapes would make ownership unclear.\n+ return Err(CatalogBuildError::BuiltinModelProviderMismatch {\n+ path: path.to_string(),\n+ model: \"\".to_string(),\n+ expected: expected.to_string(),\n+ actual: \"top-level models\".to_string(),\n+ });\n+ }\n Ok(())\n }\n \n@@ -1762,6 +2217,255 @@ mod tests {\n toml::from_str(source).expect(\"fixture should parse as an LLM settings layer\")\n }\n \n+ const PORTABLE_MODEL_SETTINGS: &str = r#\"\n+[providers.openai]\n+display_name = \"OpenAI\"\n+adapter = \"openai\"\n+priority = 90\n+\n+[providers.openai.models.\"gpt-5.6-sol\"]\n+display_name = \"GPT-5.6 Sol\"\n+family = \"gpt-5\"\n+aliases = [\"gpt-56-sol\"]\n+default = true\n+\n+[providers.openai.models.\"gpt-5.6-sol\".limits]\n+context_window = 1000\n+\n+[providers.openai.models.\"gpt-5.6-sol\".features]\n+tools = true\n+vision = false\n+reasoning = true\n+\n+[providers.openrouter]\n+display_name = \"OpenRouter\"\n+adapter = \"openai_compatible\"\n+base_url = \"https://openrouter.invalid/v1\"\n+priority = 25\n+\n+[providers.openrouter.models.\"gpt-5.6-sol\"]\n+api_id = \"openai/gpt-5.6-sol\"\n+display_name = \"GPT-5.6 Sol (via OpenRouter)\"\n+family = \"gpt-5\"\n+aliases = [\"gpt-56-sol\"]\n+default = true\n+\n+[providers.openrouter.models.\"gpt-5.6-sol\".limits]\n+context_window = 1000\n+\n+[providers.openrouter.models.\"gpt-5.6-sol\".features]\n+tools = true\n+vision = false\n+reasoning = true\n+\"#;\n+\n+ fn portable_catalog() -> Catalog {\n+ Catalog::from_settings(&minimal_settings(PORTABLE_MODEL_SETTINGS))\n+ .expect(\"portable fixture should build\")\n+ }\n+\n+ #[test]\n+ fn provider_aware_catalog_allows_shared_canonical_ids_and_aliases() {\n+ let catalog = portable_catalog();\n+ let openai = catalog\n+ .model_on_provider(&ProviderId::new(\"openai\"), \"gpt-56-sol\")\n+ .expect(\"OpenAI alias should resolve\");\n+ let openrouter = catalog\n+ .model_on_provider(&ProviderId::new(\"openrouter\"), \"gpt-56-sol\")\n+ .expect(\"OpenRouter alias should resolve\");\n+\n+ assert_eq!(openai.id.as_str(), \"gpt-5.6-sol\");\n+ assert_eq!(openrouter.id.as_str(), \"gpt-5.6-sol\");\n+ assert_ne!(openai.provider, openrouter.provider);\n+ assert_eq!(\n+ catalog\n+ .model_settings_for(openai)\n+ .expect(\"OpenAI settings should exist\")\n+ .api_id,\n+ \"gpt-5.6-sol\"\n+ );\n+ assert_eq!(\n+ catalog\n+ .model_settings_for(openrouter)\n+ .expect(\"OpenRouter settings should exist\")\n+ .api_id,\n+ \"openai/gpt-5.6-sol\"\n+ );\n+ }\n+\n+ #[test]\n+ fn provider_aware_selection_uses_eligibility_priority_and_explicit_pin() {\n+ let catalog = portable_catalog();\n+ let openai = ProviderId::new(\"openai\");\n+ let openrouter = ProviderId::new(\"openrouter\");\n+\n+ assert_eq!(\n+ catalog\n+ .select_model(\"gpt-56-sol\", None, std::slice::from_ref(&openai))\n+ .unwrap()\n+ .provider,\n+ openai\n+ );\n+ assert_eq!(\n+ catalog\n+ .select_model(\"gpt-56-sol\", None, std::slice::from_ref(&openrouter))\n+ .unwrap()\n+ .provider,\n+ openrouter\n+ );\n+ assert_eq!(\n+ catalog\n+ .select_model(\"gpt-56-sol\", None, &[openrouter.clone(), openai.clone()])\n+ .unwrap()\n+ .provider,\n+ openai\n+ );\n+ assert_eq!(\n+ catalog\n+ .select_model(\n+ \"gpt-56-sol\",\n+ Some(&openrouter),\n+ &[openai.clone(), openrouter.clone()],\n+ )\n+ .unwrap()\n+ .provider,\n+ openrouter\n+ );\n+ assert!(matches!(\n+ catalog.select_model(\"gpt-56-sol\", Some(&openrouter), &[openai]),\n+ Err(ModelSelectionError::UnavailableProvider { provider })\n+ if provider == openrouter\n+ ));\n+ }\n+\n+ #[test]\n+ fn provider_aware_selection_ties_by_canonical_provider_id() {\n+ let settings = PORTABLE_MODEL_SETTINGS\n+ .replace(\"priority = 90\", \"priority = 25\");\n+ let catalog = Catalog::from_settings(&minimal_settings(&settings)).unwrap();\n+\n+ assert_eq!(\n+ catalog\n+ .select_model(\n+ \"gpt-56-sol\",\n+ None,\n+ &[ProviderId::new(\"openrouter\"), ProviderId::new(\"openai\")],\n+ )\n+ .unwrap()\n+ .provider,\n+ ProviderId::new(\"openai\")\n+ );\n+ }\n+\n+ #[test]\n+ fn canonical_id_candidates_shadow_cross_provider_alias_candidates() {\n+ let settings = minimal_settings(\n+ r#\"\n+[providers.canonical]\n+adapter = \"openai\"\n+priority = 1\n+\n+[providers.canonical.models.pin]\n+display_name = \"Canonical Pin\"\n+family = \"pin\"\n+default = true\n+[providers.canonical.models.pin.limits]\n+context_window = 1000\n+[providers.canonical.models.pin.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\n+[providers.alias]\n+adapter = \"openai\"\n+priority = 100\n+\n+[providers.alias.models.other]\n+display_name = \"Alias Pin\"\n+family = \"pin\"\n+aliases = [\"pin\"]\n+default = true\n+[providers.alias.models.other.limits]\n+context_window = 1000\n+[providers.alias.models.other.features]\n+tools = false\n+vision = false\n+reasoning = false\n+\"#,\n+ );\n+ let catalog = Catalog::from_settings(&settings).unwrap();\n+ let canonical = ProviderId::new(\"canonical\");\n+ let alias = ProviderId::new(\"alias\");\n+\n+ assert_eq!(\n+ catalog\n+ .select_model(\"pin\", None, &[alias.clone(), canonical.clone()])\n+ .unwrap()\n+ .provider,\n+ canonical\n+ );\n+ assert!(matches!(\n+ catalog.select_model(\"pin\", None, std::slice::from_ref(&alias)),\n+ Err(ModelSelectionError::NoEligibleOffering { selector }) if selector == \"pin\"\n+ ));\n+ assert_eq!(\n+ catalog\n+ .select_model(\"pin\", Some(&alias), std::slice::from_ref(&alias))\n+ .unwrap()\n+ .id\n+ .as_str(),\n+ \"other\"\n+ );\n+ }\n+\n+ #[test]\n+ fn same_provider_identifier_collision_is_rejected() {\n+ let source = PORTABLE_MODEL_SETTINGS.replace(\n+ \"[providers.openai.models.\\\"gpt-5.6-sol\\\".limits]\",\n+ r#\"[providers.openai.models.other]\n+display_name = \"Other\"\n+family = \"gpt-5\"\n+aliases = [\"gpt-56-sol\"]\n+[providers.openai.models.other.limits]\n+context_window = 1000\n+[providers.openai.models.other.features]\n+tools = true\n+vision = false\n+reasoning = true\n+\n+[providers.openai.models.\"gpt-5.6-sol\".limits]\"#,\n+ );\n+ let err = Catalog::from_settings(&minimal_settings(&source)).unwrap_err();\n+\n+ assert!(matches!(\n+ err,\n+ CatalogBuildError::DuplicateProviderModelIdentifier {\n+ provider,\n+ identifier,\n+ first,\n+ second,\n+ } if provider == ProviderId::new(\"openai\")\n+ && identifier == \"gpt-56-sol\"\n+ && first.as_str() == \"gpt-5.6-sol\"\n+ && second.as_str() == \"other\"\n+ ));\n+ }\n+\n+ #[test]\n+ fn explicitly_empty_api_id_is_rejected() {\n+ let settings = PORTABLE_MODEL_SETTINGS.replace(\n+ \"display_name = \\\"GPT-5.6 Sol\\\"\",\n+ \"api_id = \\\"\\\"\\ndisplay_name = \\\"GPT-5.6 Sol\\\"\",\n+ );\n+ let err = Catalog::from_settings(&minimal_settings(&settings)).unwrap_err();\n+\n+ assert!(matches!(\n+ err,\n+ CatalogBuildError::EmptyModelApiId { provider, model }\n+ if provider == ProviderId::new(\"openai\") && model.as_str() == \"gpt-5.6-sol\"\n+ ));\n+ }\n+\n const BEDROCK_SIGV4_LAYER: &str = r#\"\n [providers.bedrock]\n adapter = \"bedrock\"\n@@ -2807,8 +3511,15 @@ reasoning = false\n \n assert!(matches!(\n err,\n- CatalogBuildError::DuplicateModelIdentifier { identifier, first, second }\n- if identifier == \"shared\" && first == \"one\" && second == \"two\"\n+ CatalogBuildError::DuplicateProviderModelIdentifier {\n+ provider,\n+ identifier,\n+ first,\n+ second,\n+ } if provider == ProviderId::new(\"test\")\n+ && identifier == \"shared\"\n+ && first.as_str() == \"one\"\n+ && second.as_str() == \"two\"\n ));\n }\n \ndiff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml\nindex 0a1587197..95649e214 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml\n@@ -9,18 +9,16 @@ priority = 100\n credentials = [\"env:ANTHROPIC_API_KEY\", \"vault:ANTHROPIC_API_KEY\"]\n header = { custom = \"x-api-key\" }\n \n-[models.\"claude-fable-5\"]\n-provider = \"anthropic\"\n-api_id = \"claude-fable-5\"\n+[providers.anthropic.models.\"claude-fable-5\"]\n display_name = \"Claude Fable 5\"\n family = \"claude-5\"\n aliases = [\"fable\", \"claude-fable\"]\n \n-[models.\"claude-fable-5\".limits]\n+[providers.anthropic.models.\"claude-fable-5\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"claude-fable-5\".features]\n+[providers.anthropic.models.\"claude-fable-5\".features]\n tools = true\n vision = true\n reasoning = true\n@@ -28,14 +26,12 @@ reasoning_effort = \"always_adaptive\"\n prompt_cache = true\n sampling_params = false\n \n-[models.\"claude-fable-5\".costs]\n+[providers.anthropic.models.\"claude-fable-5\".costs]\n input_cost_per_mtok = 10.0\n output_cost_per_mtok = 50.0\n cache_input_cost_per_mtok = 1.0\n \n-[models.\"claude-opus-4-8\"]\n-provider = \"anthropic\"\n-api_id = \"claude-opus-4-8\"\n+[providers.anthropic.models.\"claude-opus-4-8\"]\n display_name = \"Claude Opus 4.8\"\n family = \"claude-4\"\n training = \"2026-01-01\"\n@@ -43,11 +39,11 @@ knowledge_cutoff = \"Jan 2026\"\n estimated_output_tps = 25\n aliases = [\"opus\", \"claude-opus\"]\n \n-[models.\"claude-opus-4-8\".limits]\n+[providers.anthropic.models.\"claude-opus-4-8\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"claude-opus-4-8\".features]\n+[providers.anthropic.models.\"claude-opus-4-8\".features]\n tools = true\n vision = true\n reasoning = true\n@@ -55,33 +51,31 @@ reasoning_effort = \"levels\"\n prompt_cache = true\n sampling_params = false\n \n-[models.\"claude-opus-4-8\".controls]\n+[providers.anthropic.models.\"claude-opus-4-8\".controls]\n speed = [\"fast\"]\n \n-[models.\"claude-opus-4-8\".costs]\n+[providers.anthropic.models.\"claude-opus-4-8\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 25.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"claude-opus-4-8\".costs.speed.fast]\n+[providers.anthropic.models.\"claude-opus-4-8\".costs.speed.fast]\n input_cost_per_mtok = 10.0\n output_cost_per_mtok = 50.0\n cache_input_cost_per_mtok = 1.0\n \n-[models.\"claude-opus-4-7\"]\n-provider = \"anthropic\"\n-api_id = \"claude-opus-4-7\"\n+[providers.anthropic.models.\"claude-opus-4-7\"]\n display_name = \"Claude Opus 4.7\"\n family = \"claude-4\"\n training = \"2025-08-01\"\n knowledge_cutoff = \"May 2025\"\n estimated_output_tps = 25\n \n-[models.\"claude-opus-4-7\".limits]\n+[providers.anthropic.models.\"claude-opus-4-7\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"claude-opus-4-7\".features]\n+[providers.anthropic.models.\"claude-opus-4-7\".features]\n tools = true\n vision = true\n reasoning = true\n@@ -89,82 +83,76 @@ reasoning_effort = \"levels\"\n prompt_cache = true\n sampling_params = false\n \n-[models.\"claude-opus-4-7\".controls]\n+[providers.anthropic.models.\"claude-opus-4-7\".controls]\n speed = [\"fast\"]\n \n-[models.\"claude-opus-4-7\".costs]\n+[providers.anthropic.models.\"claude-opus-4-7\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 25.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"claude-opus-4-7\".costs.speed.fast]\n+[providers.anthropic.models.\"claude-opus-4-7\".costs.speed.fast]\n input_cost_per_mtok = 30.0\n output_cost_per_mtok = 150.0\n cache_input_cost_per_mtok = 3.0\n \n-[models.\"claude-opus-4-6\"]\n-provider = \"anthropic\"\n-api_id = \"claude-opus-4-6\"\n+[providers.anthropic.models.\"claude-opus-4-6\"]\n display_name = \"Claude Opus 4.6\"\n family = \"claude-4\"\n training = \"2025-08-01\"\n knowledge_cutoff = \"May 2025\"\n estimated_output_tps = 25\n \n-[models.\"claude-opus-4-6\".limits]\n+[providers.anthropic.models.\"claude-opus-4-6\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"claude-opus-4-6\".features]\n+[providers.anthropic.models.\"claude-opus-4-6\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"claude-opus-4-6\".controls]\n+[providers.anthropic.models.\"claude-opus-4-6\".controls]\n speed = [\"fast\"]\n \n-[models.\"claude-opus-4-6\".costs]\n+[providers.anthropic.models.\"claude-opus-4-6\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 25.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"claude-opus-4-6\".costs.speed.fast]\n+[providers.anthropic.models.\"claude-opus-4-6\".costs.speed.fast]\n input_cost_per_mtok = 30.0\n output_cost_per_mtok = 150.0\n cache_input_cost_per_mtok = 3.0\n \n-[models.\"claude-sonnet-4-5\"]\n-provider = \"anthropic\"\n-api_id = \"claude-sonnet-4-5\"\n+[providers.anthropic.models.\"claude-sonnet-4-5\"]\n display_name = \"Claude Sonnet 4.5\"\n family = \"claude-4\"\n training = \"2025-08-01\"\n knowledge_cutoff = \"May 2025\"\n estimated_output_tps = 50\n \n-[models.\"claude-sonnet-4-5\".limits]\n+[providers.anthropic.models.\"claude-sonnet-4-5\".limits]\n context_window = 200000\n max_output = 64000\n \n-[models.\"claude-sonnet-4-5\".features]\n+[providers.anthropic.models.\"claude-sonnet-4-5\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n \n-[models.\"claude-sonnet-4-5\".controls]\n+[providers.anthropic.models.\"claude-sonnet-4-5\".controls]\n reasoning_effort = [\"low\", \"medium\", \"high\", \"xhigh\", \"max\"]\n \n-[models.\"claude-sonnet-4-5\".costs]\n+[providers.anthropic.models.\"claude-sonnet-4-5\".costs]\n input_cost_per_mtok = 3.0\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.3\n \n-[models.\"claude-sonnet-4-6\"]\n-provider = \"anthropic\"\n-api_id = \"claude-sonnet-4-6\"\n+[providers.anthropic.models.\"claude-sonnet-4-6\"]\n display_name = \"Claude Sonnet 4.6\"\n family = \"claude-4\"\n training = \"2025-08-01\"\n@@ -173,25 +161,23 @@ default = true\n estimated_output_tps = 50\n aliases = [\"sonnet\", \"claude-sonnet\"]\n \n-[models.\"claude-sonnet-4-6\".limits]\n+[providers.anthropic.models.\"claude-sonnet-4-6\".limits]\n context_window = 200000\n max_output = 64000\n \n-[models.\"claude-sonnet-4-6\".features]\n+[providers.anthropic.models.\"claude-sonnet-4-6\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"claude-sonnet-4-6\".costs]\n+[providers.anthropic.models.\"claude-sonnet-4-6\".costs]\n input_cost_per_mtok = 3.0\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.3\n \n-[models.\"claude-haiku-4-5\"]\n-provider = \"anthropic\"\n-api_id = \"claude-haiku-4-5\"\n+[providers.anthropic.models.\"claude-haiku-4-5\"]\n display_name = \"Claude Haiku 4.5\"\n family = \"claude-4\"\n training = \"2025-08-01\"\n@@ -201,17 +187,17 @@ aliases = [\"haiku\", \"claude-haiku\"]\n probe = true\n small_default = true\n \n-[models.\"claude-haiku-4-5\".limits]\n+[providers.anthropic.models.\"claude-haiku-4-5\".limits]\n context_window = 200000\n max_output = 8192\n \n-[models.\"claude-haiku-4-5\".features]\n+[providers.anthropic.models.\"claude-haiku-4-5\".features]\n tools = true\n vision = true\n reasoning = false\n prompt_cache = true\n \n-[models.\"claude-haiku-4-5\".costs]\n+[providers.anthropic.models.\"claude-haiku-4-5\".costs]\n input_cost_per_mtok = 0.8\n output_cost_per_mtok = 4.0\n cache_input_cost_per_mtok = 0.08\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml b/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml\nindex 20b011b9c..21384d9b2 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml\n@@ -35,41 +35,41 @@ credentials = [\n # [llm.providers.bedrock-openai]\n # enabled = true\n \n-[models.\"openai.gpt-5.5\"]\n-provider = \"bedrock-openai\"\n+[providers.bedrock-openai.models.\"gpt-5.5\"]\n+api_id = \"openai.gpt-5.5\"\n display_name = \"GPT-5.5 (Bedrock)\"\n family = \"gpt-5\"\n default = true\n \n-[models.\"openai.gpt-5.5\".limits]\n+[providers.bedrock-openai.models.\"gpt-5.5\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"openai.gpt-5.5\".features]\n+[providers.bedrock-openai.models.\"gpt-5.5\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"openai.gpt-5.5\".costs]\n+[providers.bedrock-openai.models.\"gpt-5.5\".costs]\n input_cost_per_mtok = 5.5\n output_cost_per_mtok = 33.0\n \n-[models.\"openai.gpt-5.4\"]\n-provider = \"bedrock-openai\"\n+[providers.bedrock-openai.models.\"gpt-5.4\"]\n+api_id = \"openai.gpt-5.4\"\n display_name = \"GPT-5.4 (Bedrock)\"\n family = \"gpt-5\"\n \n-[models.\"openai.gpt-5.4\".limits]\n+[providers.bedrock-openai.models.\"gpt-5.4\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"openai.gpt-5.4\".features]\n+[providers.bedrock-openai.models.\"gpt-5.4\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"openai.gpt-5.4\".costs]\n+[providers.bedrock-openai.models.\"gpt-5.4\".costs]\n input_cost_per_mtok = 2.75\n output_cost_per_mtok = 16.5\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml b/lib/crates/fabro-model/src/catalog/providers/bedrock.toml\nindex f6f217a4f..aeed22e46 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/bedrock.toml\n@@ -45,68 +45,67 @@ credentials = [\n # file because its Bedrock deployment pins sampling parameters and requires an\n # extra data-sharing opt-in.\n \n-[models.\"us.anthropic.claude-sonnet-4-6\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"claude-sonnet-4-6\"]\n+api_id = \"us.anthropic.claude-sonnet-4-6\"\n display_name = \"Claude Sonnet 4.6 (Bedrock)\"\n family = \"claude-4\"\n billing_policy = \"anthropic\"\n default = true\n \n-[models.\"us.anthropic.claude-sonnet-4-6\".limits]\n+[providers.bedrock.models.\"claude-sonnet-4-6\".limits]\n context_window = 1000000\n max_output = 64000\n \n-[models.\"us.anthropic.claude-sonnet-4-6\".features]\n+[providers.bedrock.models.\"claude-sonnet-4-6\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n \n-[models.\"us.anthropic.claude-sonnet-4-6\".costs]\n+[providers.bedrock.models.\"claude-sonnet-4-6\".costs]\n input_cost_per_mtok = 3.0\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.3\n \n-[models.\"us.anthropic.claude-opus-4-8\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"claude-opus-4-8\"]\n+api_id = \"us.anthropic.claude-opus-4-8\"\n display_name = \"Claude Opus 4.8 (Bedrock)\"\n family = \"claude-4\"\n billing_policy = \"anthropic\"\n \n-[models.\"us.anthropic.claude-opus-4-8\".limits]\n+[providers.bedrock.models.\"claude-opus-4-8\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"us.anthropic.claude-opus-4-8\".features]\n+[providers.bedrock.models.\"claude-opus-4-8\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n \n-[models.\"us.anthropic.claude-opus-4-8\".costs]\n+[providers.bedrock.models.\"claude-opus-4-8\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 25.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"us.anthropic.claude-haiku-4-5\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"claude-haiku-4-5\"]\n api_id = \"us.anthropic.claude-haiku-4-5-20251001-v1:0\"\n display_name = \"Claude Haiku 4.5 (Bedrock)\"\n family = \"claude-4\"\n billing_policy = \"anthropic\"\n small_default = true\n \n-[models.\"us.anthropic.claude-haiku-4-5\".limits]\n+[providers.bedrock.models.\"claude-haiku-4-5\".limits]\n context_window = 200000\n max_output = 64000\n \n-[models.\"us.anthropic.claude-haiku-4-5\".features]\n+[providers.bedrock.models.\"claude-haiku-4-5\".features]\n tools = true\n vision = true\n reasoning = false\n prompt_cache = true\n \n-[models.\"us.anthropic.claude-haiku-4-5\".costs]\n+[providers.bedrock.models.\"claude-haiku-4-5\".costs]\n input_cost_per_mtok = 1.0\n output_cost_per_mtok = 5.0\n cache_input_cost_per_mtok = 0.1\n@@ -116,149 +115,142 @@ cache_input_cost_per_mtok = 0.1\n # GPT-5.5/5.4 are NOT here: on Bedrock they are Responses-API-only on the\n # bedrock-mantle endpoint (no Converse), a named follow-up route.\n \n-[models.\"openai.gpt-oss-120b\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"gpt-oss-120b\"]\n api_id = \"openai.gpt-oss-120b-1:0\"\n display_name = \"GPT-OSS 120B (Bedrock)\"\n family = \"gpt-oss\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"openai.gpt-oss-120b\".limits]\n+[providers.bedrock.models.\"gpt-oss-120b\".limits]\n context_window = 128000\n max_output = 16384\n \n-[models.\"openai.gpt-oss-120b\".features]\n+[providers.bedrock.models.\"gpt-oss-120b\".features]\n tools = true\n vision = false\n reasoning = true\n \n-[models.\"openai.gpt-oss-120b\".costs]\n+[providers.bedrock.models.\"gpt-oss-120b\".costs]\n input_cost_per_mtok = 0.15\n output_cost_per_mtok = 0.60\n \n-[models.\"openai.gpt-oss-20b\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"gpt-oss-20b\"]\n api_id = \"openai.gpt-oss-20b-1:0\"\n display_name = \"GPT-OSS 20B (Bedrock)\"\n family = \"gpt-oss\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"openai.gpt-oss-20b\".limits]\n+[providers.bedrock.models.\"gpt-oss-20b\".limits]\n context_window = 128000\n max_output = 16384\n \n-[models.\"openai.gpt-oss-20b\".features]\n+[providers.bedrock.models.\"gpt-oss-20b\".features]\n tools = true\n vision = false\n reasoning = true\n \n-[models.\"openai.gpt-oss-20b\".costs]\n+[providers.bedrock.models.\"gpt-oss-20b\".costs]\n input_cost_per_mtok = 0.07\n output_cost_per_mtok = 0.30\n \n # ---------- Amazon Nova ----------\n \n-[models.\"amazon.nova-2-lite\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"nova-2-lite\"]\n api_id = \"global.amazon.nova-2-lite-v1:0\"\n display_name = \"Nova 2 Lite (Bedrock)\"\n family = \"nova-2\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"amazon.nova-2-lite\".limits]\n+[providers.bedrock.models.\"nova-2-lite\".limits]\n context_window = 1000000\n # Bedrock caps Nova output at 65535 (2^16 - 1); 65536 trips\n # \"maximum tokens exceeds the model limit of 65535\" since the prompt handler\n # defaults max_tokens to max_output.\n max_output = 65535\n \n-[models.\"amazon.nova-2-lite\".features]\n+[providers.bedrock.models.\"nova-2-lite\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"amazon.nova-2-lite\".costs]\n+[providers.bedrock.models.\"nova-2-lite\".costs]\n input_cost_per_mtok = 0.30\n output_cost_per_mtok = 2.50\n \n # ---------- Open-weights ----------\n \n-[models.\"meta.llama4-maverick\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"llama-4-maverick\"]\n api_id = \"us.meta.llama4-maverick-17b-instruct-v1:0\"\n display_name = \"Llama 4 Maverick (Bedrock)\"\n family = \"llama-4\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"meta.llama4-maverick\".limits]\n+[providers.bedrock.models.\"llama-4-maverick\".limits]\n context_window = 1000000\n max_output = 8192\n \n-[models.\"meta.llama4-maverick\".features]\n+[providers.bedrock.models.\"llama-4-maverick\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"mistral.mistral-large-3\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"mistral-large-3\"]\n api_id = \"mistral.mistral-large-3-675b-instruct\"\n display_name = \"Mistral Large 3 (Bedrock)\"\n family = \"mistral-large\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"mistral.mistral-large-3\".limits]\n+[providers.bedrock.models.\"mistral-large-3\".limits]\n context_window = 256000\n max_output = 32768\n \n-[models.\"mistral.mistral-large-3\".features]\n+[providers.bedrock.models.\"mistral-large-3\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"mistral.mistral-large-3\".costs]\n+[providers.bedrock.models.\"mistral-large-3\".costs]\n input_cost_per_mtok = 0.50\n output_cost_per_mtok = 1.50\n \n-[models.\"mistral.devstral-2\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"devstral-2\"]\n api_id = \"mistral.devstral-2-123b\"\n display_name = \"Devstral 2 (Bedrock)\"\n family = \"devstral\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"mistral.devstral-2\".limits]\n+[providers.bedrock.models.\"devstral-2\".limits]\n context_window = 256000\n max_output = 32768\n \n-[models.\"mistral.devstral-2\".features]\n+[providers.bedrock.models.\"devstral-2\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"deepseek.v3-2\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"deepseek-v3.2\"]\n api_id = \"deepseek.v3.2\"\n display_name = \"DeepSeek V3.2 (Bedrock)\"\n family = \"deepseek-v3\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"deepseek.v3-2\".limits]\n+[providers.bedrock.models.\"deepseek-v3.2\".limits]\n context_window = 164000\n max_output = 8192\n \n-[models.\"deepseek.v3-2\".features]\n+[providers.bedrock.models.\"deepseek-v3.2\".features]\n tools = true\n vision = false\n reasoning = true\n \n-[models.\"deepseek.v3-2\".costs]\n+[providers.bedrock.models.\"deepseek-v3.2\".costs]\n input_cost_per_mtok = 0.62\n output_cost_per_mtok = 1.85\n \n@@ -267,92 +259,90 @@ output_cost_per_mtok = 1.85\n # \"The provided model identifier is invalid\"), so this row needs an explicit\n # `api_id` confirmed against `aws bedrock list-inference-profiles` before it\n # ships. Re-add with:\n-# [models.\"qwen.qwen3-coder-next\"]\n-# provider = \"bedrock\"\n+# [providers.bedrock.models.\"qwen3-coder-next\"]\n # api_id = \"\"\n # display_name = \"Qwen3 Coder Next (Bedrock)\"\n # family = \"qwen3\"\n # billing_policy = \"openai\"\n # agent_profile = \"openai\"\n-# [models.\"qwen.qwen3-coder-next\".limits]\n+# [providers.bedrock.models.\"qwen3-coder-next\".limits]\n # context_window = 256000\n # max_output = 16384\n-# [models.\"qwen.qwen3-coder-next\".features]\n+# [providers.bedrock.models.\"qwen3-coder-next\".features]\n # tools = true\n \n-[models.\"moonshotai.kimi-k2.5\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"kimi-k2.5\"]\n+api_id = \"moonshotai.kimi-k2.5\"\n display_name = \"Kimi K2.5 (Bedrock)\"\n family = \"kimi-k2\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"moonshotai.kimi-k2.5\".limits]\n+[providers.bedrock.models.\"kimi-k2.5\".limits]\n context_window = 262144\n max_output = 16384\n \n-[models.\"moonshotai.kimi-k2.5\".features]\n+[providers.bedrock.models.\"kimi-k2.5\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"moonshotai.kimi-k2.5\".costs]\n+[providers.bedrock.models.\"kimi-k2.5\".costs]\n input_cost_per_mtok = 0.60\n output_cost_per_mtok = 3.00\n \n-[models.\"zai.glm-5\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"glm-5\"]\n+api_id = \"zai.glm-5\"\n display_name = \"GLM 5 (Bedrock)\"\n family = \"glm\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"zai.glm-5\".limits]\n+[providers.bedrock.models.\"glm-5\".limits]\n context_window = 200000\n max_output = 128000\n \n-[models.\"zai.glm-5\".features]\n+[providers.bedrock.models.\"glm-5\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"zai.glm-5\".costs]\n+[providers.bedrock.models.\"glm-5\".costs]\n input_cost_per_mtok = 1.00\n output_cost_per_mtok = 3.20\n \n-[models.\"minimax.minimax-m2.5\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"minimax-m2.5\"]\n+api_id = \"minimax.minimax-m2.5\"\n display_name = \"MiniMax M2.5 (Bedrock)\"\n family = \"minimax-m2\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"minimax.minimax-m2.5\".limits]\n+[providers.bedrock.models.\"minimax-m2.5\".limits]\n context_window = 196000\n max_output = 8192\n \n-[models.\"minimax.minimax-m2.5\".features]\n+[providers.bedrock.models.\"minimax-m2.5\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"minimax.minimax-m2.5\".costs]\n+[providers.bedrock.models.\"minimax-m2.5\".costs]\n input_cost_per_mtok = 0.30\n output_cost_per_mtok = 1.20\n \n-[models.\"nvidia.nemotron-3-super\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"nemotron-3-super\"]\n api_id = \"nvidia.nemotron-super-3-120b\"\n display_name = \"Nemotron 3 Super (Bedrock)\"\n family = \"nemotron-3\"\n billing_policy = \"openai\"\n agent_profile = \"openai\"\n \n-[models.\"nvidia.nemotron-3-super\".limits]\n+[providers.bedrock.models.\"nemotron-3-super\".limits]\n context_window = 256000\n max_output = 32768\n \n-[models.\"nvidia.nemotron-3-super\".features]\n+[providers.bedrock.models.\"nemotron-3-super\".features]\n tools = true\n vision = false\n reasoning = false\n@@ -365,24 +355,24 @@ reasoning = false\n # reasoning_effort stays undeclared here (requests carrying one are\n # rejected up front rather than silently dropped).\n \n-[models.\"us.anthropic.claude-fable-5\"]\n-provider = \"bedrock\"\n+[providers.bedrock.models.\"claude-fable-5\"]\n+api_id = \"us.anthropic.claude-fable-5\"\n display_name = \"Claude Fable 5 (Bedrock)\"\n family = \"claude-5\"\n billing_policy = \"anthropic\"\n \n-[models.\"us.anthropic.claude-fable-5\".limits]\n+[providers.bedrock.models.\"claude-fable-5\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"us.anthropic.claude-fable-5\".features]\n+[providers.bedrock.models.\"claude-fable-5\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n sampling_params = false\n \n-[models.\"us.anthropic.claude-fable-5\".costs]\n+[providers.bedrock.models.\"claude-fable-5\".costs]\n input_cost_per_mtok = 10.0\n output_cost_per_mtok = 50.0\n cache_input_cost_per_mtok = 1.0\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml\nindex 74ffc91de..a03c2a249 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml\n@@ -9,9 +9,7 @@ priority = 80\n credentials = [\"env:GEMINI_API_KEY\", \"env:GOOGLE_API_KEY\", \"vault:GEMINI_API_KEY\"]\n header = { custom = \"x-goog-api-key\" }\n \n-[models.\"gemini-3.1-pro-preview\"]\n-provider = \"gemini\"\n-api_id = \"gemini-3.1-pro-preview\"\n+[providers.gemini.models.\"gemini-3.1-pro-preview\"]\n display_name = \"Gemini 3.1 Pro (Preview)\"\n family = \"gemini-3\"\n training = \"2025-01-01\"\n@@ -19,24 +17,22 @@ knowledge_cutoff = \"January 2025\"\n estimated_output_tps = 85\n aliases = [\"gemini-pro\"]\n \n-[models.\"gemini-3.1-pro-preview\".limits]\n+[providers.gemini.models.\"gemini-3.1-pro-preview\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"gemini-3.1-pro-preview\".features]\n+[providers.gemini.models.\"gemini-3.1-pro-preview\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gemini-3.1-pro-preview\".costs]\n+[providers.gemini.models.\"gemini-3.1-pro-preview\".costs]\n input_cost_per_mtok = 2.0\n output_cost_per_mtok = 12.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"gemini-3.1-pro-preview-customtools\"]\n-provider = \"gemini\"\n-api_id = \"gemini-3.1-pro-preview-customtools\"\n+[providers.gemini.models.\"gemini-3.1-pro-preview-customtools\"]\n display_name = \"Gemini 3.1 Pro Custom Tools (Preview)\"\n family = \"gemini-3\"\n training = \"2025-01-01\"\n@@ -44,24 +40,22 @@ knowledge_cutoff = \"January 2025\"\n estimated_output_tps = 85\n aliases = [\"gemini-customtools\"]\n \n-[models.\"gemini-3.1-pro-preview-customtools\".limits]\n+[providers.gemini.models.\"gemini-3.1-pro-preview-customtools\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"gemini-3.1-pro-preview-customtools\".features]\n+[providers.gemini.models.\"gemini-3.1-pro-preview-customtools\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gemini-3.1-pro-preview-customtools\".costs]\n+[providers.gemini.models.\"gemini-3.1-pro-preview-customtools\".costs]\n input_cost_per_mtok = 2.0\n output_cost_per_mtok = 12.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"gemini-3.5-flash\"]\n-provider = \"gemini\"\n-api_id = \"gemini-3.5-flash\"\n+[providers.gemini.models.\"gemini-3.5-flash\"]\n display_name = \"Gemini 3.5 Flash\"\n family = \"gemini-3\"\n training = \"2025-01-01\"\n@@ -70,24 +64,22 @@ default = true\n estimated_output_tps = 150\n aliases = [\"gemini-35-flash\"]\n \n-[models.\"gemini-3.5-flash\".limits]\n+[providers.gemini.models.\"gemini-3.5-flash\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"gemini-3.5-flash\".features]\n+[providers.gemini.models.\"gemini-3.5-flash\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gemini-3.5-flash\".costs]\n+[providers.gemini.models.\"gemini-3.5-flash\".costs]\n input_cost_per_mtok = 1.5\n output_cost_per_mtok = 9.0\n cache_input_cost_per_mtok = 0.15\n \n-[models.\"gemini-3-flash-preview\"]\n-provider = \"gemini\"\n-api_id = \"gemini-3-flash-preview\"\n+[providers.gemini.models.\"gemini-3-flash-preview\"]\n display_name = \"Gemini 3 Flash (Preview)\"\n family = \"gemini-3\"\n training = \"2025-01-01\"\n@@ -95,24 +87,22 @@ knowledge_cutoff = \"January 2025\"\n estimated_output_tps = 150\n aliases = [\"gemini-flash\"]\n \n-[models.\"gemini-3-flash-preview\".limits]\n+[providers.gemini.models.\"gemini-3-flash-preview\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"gemini-3-flash-preview\".features]\n+[providers.gemini.models.\"gemini-3-flash-preview\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gemini-3-flash-preview\".costs]\n+[providers.gemini.models.\"gemini-3-flash-preview\".costs]\n input_cost_per_mtok = 0.5\n output_cost_per_mtok = 3.0\n cache_input_cost_per_mtok = 0.125\n \n-[models.\"gemini-3.1-flash-lite\"]\n-provider = \"gemini\"\n-api_id = \"gemini-3.1-flash-lite\"\n+[providers.gemini.models.\"gemini-3.1-flash-lite\"]\n display_name = \"Gemini 3.1 Flash Lite\"\n family = \"gemini-3\"\n training = \"2025-01-01\"\n@@ -121,17 +111,17 @@ estimated_output_tps = 200\n aliases = [\"gemini-flash-lite\", \"gemini-3.1-flash-lite-preview\"]\n small_default = true\n \n-[models.\"gemini-3.1-flash-lite\".limits]\n+[providers.gemini.models.\"gemini-3.1-flash-lite\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"gemini-3.1-flash-lite\".features]\n+[providers.gemini.models.\"gemini-3.1-flash-lite\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gemini-3.1-flash-lite\".costs]\n+[providers.gemini.models.\"gemini-3.1-flash-lite\".costs]\n input_cost_per_mtok = 0.25\n output_cost_per_mtok = 1.5\n cache_input_cost_per_mtok = 0.025\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/crates/fabro-model/src/catalog/providers/inception.toml\nindex 1d2b08a64..965120f27 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/inception.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/inception.toml\n@@ -8,25 +8,23 @@ priority = 40\n [providers.inception.auth]\n credentials = [\"env:INCEPTION_API_KEY\", \"vault:INCEPTION_API_KEY\"]\n \n-[models.\"mercury-2\"]\n-provider = \"inception\"\n-api_id = \"mercury-2\"\n+[providers.inception.models.\"mercury-2\"]\n display_name = \"Mercury 2\"\n family = \"mercury\"\n default = true\n estimated_output_tps = 1000\n aliases = [\"mercury\"]\n \n-[models.\"mercury-2\".limits]\n+[providers.inception.models.\"mercury-2\".limits]\n context_window = 131072\n max_output = 50000\n \n-[models.\"mercury-2\".features]\n+[providers.inception.models.\"mercury-2\".features]\n tools = true\n vision = false\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"mercury-2\".costs]\n+[providers.inception.models.\"mercury-2\".costs]\n input_cost_per_mtok = 0.25\n output_cost_per_mtok = 0.75\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/kimi.toml b/lib/crates/fabro-model/src/catalog/providers/kimi.toml\nindex c57779116..daa4b20c2 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/kimi.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/kimi.toml\n@@ -8,46 +8,42 @@ priority = 70\n [providers.kimi.auth]\n credentials = [\"env:KIMI_API_KEY\", \"vault:KIMI_API_KEY\"]\n \n-[models.\"kimi-k2.5\"]\n-provider = \"kimi\"\n-api_id = \"kimi-k2.5\"\n+[providers.kimi.models.\"kimi-k2.5\"]\n display_name = \"Kimi K2.5\"\n family = \"kimi-k2\"\n training = \"2025-10-01\"\n knowledge_cutoff = \"October 2025\"\n estimated_output_tps = 50\n \n-[models.\"kimi-k2.5\".limits]\n+[providers.kimi.models.\"kimi-k2.5\".limits]\n context_window = 262144\n max_output = 32768\n \n-[models.\"kimi-k2.5\".features]\n+[providers.kimi.models.\"kimi-k2.5\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n sampling_params = false\n \n-[models.\"kimi-k2.5\".costs]\n+[providers.kimi.models.\"kimi-k2.5\".costs]\n input_cost_per_mtok = 0.6\n output_cost_per_mtok = 3.0\n cache_input_cost_per_mtok = 0.1\n \n-[models.\"kimi-k3\"]\n-provider = \"kimi\"\n-api_id = \"kimi-k3\"\n+[providers.kimi.models.\"kimi-k3\"]\n display_name = \"Kimi K3\"\n family = \"kimi-k3\"\n default = true\n aliases = [\"kimi\"]\n \n-[models.\"kimi-k3\".limits]\n+[providers.kimi.models.\"kimi-k3\".limits]\n context_window = 1048576\n # K3 accepts explicit completion budgets up to 1048576, but Fabro also uses\n # max_output as the default request budget. Match Kimi's 131072-token default.\n max_output = 131072\n \n-[models.\"kimi-k3\".features]\n+[providers.kimi.models.\"kimi-k3\".features]\n tools = true\n vision = true\n reasoning = true\n@@ -55,10 +51,10 @@ reasoning_effort = \"always_adaptive\"\n prompt_cache = true\n sampling_params = false\n \n-[models.\"kimi-k3\".controls]\n+[providers.kimi.models.\"kimi-k3\".controls]\n reasoning_effort = [\"low\", \"high\", \"max\"]\n \n-[models.\"kimi-k3\".costs]\n+[providers.kimi.models.\"kimi-k3\".costs]\n input_cost_per_mtok = 3.0\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.3\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/litellm.toml b/lib/crates/fabro-model/src/catalog/providers/litellm.toml\nindex 55f5aef15..1307378c1 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/litellm.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/litellm.toml\n@@ -14,18 +14,17 @@ credentials = [\"env:LITELLM_API_KEY\", \"vault:LITELLM_API_KEY\"]\n # enabled = true\n # base_url = \"http://localhost:4000/v1\"\n #\n-# [llm.models.\"litellm-gpt-5\"]\n-# provider = \"litellm\"\n+# [llm.providers.litellm.models.\"litellm-gpt-5\"]\n # api_id = \"gpt-5\"\n # display_name = \"LiteLLM GPT-5\"\n # family = \"litellm\"\n # default = true\n #\n-# [llm.models.\"litellm-gpt-5\".limits]\n+# [llm.providers.litellm.models.\"litellm-gpt-5\".limits]\n # context_window = 128000\n # max_output = 8192\n #\n-# [llm.models.\"litellm-gpt-5\".features]\n+# [llm.providers.litellm.models.\"litellm-gpt-5\".features]\n # tools = true\n # vision = false\n # reasoning = false\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/minimax.toml b/lib/crates/fabro-model/src/catalog/providers/minimax.toml\nindex 172a6fd6a..e68dfc290 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/minimax.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/minimax.toml\n@@ -8,24 +8,22 @@ priority = 50\n [providers.minimax.auth]\n credentials = [\"env:MINIMAX_API_KEY\", \"vault:MINIMAX_API_KEY\"]\n \n-[models.\"minimax-m2.5\"]\n-provider = \"minimax\"\n-api_id = \"minimax-m2.5\"\n+[providers.minimax.models.\"minimax-m2.5\"]\n display_name = \"Minimax M2.5\"\n family = \"minimax-m2\"\n default = true\n estimated_output_tps = 45\n aliases = [\"minimax\"]\n \n-[models.\"minimax-m2.5\".limits]\n+[providers.minimax.models.\"minimax-m2.5\".limits]\n context_window = 196608\n max_output = 16384\n \n-[models.\"minimax-m2.5\".features]\n+[providers.minimax.models.\"minimax-m2.5\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"minimax-m2.5\".costs]\n+[providers.minimax.models.\"minimax-m2.5\".costs]\n input_cost_per_mtok = 0.3\n output_cost_per_mtok = 1.2\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/ollama.toml b/lib/crates/fabro-model/src/catalog/providers/ollama.toml\nindex bf3b16f5e..78dc5db69 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/ollama.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/ollama.toml\n@@ -9,18 +9,17 @@ enabled = false\n # Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true`\n # above) to expose it through the OpenAI-compatible adapter.\n #\n-# [models.\"qwen3.5\"]\n-# provider = \"ollama\"\n+# [providers.ollama.models.\"qwen3.5\"]\n # api_id = \"qwen3.5:latest\"\n # display_name = \"Qwen3.5\"\n # family = \"qwen3.5\"\n # default = true\n # aliases = [\"ollama-qwen3.5\"]\n #\n-# [models.\"qwen3.5\".limits]\n+# [providers.ollama.models.\"qwen3.5\".limits]\n # context_window = 32768\n #\n-# [models.\"qwen3.5\".features]\n+# [providers.ollama.models.\"qwen3.5\".features]\n # tools = true\n # vision = false\n # reasoning = false\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml\nindex 56fe7a03c..9ae21c91f 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/openai.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml\n@@ -8,9 +8,7 @@ priority = 90\n [providers.openai.auth]\n credentials = [\"env:OPENAI_API_KEY\", \"vault:OPENAI_API_KEY\", \"vault:OPENAI_CODEX\"]\n \n-[models.\"gpt-5.6-sol\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.6-sol\"\n+[providers.openai.models.\"gpt-5.6-sol\"]\n display_name = \"GPT-5.6 Sol\"\n family = \"gpt-5\"\n training = \"2026-02-16\"\n@@ -18,75 +16,69 @@ knowledge_cutoff = \"February 16, 2026\"\n default = true\n aliases = [\"gpt56-sol\", \"gpt-56-sol\", \"gpt-5.6\", \"gpt56\", \"gpt-56\"]\n \n-[models.\"gpt-5.6-sol\".limits]\n+[providers.openai.models.\"gpt-5.6-sol\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"gpt-5.6-sol\".features]\n+[providers.openai.models.\"gpt-5.6-sol\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"gpt-5.6-sol\".costs]\n+[providers.openai.models.\"gpt-5.6-sol\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 30.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"gpt-5.6-terra\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.6-terra\"\n+[providers.openai.models.\"gpt-5.6-terra\"]\n display_name = \"GPT-5.6 Terra\"\n family = \"gpt-5\"\n training = \"2026-02-16\"\n knowledge_cutoff = \"February 16, 2026\"\n aliases = [\"gpt56-terra\", \"gpt-56-terra\"]\n \n-[models.\"gpt-5.6-terra\".limits]\n+[providers.openai.models.\"gpt-5.6-terra\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"gpt-5.6-terra\".features]\n+[providers.openai.models.\"gpt-5.6-terra\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"gpt-5.6-terra\".costs]\n+[providers.openai.models.\"gpt-5.6-terra\".costs]\n input_cost_per_mtok = 2.5\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.25\n \n-[models.\"gpt-5.6-luna\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.6-luna\"\n+[providers.openai.models.\"gpt-5.6-luna\"]\n display_name = \"GPT-5.6 Luna\"\n family = \"gpt-5\"\n training = \"2026-02-16\"\n knowledge_cutoff = \"February 16, 2026\"\n aliases = [\"gpt56-luna\", \"gpt-56-luna\"]\n \n-[models.\"gpt-5.6-luna\".limits]\n+[providers.openai.models.\"gpt-5.6-luna\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"gpt-5.6-luna\".features]\n+[providers.openai.models.\"gpt-5.6-luna\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"gpt-5.6-luna\".costs]\n+[providers.openai.models.\"gpt-5.6-luna\".costs]\n input_cost_per_mtok = 1.0\n output_cost_per_mtok = 6.0\n cache_input_cost_per_mtok = 0.1\n \n-[models.\"gpt-5.4\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.4\"\n+[providers.openai.models.\"gpt-5.4\"]\n display_name = \"GPT-5.4\"\n family = \"gpt-5\"\n training = \"2025-08-31\"\n@@ -94,24 +86,22 @@ knowledge_cutoff = \"April 2025\"\n estimated_output_tps = 70\n aliases = [\"gpt54\", \"gpt-54\", \"gpt-5.2\", \"gpt5\", \"gpt-5.3-codex\", \"codex\"]\n \n-[models.\"gpt-5.4\".limits]\n+[providers.openai.models.\"gpt-5.4\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"gpt-5.4\".features]\n+[providers.openai.models.\"gpt-5.4\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gpt-5.4\".costs]\n+[providers.openai.models.\"gpt-5.4\".costs]\n input_cost_per_mtok = 2.5\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.25\n \n-[models.\"gpt-5.5\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.5\"\n+[providers.openai.models.\"gpt-5.5\"]\n display_name = \"GPT-5.5\"\n family = \"gpt-5\"\n training = \"2025-12-01\"\n@@ -119,24 +109,22 @@ knowledge_cutoff = \"December 2025\"\n estimated_output_tps = 70\n aliases = [\"gpt55\", \"gpt-55\"]\n \n-[models.\"gpt-5.5\".limits]\n+[providers.openai.models.\"gpt-5.5\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"gpt-5.5\".features]\n+[providers.openai.models.\"gpt-5.5\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gpt-5.5\".costs]\n+[providers.openai.models.\"gpt-5.5\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 30.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"gpt-5.5-pro\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.5-pro\"\n+[providers.openai.models.\"gpt-5.5-pro\"]\n display_name = \"GPT-5.5 Pro\"\n family = \"gpt-5\"\n training = \"2025-12-01\"\n@@ -144,24 +132,22 @@ knowledge_cutoff = \"December 2025\"\n estimated_output_tps = 20\n aliases = [\"gpt55-pro\", \"gpt-55-pro\"]\n \n-[models.\"gpt-5.5-pro\".limits]\n+[providers.openai.models.\"gpt-5.5-pro\".limits]\n context_window = 1050000\n max_output = 128000\n \n-[models.\"gpt-5.5-pro\".features]\n+[providers.openai.models.\"gpt-5.5-pro\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gpt-5.5-pro\".costs]\n+[providers.openai.models.\"gpt-5.5-pro\".costs]\n input_cost_per_mtok = 30.0\n output_cost_per_mtok = 180.0\n cache_input_cost_per_mtok = 3.0\n \n-[models.\"gpt-5.4-pro\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.4-pro\"\n+[providers.openai.models.\"gpt-5.4-pro\"]\n display_name = \"GPT-5.4 Pro\"\n family = \"gpt-5\"\n training = \"2025-08-31\"\n@@ -169,24 +155,22 @@ knowledge_cutoff = \"April 2025\"\n estimated_output_tps = 20\n aliases = [\"gpt54-pro\", \"gpt-54-pro\"]\n \n-[models.\"gpt-5.4-pro\".limits]\n+[providers.openai.models.\"gpt-5.4-pro\".limits]\n context_window = 1047576\n max_output = 128000\n \n-[models.\"gpt-5.4-pro\".features]\n+[providers.openai.models.\"gpt-5.4-pro\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gpt-5.4-pro\".costs]\n+[providers.openai.models.\"gpt-5.4-pro\".costs]\n input_cost_per_mtok = 30.0\n output_cost_per_mtok = 180.0\n cache_input_cost_per_mtok = 3.0\n \n-[models.\"gpt-5.4-mini\"]\n-provider = \"openai\"\n-api_id = \"gpt-5.4-mini\"\n+[providers.openai.models.\"gpt-5.4-mini\"]\n display_name = \"GPT-5.4 Mini\"\n family = \"gpt-5\"\n training = \"2025-08-31\"\n@@ -196,17 +180,17 @@ aliases = [\"gpt54-mini\", \"gpt-54-mini\", \"gpt-5.3-codex-spark\", \"codex-spark\"]\n probe = true\n small_default = true\n \n-[models.\"gpt-5.4-mini\".limits]\n+[providers.openai.models.\"gpt-5.4-mini\".limits]\n context_window = 272000\n max_output = 128000\n \n-[models.\"gpt-5.4-mini\".features]\n+[providers.openai.models.\"gpt-5.4-mini\".features]\n tools = true\n vision = true\n reasoning = true\n reasoning_effort = \"levels\"\n \n-[models.\"gpt-5.4-mini\".costs]\n+[providers.openai.models.\"gpt-5.4-mini\".costs]\n input_cost_per_mtok = 0.75\n output_cost_per_mtok = 4.5\n cache_input_cost_per_mtok = 0.075\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml\nindex 86fd43319..bad07290a 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml\n@@ -33,262 +33,249 @@ credentials = [\"env:OPENROUTER_API_KEY\", \"vault:OPENROUTER_API_KEY\"]\n # best-effort estimates; OpenRouter returns the authoritative usage.cost\n # in-band on every response.\n \n-[models.\"anthropic/claude-opus-4-7\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"claude-opus-4-7\"]\n api_id = \"anthropic/claude-opus-4.7\"\n display_name = \"Claude Opus 4.7 (via OpenRouter)\"\n family = \"claude-4\"\n billing_policy = \"anthropic\"\n \n-[models.\"anthropic/claude-opus-4-7\".limits]\n+[providers.openrouter.models.\"claude-opus-4-7\".limits]\n context_window = 1000000\n max_output = 128000\n \n-[models.\"anthropic/claude-opus-4-7\".features]\n+[providers.openrouter.models.\"claude-opus-4-7\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n \n-[models.\"anthropic/claude-opus-4-7\".costs]\n+[providers.openrouter.models.\"claude-opus-4-7\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 25.0\n cache_input_cost_per_mtok = 0.5\n \n-[models.\"anthropic/claude-sonnet-4-6\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"claude-sonnet-4-6\"]\n api_id = \"anthropic/claude-sonnet-4.6\"\n display_name = \"Claude Sonnet 4.6 (via OpenRouter)\"\n family = \"claude-4\"\n billing_policy = \"anthropic\"\n default = true\n \n-[models.\"anthropic/claude-sonnet-4-6\".limits]\n+[providers.openrouter.models.\"claude-sonnet-4-6\".limits]\n context_window = 1000000\n max_output = 64000\n \n-[models.\"anthropic/claude-sonnet-4-6\".features]\n+[providers.openrouter.models.\"claude-sonnet-4-6\".features]\n tools = true\n vision = true\n reasoning = true\n prompt_cache = true\n \n-[models.\"anthropic/claude-sonnet-4-6\".costs]\n+[providers.openrouter.models.\"claude-sonnet-4-6\".costs]\n input_cost_per_mtok = 3.0\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.3\n \n-[models.\"anthropic/claude-haiku-4-5\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"claude-haiku-4-5\"]\n api_id = \"anthropic/claude-haiku-4.5\"\n display_name = \"Claude Haiku 4.5 (via OpenRouter)\"\n family = \"claude-4\"\n billing_policy = \"anthropic\"\n small_default = true\n \n-[models.\"anthropic/claude-haiku-4-5\".limits]\n+[providers.openrouter.models.\"claude-haiku-4-5\".limits]\n context_window = 200000\n max_output = 8192\n \n-[models.\"anthropic/claude-haiku-4-5\".features]\n+[providers.openrouter.models.\"claude-haiku-4-5\".features]\n tools = true\n vision = true\n reasoning = false\n prompt_cache = true\n \n-[models.\"anthropic/claude-haiku-4-5\".costs]\n+[providers.openrouter.models.\"claude-haiku-4-5\".costs]\n input_cost_per_mtok = 1.0\n output_cost_per_mtok = 5.0\n cache_input_cost_per_mtok = 0.1\n \n # ---------- OpenAI via OpenRouter ----------\n \n-[models.\"openai/gpt-5.4\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"gpt-5.4\"]\n api_id = \"openai/gpt-5.4\"\n display_name = \"GPT-5.4 (via OpenRouter)\"\n family = \"gpt-5\"\n \n-[models.\"openai/gpt-5.4\".limits]\n+[providers.openrouter.models.\"gpt-5.4\".limits]\n context_window = 1050000\n max_output = 32768\n \n-[models.\"openai/gpt-5.4\".features]\n+[providers.openrouter.models.\"gpt-5.4\".features]\n tools = true\n vision = true\n reasoning = true\n \n-[models.\"openai/gpt-5.4\".costs]\n+[providers.openrouter.models.\"gpt-5.4\".costs]\n input_cost_per_mtok = 2.5\n output_cost_per_mtok = 15.0\n \n-[models.\"openai/gpt-5.5\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"gpt-5.5\"]\n api_id = \"openai/gpt-5.5\"\n display_name = \"GPT-5.5 (via OpenRouter)\"\n family = \"gpt-5\"\n \n-[models.\"openai/gpt-5.5\".limits]\n+[providers.openrouter.models.\"gpt-5.5\".limits]\n context_window = 1050000\n max_output = 32768\n \n-[models.\"openai/gpt-5.5\".features]\n+[providers.openrouter.models.\"gpt-5.5\".features]\n tools = true\n vision = true\n reasoning = true\n \n-[models.\"openai/gpt-5.5\".costs]\n+[providers.openrouter.models.\"gpt-5.5\".costs]\n input_cost_per_mtok = 5.0\n output_cost_per_mtok = 30.0\n \n # ---------- Google Gemini via OpenRouter ----------\n \n-[models.\"google/gemini-3.1-pro-preview\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"gemini-3.1-pro-preview\"]\n api_id = \"google/gemini-3.1-pro-preview\"\n display_name = \"Gemini 3.1 Pro Preview (via OpenRouter)\"\n family = \"gemini-3\"\n \n-[models.\"google/gemini-3.1-pro-preview\".limits]\n+[providers.openrouter.models.\"gemini-3.1-pro-preview\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"google/gemini-3.1-pro-preview\".features]\n+[providers.openrouter.models.\"gemini-3.1-pro-preview\".features]\n tools = true\n vision = true\n reasoning = true\n \n-[models.\"google/gemini-3.1-pro-preview\".costs]\n+[providers.openrouter.models.\"gemini-3.1-pro-preview\".costs]\n input_cost_per_mtok = 2.0\n output_cost_per_mtok = 12.0\n \n-[models.\"google/gemini-3.5-flash\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"gemini-3.5-flash\"]\n api_id = \"google/gemini-3.5-flash\"\n display_name = \"Gemini 3.5 Flash (via OpenRouter)\"\n family = \"gemini-3\"\n \n-[models.\"google/gemini-3.5-flash\".limits]\n+[providers.openrouter.models.\"gemini-3.5-flash\".limits]\n context_window = 1048576\n max_output = 65536\n \n-[models.\"google/gemini-3.5-flash\".features]\n+[providers.openrouter.models.\"gemini-3.5-flash\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"google/gemini-3.5-flash\".costs]\n+[providers.openrouter.models.\"gemini-3.5-flash\".costs]\n input_cost_per_mtok = 1.5\n output_cost_per_mtok = 9.0\n \n # ---------- Open-weights models ----------\n \n-[models.\"xiaomi/mimo-v2.5-pro\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"mimo-v2.5-pro\"]\n api_id = \"xiaomi/mimo-v2.5-pro\"\n display_name = \"Xiaomi MiMo v2.5 Pro\"\n family = \"mimo-v2\"\n \n-[models.\"xiaomi/mimo-v2.5-pro\".limits]\n+[providers.openrouter.models.\"mimo-v2.5-pro\".limits]\n context_window = 1050000\n max_output = 16384\n \n-[models.\"xiaomi/mimo-v2.5-pro\".features]\n+[providers.openrouter.models.\"mimo-v2.5-pro\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"xiaomi/mimo-v2.5-pro\".costs]\n+[providers.openrouter.models.\"mimo-v2.5-pro\".costs]\n input_cost_per_mtok = 0.435\n output_cost_per_mtok = 0.87\n \n-[models.\"minimax/minimax-m2.7\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"minimax-m2.7\"]\n api_id = \"minimax/minimax-m2.7\"\n display_name = \"MiniMax M2.7\"\n family = \"minimax-m2\"\n \n-[models.\"minimax/minimax-m2.7\".limits]\n+[providers.openrouter.models.\"minimax-m2.7\".limits]\n context_window = 200000\n max_output = 16384\n \n-[models.\"minimax/minimax-m2.7\".features]\n+[providers.openrouter.models.\"minimax-m2.7\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"minimax/minimax-m2.7\".costs]\n+[providers.openrouter.models.\"minimax-m2.7\".costs]\n input_cost_per_mtok = 0.28\n output_cost_per_mtok = 1.20\n \n-[models.\"deepseek/deepseek-v4-pro\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"deepseek-v4-pro\"]\n api_id = \"deepseek/deepseek-v4-pro\"\n display_name = \"DeepSeek V4 Pro\"\n family = \"deepseek-v4\"\n \n-[models.\"deepseek/deepseek-v4-pro\".limits]\n+[providers.openrouter.models.\"deepseek-v4-pro\".limits]\n context_window = 1050000\n max_output = 16384\n \n-[models.\"deepseek/deepseek-v4-pro\".features]\n+[providers.openrouter.models.\"deepseek-v4-pro\".features]\n tools = true\n vision = false\n reasoning = true\n \n-[models.\"deepseek/deepseek-v4-pro\".costs]\n+[providers.openrouter.models.\"deepseek-v4-pro\".costs]\n input_cost_per_mtok = 0.435\n output_cost_per_mtok = 0.87\n \n-[models.\"deepseek/deepseek-v4-flash\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"deepseek-v4-flash\"]\n api_id = \"deepseek/deepseek-v4-flash\"\n display_name = \"DeepSeek V4 Flash\"\n family = \"deepseek-v4\"\n \n-[models.\"deepseek/deepseek-v4-flash\".limits]\n+[providers.openrouter.models.\"deepseek-v4-flash\".limits]\n context_window = 1050000\n max_output = 16384\n \n-[models.\"deepseek/deepseek-v4-flash\".features]\n+[providers.openrouter.models.\"deepseek-v4-flash\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"deepseek/deepseek-v4-flash\".costs]\n+[providers.openrouter.models.\"deepseek-v4-flash\".costs]\n input_cost_per_mtok = 0.10\n output_cost_per_mtok = 0.20\n \n-[models.\"moonshotai/kimi-k2.6\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"kimi-k2.6\"]\n api_id = \"moonshotai/kimi-k2.6\"\n display_name = \"Kimi K2.6\"\n family = \"kimi-k2\"\n \n-[models.\"moonshotai/kimi-k2.6\".limits]\n+[providers.openrouter.models.\"kimi-k2.6\".limits]\n context_window = 262144\n max_output = 16384\n \n-[models.\"moonshotai/kimi-k2.6\".features]\n+[providers.openrouter.models.\"kimi-k2.6\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"moonshotai/kimi-k2.6\".costs]\n+[providers.openrouter.models.\"kimi-k2.6\".costs]\n input_cost_per_mtok = 0.73\n output_cost_per_mtok = 3.49\n \n-[models.\"moonshotai/kimi-k3\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"kimi-k3\"]\n api_id = \"moonshotai/kimi-k3\"\n display_name = \"Kimi K3 (via OpenRouter)\"\n family = \"kimi-k3\"\n \n-[models.\"moonshotai/kimi-k3\".limits]\n+[providers.openrouter.models.\"kimi-k3\".limits]\n context_window = 1048576\n max_output = 131072\n \n-[models.\"moonshotai/kimi-k3\".features]\n+[providers.openrouter.models.\"kimi-k3\".features]\n tools = true\n vision = true\n reasoning = true\n@@ -296,47 +283,45 @@ reasoning_effort = \"always_adaptive\"\n prompt_cache = true\n sampling_params = false\n \n-[models.\"moonshotai/kimi-k3\".controls]\n+[providers.openrouter.models.\"kimi-k3\".controls]\n reasoning_effort = [\"low\", \"high\", \"max\"]\n \n-[models.\"moonshotai/kimi-k3\".costs]\n+[providers.openrouter.models.\"kimi-k3\".costs]\n input_cost_per_mtok = 3.0\n output_cost_per_mtok = 15.0\n cache_input_cost_per_mtok = 0.3\n \n-[models.\"poolside/laguna-s-2.1\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"laguna-s-2.1\"]\n api_id = \"poolside/laguna-s-2.1\"\n display_name = \"Laguna S 2.1 (via OpenRouter)\"\n family = \"laguna-2\"\n \n-[models.\"poolside/laguna-s-2.1\".limits]\n+[providers.openrouter.models.\"laguna-s-2.1\".limits]\n context_window = 1048576\n max_output = 131072\n \n-[models.\"poolside/laguna-s-2.1\".features]\n+[providers.openrouter.models.\"laguna-s-2.1\".features]\n tools = true\n vision = false\n reasoning = true\n prompt_cache = true\n sampling_params = true\n \n-[models.\"poolside/laguna-s-2.1\".costs]\n+[providers.openrouter.models.\"laguna-s-2.1\".costs]\n input_cost_per_mtok = 0.10\n output_cost_per_mtok = 0.20\n cache_input_cost_per_mtok = 0.01\n \n-[models.\"poolside/laguna-xs-2.1\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"laguna-xs-2.1\"]\n api_id = \"poolside/laguna-xs-2.1\"\n display_name = \"Laguna XS 2.1 (via OpenRouter)\"\n family = \"laguna-2\"\n \n-[models.\"poolside/laguna-xs-2.1\".limits]\n+[providers.openrouter.models.\"laguna-xs-2.1\".limits]\n context_window = 262144\n max_output = 32768\n \n-[models.\"poolside/laguna-xs-2.1\".features]\n+[providers.openrouter.models.\"laguna-xs-2.1\".features]\n tools = true\n vision = false\n reasoning = true\n@@ -345,127 +330,121 @@ sampling_params = true\n \n # Current promotional rate. OpenRouter's authoritative in-band usage.cost\n # supersedes this estimate on completed responses.\n-[models.\"poolside/laguna-xs-2.1\".costs]\n+[providers.openrouter.models.\"laguna-xs-2.1\".costs]\n input_cost_per_mtok = 0.06\n output_cost_per_mtok = 0.12\n cache_input_cost_per_mtok = 0.03\n \n-[models.\"qwen/qwen3-coder\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"qwen3-coder\"]\n api_id = \"qwen/qwen3-coder\"\n display_name = \"Qwen3 Coder\"\n family = \"qwen3\"\n \n-[models.\"qwen/qwen3-coder\".limits]\n+[providers.openrouter.models.\"qwen3-coder\".limits]\n context_window = 1050000\n max_output = 16384\n \n-[models.\"qwen/qwen3-coder\".features]\n+[providers.openrouter.models.\"qwen3-coder\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"qwen/qwen3-coder\".costs]\n+[providers.openrouter.models.\"qwen3-coder\".costs]\n input_cost_per_mtok = 0.22\n output_cost_per_mtok = 1.80\n \n-[models.\"qwen/qwen3.6-flash\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"qwen3.6-flash\"]\n api_id = \"qwen/qwen3.6-flash\"\n display_name = \"Qwen3.6 Flash\"\n family = \"qwen3\"\n \n-[models.\"qwen/qwen3.6-flash\".limits]\n+[providers.openrouter.models.\"qwen3.6-flash\".limits]\n context_window = 1000000\n max_output = 16384\n \n-[models.\"qwen/qwen3.6-flash\".features]\n+[providers.openrouter.models.\"qwen3.6-flash\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"qwen/qwen3.6-flash\".costs]\n+[providers.openrouter.models.\"qwen3.6-flash\".costs]\n input_cost_per_mtok = 0.1875\n output_cost_per_mtok = 1.125\n \n-[models.\"z-ai/glm-5.2\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"glm-5.2\"]\n api_id = \"z-ai/glm-5.2\"\n display_name = \"GLM 5.2 (via OpenRouter)\"\n family = \"glm-5\"\n \n-[models.\"z-ai/glm-5.2\".limits]\n+[providers.openrouter.models.\"glm-5.2\".limits]\n context_window = 1048576\n max_output = 131072\n \n-[models.\"z-ai/glm-5.2\".features]\n+[providers.openrouter.models.\"glm-5.2\".features]\n tools = true\n vision = false\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"z-ai/glm-5.2\".controls]\n+[providers.openrouter.models.\"glm-5.2\".controls]\n reasoning_effort = [\"high\", \"xhigh\"]\n \n-[models.\"z-ai/glm-5.2\".costs]\n+[providers.openrouter.models.\"glm-5.2\".costs]\n input_cost_per_mtok = 0.784\n output_cost_per_mtok = 2.464\n cache_input_cost_per_mtok = 0.1456\n \n-[models.\"z-ai/glm-4.6\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"glm-4.6\"]\n api_id = \"z-ai/glm-4.6\"\n display_name = \"GLM 4.6\"\n family = \"glm-4\"\n \n-[models.\"z-ai/glm-4.6\".limits]\n+[providers.openrouter.models.\"glm-4.6\".limits]\n context_window = 203000\n max_output = 16384\n \n-[models.\"z-ai/glm-4.6\".features]\n+[providers.openrouter.models.\"glm-4.6\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"z-ai/glm-4.6\".costs]\n+[providers.openrouter.models.\"glm-4.6\".costs]\n input_cost_per_mtok = 0.43\n output_cost_per_mtok = 1.74\n \n-[models.\"nvidia/nemotron-3-super-120b-a12b\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"nemotron-3-super\"]\n api_id = \"nvidia/nemotron-3-super-120b-a12b\"\n display_name = \"NVIDIA Nemotron 3 Super 120B\"\n family = \"nemotron-3\"\n \n-[models.\"nvidia/nemotron-3-super-120b-a12b\".limits]\n+[providers.openrouter.models.\"nemotron-3-super\".limits]\n context_window = 1000000\n max_output = 16384\n \n-[models.\"nvidia/nemotron-3-super-120b-a12b\".features]\n+[providers.openrouter.models.\"nemotron-3-super\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"nvidia/nemotron-3-super-120b-a12b\".costs]\n+[providers.openrouter.models.\"nemotron-3-super\".costs]\n input_cost_per_mtok = 0.09\n output_cost_per_mtok = 0.45\n \n-[models.\"mistralai/devstral-2512\"]\n-provider = \"openrouter\"\n+[providers.openrouter.models.\"devstral-2\"]\n api_id = \"mistralai/devstral-2512\"\n display_name = \"Devstral 2512\"\n family = \"devstral\"\n \n-[models.\"mistralai/devstral-2512\".limits]\n+[providers.openrouter.models.\"devstral-2\".limits]\n context_window = 262144\n max_output = 16384\n \n-[models.\"mistralai/devstral-2512\".features]\n+[providers.openrouter.models.\"devstral-2\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"mistralai/devstral-2512\".costs]\n+[providers.openrouter.models.\"devstral-2\".costs]\n input_cost_per_mtok = 0.40\n output_cost_per_mtok = 2.00\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/poolside.toml b/lib/crates/fabro-model/src/catalog/providers/poolside.toml\nindex 8fd1b6855..65ce87246 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/poolside.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/poolside.toml\n@@ -8,19 +8,18 @@ priority = 65\n [providers.poolside.auth]\n credentials = [\"env:POOLSIDE_API_KEY\", \"vault:POOLSIDE_API_KEY\"]\n \n-[models.\"laguna-s-2.1\"]\n-provider = \"poolside\"\n+[providers.poolside.models.\"laguna-s-2.1\"]\n api_id = \"poolside/laguna-s-2.1\"\n display_name = \"Laguna S 2.1\"\n family = \"laguna-2\"\n default = true\n aliases = [\"laguna\", \"laguna-s\"]\n \n-[models.\"laguna-s-2.1\".limits]\n+[providers.poolside.models.\"laguna-s-2.1\".limits]\n context_window = 1048576\n max_output = 131072\n \n-[models.\"laguna-s-2.1\".features]\n+[providers.poolside.models.\"laguna-s-2.1\".features]\n tools = true\n vision = false\n reasoning = true\n@@ -30,13 +29,12 @@ sampling_params = true\n # Poolside Platform is free for a limited preview period. Keep the published\n # paid hosted rate as Fabro's durable estimate for paid access and post-preview\n # usage.\n-[models.\"laguna-s-2.1\".costs]\n+[providers.poolside.models.\"laguna-s-2.1\".costs]\n input_cost_per_mtok = 0.10\n output_cost_per_mtok = 0.20\n cache_input_cost_per_mtok = 0.01\n \n-[models.\"laguna-xs-2.1\"]\n-provider = \"poolside\"\n+[providers.poolside.models.\"laguna-xs-2.1\"]\n api_id = \"poolside/laguna-xs-2.1\"\n display_name = \"Laguna XS 2.1\"\n family = \"laguna-2\"\n@@ -44,11 +42,11 @@ small_default = true\n probe = true\n aliases = [\"laguna-xs\"]\n \n-[models.\"laguna-xs-2.1\".limits]\n+[providers.poolside.models.\"laguna-xs-2.1\".limits]\n context_window = 262144\n max_output = 32768\n \n-[models.\"laguna-xs-2.1\".features]\n+[providers.poolside.models.\"laguna-xs-2.1\".features]\n tools = true\n vision = false\n reasoning = true\n@@ -57,7 +55,7 @@ sampling_params = true\n \n # Poolside Platform is free for a limited preview period. These are Poolside's\n # published paid endpoint rates.\n-[models.\"laguna-xs-2.1\".costs]\n+[providers.poolside.models.\"laguna-xs-2.1\".costs]\n input_cost_per_mtok = 0.10\n output_cost_per_mtok = 0.20\n cache_input_cost_per_mtok = 0.05\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/venice.toml b/lib/crates/fabro-model/src/catalog/providers/venice.toml\nindex bb91aeada..dc97c4ab4 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/venice.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/venice.toml\n@@ -8,43 +8,39 @@ aliases = [\"venice-ai\"]\n [providers.venice.auth]\n credentials = [\"env:VENICE_API_KEY\", \"vault:VENICE_API_KEY\"]\n \n-[models.\"venice-uncensored-1-2\"]\n-provider = \"venice\"\n-api_id = \"venice-uncensored-1-2\"\n+[providers.venice.models.\"venice-uncensored-1-2\"]\n display_name = \"Venice Uncensored 1.2\"\n family = \"venice-uncensored\"\n default = true\n aliases = [\"venice-uncensored\", \"vu\"]\n \n-[models.\"venice-uncensored-1-2\".limits]\n+[providers.venice.models.\"venice-uncensored-1-2\".limits]\n context_window = 128000\n max_output = 8192\n \n-[models.\"venice-uncensored-1-2\".features]\n+[providers.venice.models.\"venice-uncensored-1-2\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"venice-uncensored-1-2\".costs]\n+[providers.venice.models.\"venice-uncensored-1-2\".costs]\n input_cost_per_mtok = 0.2\n output_cost_per_mtok = 0.9\n \n-[models.\"venice-uncensored-role-play\"]\n-provider = \"venice\"\n-api_id = \"venice-uncensored-role-play\"\n+[providers.venice.models.\"venice-uncensored-role-play\"]\n display_name = \"Venice Uncensored Role Play\"\n family = \"venice-uncensored\"\n aliases = [\"venice-roleplay\", \"vrp\"]\n \n-[models.\"venice-uncensored-role-play\".limits]\n+[providers.venice.models.\"venice-uncensored-role-play\".limits]\n context_window = 128000\n max_output = 4096\n \n-[models.\"venice-uncensored-role-play\".features]\n+[providers.venice.models.\"venice-uncensored-role-play\".features]\n tools = true\n vision = true\n reasoning = false\n \n-[models.\"venice-uncensored-role-play\".costs]\n+[providers.venice.models.\"venice-uncensored-role-play\".costs]\n input_cost_per_mtok = 0.5\n output_cost_per_mtok = 2.0\ndiff --git a/lib/crates/fabro-model/src/catalog/providers/zai.toml b/lib/crates/fabro-model/src/catalog/providers/zai.toml\nindex e720df4da..c6d70cd64 100644\n--- a/lib/crates/fabro-model/src/catalog/providers/zai.toml\n+++ b/lib/crates/fabro-model/src/catalog/providers/zai.toml\n@@ -8,50 +8,46 @@ priority = 60\n [providers.zai.auth]\n credentials = [\"env:ZAI_API_KEY\", \"vault:ZAI_API_KEY\"]\n \n-[models.\"glm-5.2\"]\n-provider = \"zai\"\n-api_id = \"glm-5.2\"\n+[providers.zai.models.\"glm-5.2\"]\n display_name = \"GLM 5.2\"\n family = \"glm-5\"\n default = true\n aliases = [\"glm\", \"glm5\"]\n \n-[models.\"glm-5.2\".limits]\n+[providers.zai.models.\"glm-5.2\".limits]\n context_window = 1048576\n max_output = 131072\n \n-[models.\"glm-5.2\".features]\n+[providers.zai.models.\"glm-5.2\".features]\n tools = true\n vision = false\n reasoning = true\n reasoning_effort = \"levels\"\n prompt_cache = true\n \n-[models.\"glm-5.2\".controls]\n+[providers.zai.models.\"glm-5.2\".controls]\n reasoning_effort = [\"high\", \"max\"]\n \n-[models.\"glm-5.2\".costs]\n+[providers.zai.models.\"glm-5.2\".costs]\n input_cost_per_mtok = 1.4\n output_cost_per_mtok = 4.4\n cache_input_cost_per_mtok = 0.26\n \n-[models.\"glm-4.7\"]\n-provider = \"zai\"\n-api_id = \"glm-4.7\"\n+[providers.zai.models.\"glm-4.7\"]\n display_name = \"GLM 4.7\"\n family = \"glm-4\"\n estimated_output_tps = 100\n aliases = [\"glm4\"]\n \n-[models.\"glm-4.7\".limits]\n+[providers.zai.models.\"glm-4.7\".limits]\n context_window = 202752\n max_output = 16384\n \n-[models.\"glm-4.7\".features]\n+[providers.zai.models.\"glm-4.7\".features]\n tools = true\n vision = false\n reasoning = false\n \n-[models.\"glm-4.7\".costs]\n+[providers.zai.models.\"glm-4.7\".costs]\n input_cost_per_mtok = 0.6\n output_cost_per_mtok = 2.2\ndiff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs\nindex 18d1bdd86..94b6f043e 100644\n--- a/lib/crates/fabro-model/src/ids.rs\n+++ b/lib/crates/fabro-model/src/ids.rs\n@@ -101,9 +101,12 @@ impl AsRef for ProviderId {\n }\n }\n \n-/// Stable model identifier — either the canonical catalog ID or one of its\n-/// declared aliases.\n-#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]\n+/// Stable, canonical human-facing model slug.\n+///\n+/// Aliases are selectors that resolve to a `ModelId`; they are never model\n+/// IDs themselves. The same canonical slug may identify one offering on each\n+/// provider, so an offering's full identity is `(ProviderId, ModelId)`.\n+#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]\n #[serde(transparent)]\n pub struct ModelId(String);\n \n@@ -123,12 +126,32 @@ impl ModelId {\n }\n }\n \n+impl std::borrow::Borrow for ModelId {\n+ fn borrow(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl std::ops::Deref for ModelId {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ self.as_str()\n+ }\n+}\n+\n impl fmt::Display for ModelId {\n fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n f.write_str(&self.0)\n }\n }\n \n+impl fmt::Debug for ModelId {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ fmt::Debug::fmt(&self.0, f)\n+ }\n+}\n+\n impl From<&str> for ModelId {\n fn from(s: &str) -> Self {\n Self(s.to_string())\n@@ -147,6 +170,30 @@ impl AsRef for ModelId {\n }\n }\n \n+impl PartialEq for ModelId {\n+ fn eq(&self, other: &str) -> bool {\n+ self.as_str() == other\n+ }\n+}\n+\n+impl PartialEq<&str> for ModelId {\n+ fn eq(&self, other: &&str) -> bool {\n+ self.as_str() == *other\n+ }\n+}\n+\n+impl PartialEq for str {\n+ fn eq(&self, other: &ModelId) -> bool {\n+ self == other.as_str()\n+ }\n+}\n+\n+impl PartialEq for &str {\n+ fn eq(&self, other: &ModelId) -> bool {\n+ *self == other.as_str()\n+ }\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\ndiff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs\nindex 8dcbd3270..312067f9a 100644\n--- a/lib/crates/fabro-model/src/types.rs\n+++ b/lib/crates/fabro-model/src/types.rs\n@@ -1,6 +1,6 @@\n use serde::{Deserialize, Serialize};\n \n-use crate::ids::ProviderId;\n+use crate::ids::{ModelId, ProviderId};\n \n // --- 2.9 Model ---\n \n@@ -78,7 +78,7 @@ pub struct ModelCosts {\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n pub struct Model {\n- pub id: String,\n+ pub id: ModelId,\n pub provider: ProviderId,\n pub family: String,\n pub display_name: String,\n@@ -210,7 +210,7 @@ mod tests {\n #[test]\n fn inherent_methods_return_correct_values() {\n let info = Model {\n- id: \"model-id\".to_string(),\n+ id: ModelId::new(\"model-id\"),\n provider: ProviderId::new(\"provider-id\"),\n family: \"family\".to_string(),\n display_name: \"Display Name\".to_string(),\n", + "summary": { + "files_changed": 32, + "additions": 1657, + "deletions": 564 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-07-23T03:41:01.712980475Z", + "current_node": "simplify_fable", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_fable" + ], + "node_retries": {}, + "context_values": { + "thread.implement.current_node": "simplify_fable", + "internal.retry_count.simplify_fable": 0, + "internal.work_dir": "/home/daytona/workspace/fabro", + "failure_signature": "simplify_fable|deterministic|api_deterministic|openrouter|invalid_request", "internal.retry_count.toolchain": 0, "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "thread.toolchain.current_node": "preflight_compile", @@ -784,13 +902,13 @@ "internal.fidelity": "compact", "internal.run_id": "01KY6E8S0YA6KAR5ZF53X7QMWZ", "graph.goal": "# Provider-aware model aliases and API IDs\n\n## Outcome\n\nFabro workflows can name a model with one stable model slug or alias and run unchanged against whichever provider the operator has available. A model offering is identified by `(provider, ModelId)`, so the same `ModelId` and the same alias may appear on multiple providers. For an unqualified selector, Fabro filters to ready providers and then uses provider priority to choose one offering deterministically.\n\nThe motivating behavior is:\n\n| Ready providers | Selector | Selected offering |\n| --- | --- | --- |\n| OpenAI only | `gpt-56-sol` | OpenAI's `gpt-5.6-sol` |\n| OpenRouter only | `gpt-56-sol` | OpenRouter's `gpt-5.6-sol` offering |\n| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because its provider priority is higher |\n| OpenAI and OpenRouter, explicit `provider = \"openrouter\"` | `gpt-56-sol` | OpenRouter, because an explicit provider is a pin |\n\nThe provider-facing API identifier remains an implementation detail of the selected offering. It defaults to the canonical model slug and can be overridden with `api_id` when a provider uses another convention.\n\n## Scope and design decisions\n\n### Vocabulary and identity\n\n- `ProviderId` identifies who serves the request, such as `openai` or `openrouter`.\n- `ModelId` is the canonical, human-facing model slug, such as `gpt-5.6-sol` or `claude-opus-4-8`. It never means an alias.\n- An alias is an alternate user-facing selector, such as `gpt-56-sol` or `opus`.\n- An offering is one provider's route to one `ModelId`. Its stable identity is `(ProviderId, ModelId)`.\n- `api_id` is the opaque string sent to that offering's provider API.\n- `family` remains model metadata used for display and matching; it is not a routing namespace and is not combined with `provider` or `api_id`.\n\nDo not add a separate runtime `LogicalModel` type. Use the existing `Model` as the provider-specific offering and use the existing `ModelId` newtype for its canonical ID. Internally, tuple keys `(ProviderId, ModelId)` are enough; do not add an `OfferingId` type unless implementation pressure demonstrates a real invariant it would protect.\n\n### Canonical configuration shape\n\nMove model declarations under their provider, but keep the human model slug as the model table key:\n\n```toml\n[llm.providers.openai]\npriority = 90\n\n[llm.providers.openai.models.\"gpt-5.6-sol\"]\ndisplay_name = \"GPT-5.6 Sol\"\nfamily = \"gpt-5\"\naliases = [\"gpt-56-sol\"]\ndefault = true\n\n[llm.providers.openrouter]\npriority = 25\n\n[llm.providers.openrouter.models.\"gpt-5.6-sol\"]\napi_id = \"openai/gpt-5.6-sol\"\ndisplay_name = \"GPT-5.6 Sol (via OpenRouter)\"\nfamily = \"gpt-5\"\naliases = [\"gpt-56-sol\"]\ndefault = true\n```\n\nThis shape provides a natural unique key without making humans author an API identifier or repeat `provider = \"...\"` inside every model. Model settings continue to field-merge by provider and model slug across configuration layers.\n\nAt catalog build time:\n\n```text\neffective_api_id = configured api_id, otherwise ModelId's exact slug\n```\n\nReject an explicitly empty `api_id`. Do not perform provider-specific string rewrites, prefix inference, or template expansion. A future template feature may be authoring sugar that produces the same resolved `api_id`, but it is not part of this change.\n\n### Alias and selection semantics\n\nBuild candidate sets rather than a global `identifier -> one model` map:\n\n- Canonical model IDs may repeat across providers.\n- Aliases may repeat across providers and may point to different canonical model IDs on different providers. This supports both strict synonyms and portable role-like aliases.\n- Within one provider, a canonical ID or alias must identify exactly one offering. Reject two models on the same provider that claim the same alias.\n- Across providers, an alias may collide with a canonical `ModelId`; the canonical-before-alias check order keeps canonical IDs reliable pins, and the shadowed alias stays reachable through its provider-qualified form. Within one provider, the previous rule already rejects the collision.\n- An explicit provider restricts lookup to that provider and bypasses provider priority.\n- An unqualified selector considers only eligible providers, then sorts by provider priority descending and canonical provider ID ascending.\n- A canonical `ModelId` match is checked before alias matches.\n- Disabled providers and disabled offerings are absent from candidate sets.\n\n\"Eligible\" must be supplied by the caller rather than inferred inside the catalog:\n\n- Runtime calls use providers whose adapters registered successfully. This accounts for credentials and adapter initialization, not merely an enabled catalog row.\n- Static validation explicitly uses all enabled catalog providers and proves that at least one candidate exists without claiming that credentials are available.\n- An explicit but unavailable provider remains a pin and produces a clear unavailable-provider error; Fabro must not silently switch it.\n\nWhen a run is created, resolve every implicit selector once and persist the chosen canonical model ID and provider. Resume uses that materialized choice; it does not reconsider provider priority because credentials changed. Runtime fallbacks remain the mechanism for handling a later provider failure.\n\nPreserve the existing passthrough behavior for uncatalogued models: when a provider is explicit, send the unknown model string unchanged and use the provider's default route policy. An unknown unqualified model may use the runtime's default ready provider as it does today, but it cannot participate in alias-based provider selection.\n\n## Implementation plan\n\n### 1. Make configuration provider-scoped\n\nFiles centered on:\n\n- `lib/crates/fabro-config/src/layers/llm.rs`\n- `lib/crates/fabro-config/src/builders.rs`\n- `lib/crates/fabro-model/src/catalog.rs`\n- `lib/crates/fabro-model/src/catalog/providers/*.toml`\n\nChanges:\n\n1. Add `models: MergeMap` to `ProviderSettings` and the equivalent model map to `ProviderCatalogSettings`.\n2. Remove `provider` from the canonical model-row shape; the containing provider supplies it.\n3. Normalize catalog data into provider/model pairs before catalog building, preserving layer precedence independently for each pair.\n4. Convert every built-in provider TOML to `[providers..models.\"\"]`.\n5. Re-key OpenRouter, Bedrock, and other aggregator offerings by Fabro's model slug rather than their provider API ID. Retain explicit `api_id` overrides for `author/model`, Bedrock profile IDs, deployment names, and other exceptions.\n6. Remove redundant `api_id` fields where they equal the model slug.\n7. Do not add an unverified provider offering merely to match the motivating example; exercise the exact example with a catalog fixture and use existing verified cross-provider models in the built-in catalog.\n\nCompatibility:\n\n- Accept the current `[llm.models.\"\"]` plus `provider = \"...\"` form as a temporary input shape. A row that omits provider adopts the provider of the unique known offering matching its id or alias; if none or several match, fail with an error naming the row.\n- Normalize each source layer into the canonical provider-scoped form before combining layers, so old and new definitions retain correct precedence.\n- Reject a single source that defines the same `(provider, model)` through both syntaxes instead of choosing silently.\n- Keep built-ins and documentation exclusively on the new syntax. Do not add a filesystem rewrite migration yet because LLM catalog layers can come from more places than one owned settings file; the compatibility parser covers all of those boundaries safely.\n- Ship a retired-identifier map for re-keyed built-in ids (old catalog key to provider plus new slug). Any selector or persisted model reference matching a retired id fails with a typed error naming the new address; nothing silently re-routes. One mechanism covers old config references, workflow graphs, and resumed pre-change runs.\n\n### 2. Rebuild catalog identity and indexes\n\nFiles centered on:\n\n- `lib/crates/fabro-model/src/ids.rs`\n- `lib/crates/fabro-model/src/types.rs`\n- `lib/crates/fabro-model/src/catalog.rs`\n- `lib/crates/fabro-model/src/model_ref.rs`\n- `lib/crates/fabro-model/src/billing.rs`\n\nChanges:\n\n1. Change `Model.id` from `String` to the transparent `ModelId` newtype and correct `ModelId` documentation so aliases are not described as model IDs. JSON remains a plain string.\n2. Key resolved model settings by `(ProviderId, ModelId)` rather than model ID alone.\n3. Replace the one-to-one `model_index` with:\n - an offering index keyed by `(ProviderId, ModelId)`;\n - canonical-ID candidates keyed by `ModelId`;\n - alias candidates keyed by alias string.\n4. Pre-sort candidate vectors with the catalog's provider ordering so every caller receives the same priority and tie-break behavior.\n5. Replace global `Catalog::get`-style assumptions with explicit methods:\n - lookup on a named provider;\n - selection from an eligible-provider set;\n - lookup of settings from a resolved `Model` offering;\n - listing every offering, optionally by provider.\n6. Make pricing, billing, codec, profile, probe, default, and closest-model lookups use the composite identity. Resolve the run-level default model with the same selection algorithm (default-flagged candidates from eligible providers, ordered by provider priority) without requiring providers to agree on their defaults.\n7. Replace `DuplicateModelIdentifier` with provider-scoped validation errors that name the provider, selector, and conflicting model IDs.\n8. Add a typed selection error that distinguishes an unknown selector from a known selector with no eligible offering. Preserve error sources and render strings only at CLI/API boundaries.\n\n### 3. Centralize provider-aware resolution\n\nFiles centered on:\n\n- `lib/crates/fabro-model/src/catalog.rs`\n- `lib/crates/fabro-types/src/settings/model_ref.rs`\n- `lib/crates/fabro-workflow/src/handler/llm/routing.rs`\n- `lib/crates/fabro-workflow/src/transforms/model_resolution.rs`\n- `lib/crates/fabro-workflow/src/run_materialization.rs`\n- `lib/crates/fabro-workflow/src/operations/start.rs`\n\nChanges:\n\n1. Implement one catalog selection algorithm taking a selector, optional explicit provider, and eligible provider IDs.\n2. Make generic `ModelRef` parsing classify bare versus provider-qualified input only. It must not try to infer a unique provider for a bare alias, because a valid alias may now have several provider candidates.\n3. Keep the existing `provider/model` qualified syntax in this change. The model slug never contains the provider API ID, so OpenRouter's slash is no longer part of the user-facing model address.\n4. Update workflow graph model resolution and run materialization to receive the ready-provider snapshot already collected during run creation.\n5. Materialize aliases to canonical `(provider, ModelId)` values in both node attributes and run defaults before persistence.\n6. Keep static validation credential-independent by resolving against all enabled candidates only for existence/capability checks.\n7. Update fallback resolution so:\n - a provider-only fallback still selects the closest compatible model;\n - a provider-qualified model/alias resolves within that provider;\n - a bare model/alias uses the fallback-time eligible set and provider priority;\n - provider-name/model-name ambiguity becomes a user-facing typed error; today AmbiguousModelRef is silently swallowed by fallback resolution, so pin this behavior change with a test.\n\n### 4. Resolve the offering before LLM dispatch\n\nFiles centered on:\n\n- `lib/crates/fabro-llm/src/client.rs`\n- `lib/crates/fabro-llm/src/adapter_registry.rs`\n- `lib/crates/fabro-llm/src/providers/common.rs`\n- provider adapter modules under `lib/crates/fabro-llm/src/providers/`\n\nChanges:\n\n1. For requests without an explicit provider, select among the client's successfully registered providers using catalog priority.\n2. For requests with an explicit provider, resolve the model or alias only on that provider and fail if the adapter is unavailable.\n3. Canonicalize a cloned request to the selected `ModelId` before validation, costing, and dispatch; leave caller-owned request data unchanged.\n4. Resolve route metadata and `api_id` from the selected composite offering. Provider adapters must pass their own canonical provider ID into catalog lookups rather than looking up settings by model string alone.\n5. Ensure response costing and billing use the same resolved offering that was dispatched.\n6. Keep explicit-provider unknown-model passthrough intact.\n\n### 5. Update server, API, CLI, and web identities\n\nFiles centered on:\n\n- `docs/public/api-reference/fabro-api.yaml`\n- `lib/crates/fabro-server/src/server/handler/models.rs`\n- `lib/crates/fabro-server/src/server/handler/sessions.rs`\n- `lib/crates/fabro-cli/src/commands/model.rs`\n- `apps/fabro-web/app/routes/settings-models.tsx`\n- generated clients in `lib/crates/fabro-api` and `lib/packages/fabro-api-client`\n\nChanges:\n\n1. Continue returning one `Model` row per offering from `GET /models`. Document that `id` is unique within a provider and that `(provider, id)` is the resource identity.\n2. Add an optional `provider` query parameter to `POST /models/{id}/test`. With a provider it tests that exact offering; without one it selects among ready providers by priority.\n3. Include `provider` in `ModelTestResult` so the tested offering is explicit.\n4. Make model-test lookup, auth issues, and probing use the selected offering rather than a global first match.\n5. Update the CLI so bulk tests always pass each row's provider, and an explicit `--provider` plus `--model` remains pinned. Match returned results by `(provider, id)`.\n6. Update the settings models page to key row state by `(provider, id)` and send the provider when testing a row; duplicate IDs must render and update independently.\n7. Update session/playground/completion resolution to use ready provider IDs and persist or return the selected provider alongside the canonical model. Enumerate the OpenAPI schema changes this implies for session, playground, and completion resources; sessions currently store only a bare model-id string.\n8. Regenerate Rust and TypeScript API clients from the OpenAPI source after changing the contract.\n\n### 6. Document the mental model\n\nFiles centered on:\n\n- `lib/crates/fabro-dev/src/commands/docs_options_reference.rs`\n- `docs/public/reference/user-configuration.mdx` (generated region)\n- `docs/public/core-concepts/models.mdx`\n- `docs/public/execution/run-configuration.mdx`\n- `docs/public/execution/failures.mdx`\n\nDocument:\n\n1. Provider, model slug, family metadata, alias, and API ID as distinct terms.\n2. Provider-scoped model configuration and the `api_id = model slug` default.\n3. The OpenAI/OpenRouter portability example and the priority table from this plan.\n4. Explicit provider selection as a pin and unqualified selection as availability plus priority.\n5. Alias reuse across providers, including the same-provider ambiguity rule.\n6. Resolution-once behavior for persisted runs and the separate role of runtime fallback chains.\n7. API IDs as opaque provider wire values that workflows should not reference.\n\nRun `cargo dev docs refresh` after editing the generator-owned reference.\n\n## Test plan\n\n### Catalog and configuration tests\n\nAdd focused unit tests proving:\n\n- two providers can declare the same canonical `ModelId`;\n- two providers can declare the same alias;\n- only OpenAI eligible selects OpenAI;\n- only OpenRouter eligible selects OpenRouter and its overridden API ID;\n- both eligible select the higher-priority provider;\n- equal priorities use canonical provider ID as the tie-breaker;\n- an explicit provider overrides priority;\n- a disabled or ineligible provider is not selected;\n- two different models on one provider cannot claim the same alias;\n- an unqualified selector matching both a canonical ID and another provider's alias selects the canonical model, while the alias offering stays reachable provider-qualified;\n- omitted `api_id` resolves to the exact model slug;\n- explicit `api_id` is preserved and an empty override is rejected;\n- provider/model layer merges do not overwrite the same slug on another provider;\n- the temporary old config shape normalizes correctly and a same-source old/new collision errors clearly.\n- a provider-less legacy row adopts the unique matching offering's provider, and a retired built-in id fails with the typed error naming its replacement.\n\n### Routing and wire tests\n\nAdd `fabro-llm` tests with fake registered providers or local capture servers that submit the same alias under three availability configurations. Assert the selected adapter and the exact wire model value, including OpenRouter's `author/model` override. Also cover explicit provider, unknown passthrough, request-control validation, and cost lookup on duplicate model IDs.\n\n### Workflow tests\n\nAdd crate-level workflow tests that create the same workflow with:\n\n- only the direct provider ready;\n- only the aggregator ready;\n- both ready;\n- an explicit lower-priority provider.\n\nAssert the persisted graph and run settings contain the selected canonical model and provider. Add a resume-oriented test showing that changing the ready provider set does not re-resolve a materialized run. Add fallback tests for a shared bare alias and a provider-qualified alias, including propagation of a provider/model ambiguity error.\n\n### API, CLI, and web tests\n\n- Server: list two rows with the same ID but different providers; filter by provider; test each exact offering; test priority selection when provider is omitted.\n- CLI: bulk model tests do not conflate duplicate IDs, and JSON output includes the selected provider.\n- Web: duplicate-ID rows have independent React keys and test-result state, and each request includes the row provider.\n- API generation: retain the existing `Model` Rust type replacement and add or update JSON parity/type-identity coverage as required by the API policy.\n\nUse unit/crate integration tests for catalog and routing behavior. Use the existing command/API test layers only for their public contracts; no live provider credentials are required.\n\n## Verification\n\nRun, in this order:\n\n```sh\ncargo build -p fabro-api\ncd lib/packages/fabro-api-client && bun run generate\ncargo dev docs refresh\ncargo nextest run -p fabro-model\ncargo nextest run -p fabro-config\ncargo nextest run -p fabro-llm\ncargo nextest run -p fabro-workflow\ncargo nextest run -p fabro-server\ncd apps/fabro-web && bun test\ncd apps/fabro-web && bun run typecheck\ncargo dev docs check\ncargo +nightly-2026-04-14 fmt --check --all\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\nulimit -n 4096 && cargo nextest run --workspace\ncargo build --workspace\n```\n\nBefore accepting any changed snapshots, run `cargo insta pending-snapshots` and inspect the complete pending set.\n\n## Completion criteria\n\n- A workflow using one shared alias runs unchanged for an OpenAI-only operator and an OpenRouter-only operator.\n- When both are ready, provider priority selects deterministically.\n- Explicit provider selection always pins the provider.\n- The selected offering's exact `api_id` reaches the provider wire request.\n- No catalog, routing, billing, API, CLI, or UI lookup treats model ID alone as a globally unique offering identity.\n- Built-ins and public documentation use provider-scoped model-slug keys and omit redundant API IDs.\n- Existing user catalog syntax remains readable through the compatibility normalization path.\n\n## Unresolved questions\n\n- What release or date should end support for the legacy top-level `[llm.models]` syntax? This does not block implementation; the plan keeps it as a compatibility input and makes the new provider-scoped form canonical.\n", - "internal.thread_id": "preflight_lint", + "internal.thread_id": "implement", "graph.rankdir": "LR", "outcome": "failed", "internal.retry_count.start": 0, "thread.preflight_compile.current_node": "preflight_lint", "internal.retry_count.preflight_compile": 0, - "current_node": "implement", + "current_node": "simplify_fable", "thread.preflight_lint.current_node": "implement" }, "node_outcomes": { @@ -845,13 +963,23 @@ "active_time_ms": 145187 } }, + "simplify_fable": { + "status": "failed", + "failure": { + "message": "LLM error: Invalid request to openrouter: This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 27912. To increase, visit https://openrouter.ai/settings/credits and add more credits", + "category": "deterministic", + "signature": "api_deterministic|openrouter|invalid_request" + }, + "usage": null + }, "start": { "status": "succeeded", "usage": null } }, - "next_node_id": "simplify_fable", + "next_node_id": "simplify_sol", "node_visits": { + "simplify_fable": 1, "preflight_lint": 1, "toolchain": 1, "start": 1, @@ -1036,7 +1164,12 @@ "first_event_seq": 50, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "failed", + "notes": null, + "failure_reason": "LLM error: Invalid request to openrouter: This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 46521. To increase, visit https://openrouter.ai/settings/credits and add more credits", + "timestamp": "2026-07-23T03:40:57.538523441Z" + }, "provider_used": { "mode": "agent", "provider": "openrouter", @@ -1050,6 +1183,12 @@ "output": null, "started_at": "2026-07-23T02:58:59.274880118Z", "handler": "agent", + "timing": { + "wall_time_ms": 2518262, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 115864, "output_tokens": 109475, @@ -1292,7 +1431,37 @@ "depth": 1, "task": "Audit and migrate provider-aware model offering identity in ancillary crates not owned by the other agents. Scope ownership: lib/crates/fabro-agent/**, fabro-auth/**, fabro-validate/**, fabro-mcp-server/**, fabro-install/**, and any other Rust crate EXCEPT fabro-model, fabro-config, fabro-llm, fabro-workflow, fabro-server, fabro-cli, fabro-api, fabro-dev, apps/fabro-web, docs, generated TS client. Do not edit excluded paths. Inspect shared-tree catalog APIs. Find global ModelId assumptions and Catalog::get/model_settings uses where provider is known; migrate profiles/codecs/auth/probes/default selection to composite provider+model, preserve explicit provider pins and unknown explicit passthrough. Fix Model.id newtype compile fallout. Add focused tests only where behavior is changed; use existing provider-aware APIs, and report upstream API blockers rather than editing fabro-model. Run cargo check on touched crates and targeted tests. Follow AGENTS/Rust/error/testing policies. Do not commit.", "status": { - "kind": "running" + "kind": "failed", + "error": { + "type": "llm", + "data": { + "type": "provider", + "kind": "invalid_request", + "detail": { + "message": "This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 46521. To increase, visit https://openrouter.ai/settings/credits and add more credits", + "provider": "openrouter", + "status_code": 402, + "error_code": null, + "retry_after": null, + "raw": { + "error": { + "message": "This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 46521. To increase, visit https://openrouter.ai/settings/credits and add more credits", + "code": 402, + "metadata": { + "provider_name": null, + "previous_errors": [ + { + "code": 402, + "message": "This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 46521. To increase, visit https://openrouter.ai/settings/credits and add more credits" + } + ] + } + }, + "user_id": "user_3FaFUURCP5thsBEfW7qWA83ls9N" + } + } + } + } } } ], @@ -1553,6 +1722,181 @@ } ] }, + "state": "failed" + }, + "simplify_fable@1": { + "first_event_seq": 3073, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "openrouter", + "model": "anthropic/claude-fable-5", + "reasoning_effort": "xhigh" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-07-23T03:41:01.202452038Z", + "handler": "agent", + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "skills": { + "available": [ + { + "name": "rust-style-guide", + "description": "Apply this Rust style guide when writing, reviewing, refactoring, or configuring Rust code for this project. Covers Rust 2024/MSRV, library vs application conventions, public API design, errors, panics, ownership and cloning, async/Tokio/concurrency, tracing, rustfmt/Clippy, testing with nextest, and unsafe/macro policy. Also use when setting up new Rust projects, investigating Rust performance, verifying library releases, or reviewing Rust code changes." + } + ], + "activated": [] + }, + "permission_level": "full", + "agent_tools": [ + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "request_user_input", + "description": "Ask the human one or more questions and wait for their answers before continuing this stage.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": false + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "update_plan", + "description": "Update the multi-step plan for the current task. Submit the entire plan; existing steps are reconciled by exact step text.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "use_skill", + "description": "Load a skill's instructions by name. Call this when the user's request matches an available skill.", + "source": { + "kind": "skill" + }, + "category": "other", + "invoked": false + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + } + ], "state": "running" }, "start@1": { diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..955e460f9 --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,4329 @@ +diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx +index 7bda6217b..c466e3f96 100644 +--- a/docs/public/core-concepts/models.mdx ++++ b/docs/public/core-concepts/models.mdx +@@ -9,6 +9,43 @@ No single model is best at everything. Fabro lets you assign the right model to + Ensemble workflow: fan out to Opus and Gemini Pro, merge, then synthesize + + ++## How model selection works ++ ++Fabro separates the name a workflow uses from the value a provider expects on ++the wire: ++ ++| Term | Meaning | ++|---|---| ++| **Provider ID** | Who serves the request, such as `openai` or `openrouter`. | ++| **Model slug** | The canonical, human-facing model ID, such as `gpt-5.6-sol`. | ++| **Alias** | An alternate user-facing selector, such as `gpt-56-sol`. | ++| **Offering** | One provider's route to one model slug. Its identity is `(provider, model slug)`. | ++| **Family** | Metadata used for display and compatible-model matching, not a routing namespace. | ++| **API ID** | The opaque string sent to the selected provider API. Workflows do not reference it. | ++ ++A model slug is unique within a provider, not across the whole catalog. Two ++providers can offer the same slug and reuse the same alias, so a workflow can ++use one stable selector wherever either provider is available. ++ ++For an unqualified selector, Fabro first finds matching offerings on **ready ++providers**—providers whose adapters registered successfully with usable ++credentials and configuration. It then chooses the provider with the highest ++`priority`; equal priorities use canonical provider ID in ascending order. A ++canonical model-slug match is considered before alias matches. ++ ++| Ready providers | Selector | Selected offering | ++|---|---|---| ++| OpenAI only | `gpt-56-sol` | OpenAI's `gpt-5.6-sol` | ++| OpenRouter only | `gpt-56-sol` | OpenRouter's `gpt-5.6-sol` offering | ++| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because its provider priority is higher | ++| Both, with `provider = "openrouter"` | `gpt-56-sol` | OpenRouter, because an explicit provider is a pin | ++ ++ ++An explicit provider restricts lookup to that provider. If the pinned provider ++is unavailable or does not offer the selector, Fabro reports the error instead ++of silently switching providers. ++ ++ + ## Model catalog + + | Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed | +@@ -46,13 +83,14 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi + + ## Configuring providers and models + +-Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release. ++Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release. Declare each model under the provider that serves it: + + ```toml title="settings.toml" + [llm.providers.proxy] + display_name = "Acme Gateway" + adapter = "openai_compatible" + base_url = "https://llm-gateway.example.com/v1" ++priority = 50 + aliases = ["gateway"] + + [llm.providers.proxy.auth] +@@ -62,8 +100,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] + x-portkey-api-key = "{{ env.PORTKEY_API_KEY }}" + x-portkey-config = "@bedrock-prod" + +-[llm.models."team-code-large"] +-provider = "proxy" ++[llm.providers.proxy.models."team-code-large"] + api_id = "provider-wire-model-name" + agent_profile = "anthropic" + display_name = "Team Code Large" +@@ -73,32 +110,33 @@ small_default = true + aliases = ["team-code"] + estimated_output_tps = 80 + +-[llm.models."team-code-large".limits] ++[llm.providers.proxy.models."team-code-large".limits] + context_window = 200000 + max_output = 32000 + +-[llm.models."team-code-large".features] ++[llm.providers.proxy.models."team-code-large".features] + tools = true + reasoning = true + reasoning_effort = "levels" + prompt_cache = true +-effort = true + +-[llm.models."team-code-large".controls] ++[llm.providers.proxy.models."team-code-large".controls] + reasoning_effort = ["low", "medium", "high"] + speed = ["fast"] + +-[llm.models."team-code-large".costs] ++[llm.providers.proxy.models."team-code-large".costs] + input_cost_per_mtok = 1.50 + output_cost_per_mtok = 8.00 + cache_input_cost_per_mtok = 0.30 + +-[llm.models."team-code-large".costs.speed.fast] ++[llm.providers.proxy.models."team-code-large".costs.speed.fast] + input_cost_per_mtok = 3.00 + output_cost_per_mtok = 16.00 + cache_input_cost_per_mtok = 0.60 + ``` + ++The table key (`team-code-large`) is the model slug that workflows select. `api_id` is an opaque provider-facing wire value. It defaults to the exact model slug when omitted, so configure it only when the provider expects a different string, such as a deployment name, `author/model` slug, or Bedrock inference-profile ID. Fabro does not parse it for provider routing, add prefixes, or otherwise infer meaning from it; an explicitly empty `api_id` is invalid. ++ + For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Enable it in settings and declare the models your proxy exposes: + + ```toml title="settings.toml" +@@ -106,24 +144,27 @@ For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Ena + enabled = true + base_url = "http://localhost:4000/v1" + +-[llm.models."litellm-gpt-5"] +-provider = "litellm" ++[llm.providers.litellm.models."litellm-gpt-5"] + api_id = "gpt-5" + display_name = "LiteLLM GPT-5" + family = "litellm" + default = true + +-[llm.models."litellm-gpt-5".limits] ++[llm.providers.litellm.models."litellm-gpt-5".limits] + context_window = 128000 + max_output = 8192 + +-[llm.models."litellm-gpt-5".features] ++[llm.providers.litellm.models."litellm-gpt-5".features] + tools = true + vision = false + reasoning = false + ``` + +-`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same. ++### Reusing aliases across providers ++ ++Aliases are scoped to a provider. An alias or canonical slug must identify exactly one model within that provider, so two models under `proxy` cannot both claim `team-code`. The same slug or alias may be reused by another provider; that reuse is what makes unqualified selectors portable. Across providers, an exact canonical-slug match takes precedence over an alias match. You can still reach a shadowed alias by pinning its provider. ++ ++The older `[llm.models.]` form remains readable as a compatibility input, but new configuration and built-in catalog entries should use `[llm.providers..models.]`. + + Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default. + +@@ -169,7 +210,7 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it + enabled = true + ``` + +-Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.models.]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`. ++Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add an explicit `[llm.providers.ollama.models.]` block for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`. + + ## Default models + +@@ -217,11 +258,12 @@ Model stylesheets set per-node models inside the workflow graph, but you can als + Pass `--model` and optionally `--provider` to `fabro run`: + + ```bash +-fabro run docs/internal/demo/01-hello.fabro --model claude-opus-4-6 ++fabro run docs/internal/demo/01-hello.fabro --model gpt-56-sol ++fabro run docs/internal/demo/01-hello.fabro --model gpt-56-sol --provider openrouter + fabro run docs/internal/demo/04-pipeline.fabro --model gemini-3.1-pro-preview + ``` + +-These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. The provider is automatically inferred from the model catalog — you only need `--provider` for models not in the catalog or to force a specific provider. ++These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. Without `--provider`, the selector is portable across ready offerings and provider priority decides. `--provider` is an explicit pin, including for models not in the catalog. + + ### Run config TOML + +@@ -247,7 +289,13 @@ Then launch with: + fabro run run.toml + ``` + +-The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. ++The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro resolves each entry to a concrete provider and canonical model, then tries that persisted chain in order after a failover-eligible error. Provider-only entries choose the closest compatible model; qualified model entries stay pinned to their named provider. ++ ++### Resolution is stable for a run ++ ++When Fabro creates a run, it resolves every implicit model selector against the ready-provider snapshot and persists the chosen canonical `(provider, model slug)` in the run. Resuming that run uses the materialized choice—it does not re-rank providers because credentials or priorities changed later. ++ ++This resolve-once behavior makes a run reproducible; the fallback chain is the separate mechanism for handling a provider that fails after creation. A newly created run can choose a different ready offering from the same portable selector. + + + The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win. +diff --git a/docs/public/execution/failures.mdx b/docs/public/execution/failures.mdx +index 6884d5708..7113ac20a 100644 +--- a/docs/public/execution/failures.mdx ++++ b/docs/public/execution/failures.mdx +@@ -121,7 +121,9 @@ provider = "anthropic" + fallbacks = ["gemini", "openai"] + ``` + +-When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. ++When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Provider-only entries select the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. Qualified model entries are provider pins; bare models and aliases select among ready providers by priority. ++ ++Fabro resolves the primary and fallback selectors to concrete provider/model offerings when it creates the run and persists the result. Resume reuses that materialized chain rather than re-ranking providers after credentials or priorities change. Runtime fallback is the mechanism for a provider failure that happens after creation. + + ### What triggers failover + +diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx +index 1b51a9161..20001cd86 100644 +--- a/docs/public/execution/run-configuration.mdx ++++ b/docs/public/execution/run-configuration.mdx +@@ -138,11 +138,11 @@ name = "claude-sonnet-4-5" + + | Field | Description | + |---|---| +-| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | +-| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. | +-| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. | ++| `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | ++| `provider` | Optional provider pin. When omitted, Fabro selects a matching offering from ready providers by provider priority. When set, lookup is restricted to that provider and unavailability is an error. | ++| `fallbacks` | Ordered list of model references to try after a failover-eligible error. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. | + +-Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.]`. ++Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.]`. A qualified fallback such as `"openrouter/gpt-56-sol"` is pinned to that provider; a bare alias can select among ready fallback offerings by priority. + + #### `[run.model.controls]` + +@@ -163,6 +163,12 @@ speed = "fast" + | `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. | + | `speed` | Native speed value to request when the selected model declares it, such as `"fast"`. The standard speed is implicit and does not need to be set. | + ++#### Resolution and fallback behavior ++ ++At run creation, Fabro resolves unqualified primary and fallback selectors to concrete provider and canonical-model pairs using the ready-provider snapshot, then persists those choices. Resume reuses the materialized routing and does not reconsider provider priority if credentials or configuration changed. Runtime failover walks the persisted fallback chain; create a new run to reselect from current provider availability. ++ ++Provider-only fallbacks choose the closest compatible model on that provider. A provider-qualified model or alias resolves only within that provider, while a bare model or alias uses ready providers and priority. ++ + #### Fallbacks with splice + + Use the reserved `"..."` marker in `fallbacks` to splice in the inherited list from lower-precedence layers: +diff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx +index 7f1891f8d..f7acf9fd4 100644 +--- a/docs/public/integrations/litellm.mdx ++++ b/docs/public/integrations/litellm.mdx +@@ -24,24 +24,23 @@ _version = 1 + enabled = true + base_url = "http://localhost:4000/v1" + +-[llm.models."litellm-gpt-5"] +-provider = "litellm" ++[llm.providers.litellm.models."litellm-gpt-5"] + api_id = "gpt-5" + display_name = "LiteLLM GPT-5" + family = "litellm" + default = true + +-[llm.models."litellm-gpt-5".limits] ++[llm.providers.litellm.models."litellm-gpt-5".limits] + context_window = 128000 + max_output = 8192 + +-[llm.models."litellm-gpt-5".features] ++[llm.providers.litellm.models."litellm-gpt-5".features] + tools = true + vision = false + reasoning = false + ``` + +-`api_id` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. ++`api_id` is the opaque model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. When the provider-facing name is the same as the Fabro model slug, omit `api_id`; it defaults to the slug. + + ## Configure credentials + +diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs +index 1458ba0b9..78de24414 100644 +--- a/lib/crates/fabro-config/src/builders.rs ++++ b/lib/crates/fabro-config/src/builders.rs +@@ -16,9 +16,9 @@ use crate::resolve::{ + }; + use crate::user::load_settings_config; + use crate::{ +- CliLayer, Combine, CostRates, EnvironmentLayer, Error, LlmLayer, LlmModelFeatures, +- LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings, ProviderSettings, +- Result, RunLayer, ServerLayer, SettingsLayer, run, ++ CliLayer, Combine, CostRates, EnvironmentLayer, Error, LegacyModelSettings, LlmLayer, ++ LlmModelFeatures, LlmModelLimits, MergeMap, ModelControls, ModelCostTable, ModelSettings, ++ ProviderSettings, Result, RunLayer, ServerLayer, SettingsLayer, run, + }; + + #[derive(Debug, Clone, PartialEq, Eq)] +@@ -321,7 +321,7 @@ fn llm_layer_to_catalog_settings(llm: LlmLayer) -> model_catalog::LlmCatalogSett + .models + .into_inner() + .into_iter() +- .map(|(id, settings)| (id, model_settings_to_catalog(settings))) ++ .map(|(id, settings)| (id, legacy_model_settings_to_catalog(settings))) + .collect(), + } + } +@@ -341,6 +341,12 @@ fn provider_settings_to_catalog( + .collect() + }); + model_catalog::ProviderCatalogSettings { ++ models: settings ++ .models ++ .into_inner() ++ .into_iter() ++ .map(|(id, settings)| (id, model_settings_to_catalog(settings))) ++ .collect(), + display_name: settings.display_name, + adapter: settings.adapter, + codec: settings.codec, +@@ -356,9 +362,17 @@ fn provider_settings_to_catalog( + } + } + ++fn legacy_model_settings_to_catalog( ++ settings: LegacyModelSettings, ++) -> model_catalog::ModelCatalogSettings { ++ let LegacyModelSettings { provider, model } = settings; ++ let mut settings = model_settings_to_catalog(model); ++ settings.provider = provider; ++ settings ++} ++ + fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCatalogSettings { + let ModelSettings { +- provider, + api_id, + codec, + billing_policy, +@@ -379,7 +393,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat + costs, + } = settings; + model_catalog::ModelCatalogSettings { +- provider, ++ provider: None, + api_id, + codec, + billing_policy, +@@ -820,7 +834,7 @@ provider = "docker" + } + + #[test] +- fn server_runtime_settings_preserves_llm_catalog_overrides() { ++ fn server_runtime_settings_preserves_provider_scoped_llm_catalog_overrides() { + let settings = server_runtime_settings_from_toml( + r#" + _version = 1 +@@ -837,17 +851,16 @@ agent_profile = "anthropic" + [llm.providers.acme.auth] + credentials = ["env:ACME_API_KEY"] + +-[llm.models."acme-large"] +-provider = "acme" ++[llm.providers.acme.models."acme-large"] + display_name = "Acme Large" + family = "acme" + default = true + agent_profile = "gemini" + +-[llm.models."acme-large".limits] ++[llm.providers.acme.models."acme-large".limits] + context_window = 128000 + +-[llm.models."acme-large".features] ++[llm.providers.acme.models."acme-large".features] + tools = true + vision = false + reasoning = false +@@ -857,20 +870,69 @@ reasoning = false + ) + .expect("server runtime settings should resolve"); + +- let catalog = +- fabro_model::Catalog::from_builtin_with_overrides(&settings.llm_catalog_settings) +- .expect("catalog overrides should build"); ++ let provider = settings ++ .llm_catalog_settings ++ .providers ++ .get("acme") ++ .expect("provider settings should be present"); ++ let model = provider ++ .models ++ .get("acme-large") ++ .expect("provider-scoped model settings should be present"); ++ assert_eq!(model.display_name.as_deref(), Some("Acme Large")); ++ assert_eq!(model.agent_profile, Some(fabro_model::AgentProfileKind::Gemini)); ++ assert!(settings.llm_catalog_settings.models.is_empty()); ++ } ++ ++ #[test] ++ fn server_runtime_settings_converts_legacy_models_to_provider_catalog_shape() { ++ let settings = server_runtime_settings_from_toml( ++ r#" ++_version = 1 ++ ++[server.auth] ++methods = ["dev-token"] ++ ++[llm.models."acme-large"] ++provider = "acme" ++display_name = "Acme Large" ++"#, ++ None, ++ None, ++ ) ++ .expect("legacy catalog settings should resolve"); + + assert_eq!( +- catalog +- .get("acme-large") +- .map(|model| model.provider.clone()), +- Some(fabro_model::ProviderId::new("acme")) ++ settings.llm_catalog_settings.providers["acme"].models["acme-large"] ++ .display_name ++ .as_deref(), ++ Some("Acme Large") + ); ++ assert!(settings.llm_catalog_settings.models.is_empty()); ++ } ++ ++ #[test] ++ fn server_runtime_settings_retains_providerless_legacy_models_for_catalog_adoption() { ++ let settings = server_runtime_settings_from_toml( ++ r#" ++_version = 1 ++ ++[server.auth] ++methods = ["dev-token"] ++ ++[llm.models."known-model"] ++display_name = "Renamed Known Model" ++"#, ++ None, ++ None, ++ ) ++ .expect("provider-less legacy catalog settings should resolve"); ++ + assert_eq!( +- catalog +- .effective_agent_profile(&fabro_model::ProviderId::new("acme"), Some("acme-large")), +- Some(fabro_model::AgentProfileKind::Gemini) ++ settings.llm_catalog_settings.models["known-model"] ++ .display_name ++ .as_deref(), ++ Some("Renamed Known Model") + ); + } + +diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs +index 5fb8a0a64..00fc79e35 100644 +--- a/lib/crates/fabro-config/src/layers/llm.rs ++++ b/lib/crates/fabro-config/src/layers/llm.rs +@@ -12,11 +12,15 @@ + //! enabled = true + //! aliases = ["moonshot"] + //! +-//! [llm.models."kimi-k2.5"] +-//! provider = "kimi" ++//! [llm.providers.kimi.models."kimi-k2.5"] + //! ... + //! ``` + //! ++//! Legacy top-level `[llm.models.]` rows remain accepted by the settings ++//! parser. Rows with a `provider` are normalized into the canonical provider ++//! scope before layers combine; provider-less rows are retained for ++//! catalog-aware compatibility handling. ++//! + //! Per-provider and per-model entries field-merge across layers (default → + //! user → server → project → workflow/run). Inner arrays such as + //! `auth.credentials`, `aliases`, `controls.reasoning_effort`, and +@@ -43,15 +47,22 @@ pub struct LlmLayer { + /// Provider definitions keyed by provider ID. + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] + pub providers: MergeMap, +- /// Model definitions keyed by canonical model ID. ++ /// Legacy top-level model definitions keyed by canonical model ID. ++ /// ++ /// Provider-qualified rows are moved into [`ProviderSettings::models`] by ++ /// the settings parser. Rows without a provider remain here until the ++ /// built-in catalog can adopt them unambiguously. + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] +- pub models: MergeMap, ++ pub models: MergeMap, + } + + /// One entry in `[llm.providers.]`. + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] + #[serde(deny_unknown_fields)] + pub struct ProviderSettings { ++ /// Model definitions owned by this provider, keyed by canonical model ID. ++ #[serde(default, skip_serializing_if = "MergeMap::is_empty")] ++ pub models: MergeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Adapter registry key (e.g. `"openai_compatible"`). +@@ -89,13 +100,10 @@ pub struct ProviderSettings { + pub aliases: Option>, + } + +-/// One entry in `[llm.models.]`. ++/// One entry in `[llm.providers..models.]`. + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] + #[serde(deny_unknown_fields)] + pub struct ModelSettings { +- /// Provider ID this model belongs to. +- #[serde(default, skip_serializing_if = "Option::is_none")] +- pub provider: Option, + /// Identifier sent to the provider API. Defaults to the catalog model ID + /// when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] +@@ -155,6 +163,38 @@ pub struct ModelSettings { + pub costs: Option, + } + ++/// Input-only compatibility row for the legacy `[llm.models.]` shape. ++#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] ++#[serde(deny_unknown_fields)] ++pub struct LegacyModelSettings { ++ /// Provider ID used to move this row into the canonical provider scope. ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub provider: Option, ++ #[serde(flatten)] ++ pub model: ModelSettings, ++} ++ ++impl LegacyModelSettings { ++ #[must_use] ++ pub(crate) fn into_model(self) -> ModelSettings { ++ self.model ++ } ++} ++ ++impl std::ops::Deref for LegacyModelSettings { ++ type Target = ModelSettings; ++ ++ fn deref(&self) -> &Self::Target { ++ &self.model ++ } ++} ++ ++impl std::ops::DerefMut for LegacyModelSettings { ++ fn deref_mut(&mut self) -> &mut Self::Target { ++ &mut self.model ++ } ++} ++ + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] + #[serde(deny_unknown_fields)] + pub struct ModelLimits { +diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs +index c3fa1632c..aae4c99a6 100644 +--- a/lib/crates/fabro-config/src/layers/mod.rs ++++ b/lib/crates/fabro-config/src/layers/mod.rs +@@ -21,8 +21,8 @@ pub use environment::{ + EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer, + }; + pub use llm::{ +- CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable, +- ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings, ++ CostRates, CredentialRef, CredentialRefParseError, LegacyModelSettings, LlmLayer, ModelControls, ++ ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings, + ProviderSettings, ReasoningEffortFeature, + }; + pub use log_filter::LogFilter; +diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs +index f6097b9be..fd7d48608 100644 +--- a/lib/crates/fabro-config/src/lib.rs ++++ b/lib/crates/fabro-config/src/lib.rs +@@ -46,8 +46,9 @@ pub use layers::{ + CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, + EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer, + GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, +- InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, +- McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings, ++ InterviewProviderLayer, InterviewsLayer, LegacyModelSettings, LlmLayer, LlmModelFeatures, ++ LlmModelLimits, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ++ ModelRefOrSplice, ModelSettings, + NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, + PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer, + RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, +diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs +index b4e008791..35b7f4709 100644 +--- a/lib/crates/fabro-config/src/parse.rs ++++ b/lib/crates/fabro-config/src/parse.rs +@@ -39,6 +39,13 @@ pub enum ParseError { + path: String, + source: SettingsSource, + }, ++ ConflictingLlmModelDefinitions { ++ provider: String, ++ model: String, ++ }, ++ InvalidLegacyLlmModelProvider { ++ model: String, ++ }, + } + + impl fmt::Display for ParseError { +@@ -60,6 +67,14 @@ impl fmt::Display for ParseError { + f, + "`{path}` is server-managed and cannot be set in {source} settings; configure cwd on a server-managed environment instead." + ), ++ Self::ConflictingLlmModelDefinitions { provider, model } => write!( ++ f, ++ "model `{model}` on provider `{provider}` is defined in both `llm.models.{model}` and `llm.providers.{provider}.models.{model}` in the same settings source" ++ ), ++ Self::InvalidLegacyLlmModelProvider { model } => write!( ++ f, ++ "legacy model `llm.models.{model}` has an empty provider; omit it for catalog-aware adoption or set a non-empty provider ID" ++ ), + } + } + } +@@ -118,8 +133,36 @@ pub(crate) fn parse_settings(input: &str) -> Result { + } + } + +- raw.try_into::() +- .map_err(|e| ParseError::Toml(e.to_string())) ++ let mut layer = raw ++ .try_into::() ++ .map_err(|e| ParseError::Toml(e.to_string()))?; ++ normalize_legacy_llm_models(&mut layer)?; ++ Ok(layer) ++} ++ ++fn normalize_legacy_llm_models(layer: &mut SettingsLayer) -> Result<(), ParseError> { ++ let Some(llm) = layer.llm.as_mut() else { ++ return Ok(()); ++ }; ++ ++ let legacy_models = std::mem::take(&mut llm.models).into_inner(); ++ for (model, legacy) in legacy_models { ++ let Some(provider) = legacy.provider.as_deref() else { ++ llm.models.insert(model, legacy); ++ continue; ++ }; ++ if provider.is_empty() { ++ return Err(ParseError::InvalidLegacyLlmModelProvider { model }); ++ } ++ ++ let provider = provider.to_string(); ++ let provider_settings = llm.providers.entry(provider.clone()).or_default(); ++ if provider_settings.models.contains_key(&model) { ++ return Err(ParseError::ConflictingLlmModelDefinitions { provider, model }); ++ } ++ provider_settings.models.insert(model, legacy.into_model()); ++ } ++ Ok(()) + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] +@@ -289,11 +332,91 @@ mod tests { + } + + #[test] +- fn accepts_new_llm_models_subtree() { +- let parsed = "[llm.models.\"foo\"]\nprovider = \"kimi\"\n" ++ fn accepts_provider_scoped_models_subtree() { ++ let parsed = "[llm.providers.kimi.models.\"foo\"]\ndisplay_name = \"Foo\"\n" + .parse::() +- .unwrap(); +- assert!(parsed.llm.unwrap().models.contains_key("foo")); ++ .expect("provider-scoped model should parse"); ++ let llm = parsed.llm.expect("llm layer should be present"); ++ ++ assert_eq!( ++ llm.providers["kimi"].models["foo"].display_name.as_deref(), ++ Some("Foo") ++ ); ++ assert!(llm.models.is_empty()); ++ } ++ ++ #[test] ++ fn normalizes_legacy_llm_model_with_provider_into_provider_scope() { ++ let parsed = r#" ++[llm.models.foo] ++provider = "kimi" ++display_name = "Foo" ++"# ++ .parse::() ++ .expect("legacy model should parse"); ++ let llm = parsed.llm.expect("llm layer should be present"); ++ ++ assert_eq!( ++ llm.providers["kimi"].models["foo"].display_name.as_deref(), ++ Some("Foo") ++ ); ++ assert!(llm.models.is_empty()); ++ } ++ ++ #[test] ++ fn retains_providerless_legacy_llm_model_for_catalog_aware_adoption() { ++ let parsed = r#" ++[llm.models.foo] ++display_name = "Renamed Foo" ++"# ++ .parse::() ++ .expect("provider-less legacy model should remain compatible"); ++ let llm = parsed.llm.expect("llm layer should be present"); ++ ++ assert_eq!( ++ llm.models["foo"].display_name.as_deref(), ++ Some("Renamed Foo") ++ ); ++ assert!(llm.providers.is_empty()); ++ } ++ ++ #[test] ++ fn rejects_same_source_legacy_and_provider_scoped_model_pair() { ++ let error = r#" ++[llm.providers.kimi.models.foo] ++display_name = "Canonical Foo" ++ ++[llm.models.foo] ++provider = "kimi" ++display_name = "Legacy Foo" ++"# ++ .parse::() ++ .expect_err("same pair in both syntaxes should be rejected"); ++ ++ assert_eq!( ++ error, ++ ParseError::ConflictingLlmModelDefinitions { ++ provider: "kimi".to_string(), ++ model: "foo".to_string(), ++ } ++ ); ++ } ++ ++ #[test] ++ fn rejects_empty_legacy_llm_model_provider_with_typed_error() { ++ let error = r#" ++[llm.models.foo] ++provider = "" ++"# ++ .parse::() ++ .expect_err("empty legacy provider should be rejected"); ++ ++ assert_eq!( ++ error, ++ ParseError::InvalidLegacyLlmModelProvider { ++ model: "foo".to_string(), ++ } ++ ); + } + + #[test] +diff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs +index ff95a8aec..2a6c487e7 100644 +--- a/lib/crates/fabro-config/src/tests/combine.rs ++++ b/lib/crates/fabro-config/src/tests/combine.rs +@@ -318,3 +318,117 @@ bucket = "higher-bucket" + assert_eq!(s3.bucket, Some("higher-bucket".to_string())); + assert_eq!(s3.region, None); + } ++ ++#[test] ++fn provider_and_model_rows_field_merge_independently() { ++ let lower = parse( ++ r#" ++[llm.providers.acme] ++display_name = "Acme" ++adapter = "openai_compatible" ++base_url = "https://lower.example/v1" ++ ++[llm.providers.acme.models.large] ++display_name = "Acme Large" ++family = "acme" ++ ++[llm.providers.acme.models.large.limits] ++context_window = 128000 ++max_output = 32000 ++"#, ++ ); ++ let higher = parse( ++ r#" ++[llm.providers.acme] ++base_url = "https://higher.example/v1" ++ ++[llm.providers.acme.models.large] ++display_name = "Acme Large v2" ++ ++[llm.providers.acme.models.large.limits] ++max_output = 64000 ++"#, ++ ); ++ ++ let merged = higher.combine(lower); ++ let acme = &merged.llm.expect("llm layer should be present").providers["acme"]; ++ assert_eq!(acme.display_name.as_deref(), Some("Acme")); ++ assert_eq!(acme.adapter.as_deref(), Some("openai_compatible")); ++ assert_eq!(acme.base_url.as_deref(), Some("https://higher.example/v1")); ++ ++ let model = &acme.models["large"]; ++ assert_eq!(model.display_name.as_deref(), Some("Acme Large v2")); ++ assert_eq!(model.family.as_deref(), Some("acme")); ++ assert_eq!( ++ model.limits.as_ref().and_then(|limits| limits.context_window), ++ Some(128_000) ++ ); ++ assert_eq!( ++ model.limits.as_ref().and_then(|limits| limits.max_output), ++ Some(64_000) ++ ); ++} ++ ++#[test] ++fn legacy_model_is_normalized_before_cross_source_combine() { ++ let lower = parse( ++ r#" ++[llm.models.large] ++provider = "acme" ++family = "acme" ++ ++[llm.models.large.limits] ++context_window = 128000 ++"#, ++ ); ++ let higher = parse( ++ r#" ++[llm.providers.acme.models.large] ++display_name = "Acme Large" ++ ++[llm.providers.acme.models.large.limits] ++max_output = 64000 ++"#, ++ ); ++ ++ let merged = higher.combine(lower); ++ let llm = merged.llm.expect("llm layer should be present"); ++ let model = &llm.providers["acme"].models["large"]; ++ ++ assert_eq!(model.display_name.as_deref(), Some("Acme Large")); ++ assert_eq!(model.family.as_deref(), Some("acme")); ++ assert_eq!( ++ model.limits.as_ref().and_then(|limits| limits.context_window), ++ Some(128_000) ++ ); ++ assert_eq!( ++ model.limits.as_ref().and_then(|limits| limits.max_output), ++ Some(64_000) ++ ); ++ assert!(llm.models.is_empty()); ++} ++ ++#[test] ++fn same_model_id_on_different_providers_stays_independent() { ++ let merged = parse( ++ r#" ++[llm.providers.openai.models.shared] ++api_id = "shared" ++ ++[llm.providers.openrouter.models.shared] ++api_id = "openai/shared" ++"#, ++ ); ++ let llm = merged.llm.expect("llm layer should be present"); ++ ++ assert_eq!( ++ llm.providers["openai"].models["shared"].api_id.as_deref(), ++ Some("shared") ++ ); ++ assert_eq!( ++ llm.providers["openrouter"].models["shared"] ++ .api_id ++ .as_deref(), ++ Some("openai/shared") ++ ); ++} +diff --git a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs +index aacc08575..a446d048a 100644 +--- a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs ++++ b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs +@@ -247,19 +247,20 @@ x-team-secret = "{{ secrets.gateway_team_secret }}" + | `auth.credentials` | array | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. | + | `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. | + | `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are interpolation strings: literal text, an `{{ env.NAME }}` token, or a `{{ secrets.NAME }}` token. Put credentials in a secret and reference them with a `{{ secrets.NAME }}` token, not a bare literal. | +-| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. | ++| `priority` | integer | `0` | Higher-priority ready providers win unqualified model selection; ties use canonical provider ID in ascending order. | + | `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. | + | `aliases` | array | `[]` | Additional provider names accepted by model routing and fallback config. | + +-## `[llm.models.]` ++## `[llm.providers..models.]` + +-Define or override a model in the catalog. The table key is the canonical +-model ID Fabro users reference; `api_id` is the model string sent to the +-provider API. ++Define or override one provider-specific model offering. The containing table ++supplies the provider ID, and `` is the canonical, human-facing model ++slug used by workflows. The stable identity of an offering is the pair ++`(provider, model)`; the same model slug and aliases may be reused by other ++providers. + + ```toml title="settings.toml" +-[llm.models."team-code-large"] +-provider = "proxy" ++[llm.providers.proxy.models."team-code-large"] + api_id = "provider-wire-model-name" + agent_profile = "anthropic" + display_name = "Team Code Large" +@@ -270,56 +271,66 @@ enabled = true + aliases = ["team-code"] + estimated_output_tps = 80 + +-[llm.models."team-code-large".limits] ++[llm.providers.proxy.models."team-code-large".limits] + context_window = 200000 + max_output = 32000 + +-[llm.models."team-code-large".features] ++[llm.providers.proxy.models."team-code-large".features] + tools = true + vision = false + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[llm.models."team-code-large".controls] ++[llm.providers.proxy.models."team-code-large".controls] + reasoning_effort = ["low", "medium", "high"] + speed = ["fast"] + +-[llm.models."team-code-large".costs] ++[llm.providers.proxy.models."team-code-large".costs] + input_cost_per_mtok = 1.50 + output_cost_per_mtok = 8.00 + cache_input_cost_per_mtok = 0.30 + +-[llm.models."team-code-large".costs.speed.fast] ++[llm.providers.proxy.models."team-code-large".costs.speed.fast] + input_cost_per_mtok = 3.00 + output_cost_per_mtok = 16.00 + cache_input_cost_per_mtok = 0.60 + ``` + ++`api_id` is an opaque provider wire identifier, not a workflow selector or a ++routing namespace. When omitted, it defaults to the exact canonical model ++slug. Set it only when the provider expects a different value; an explicitly ++empty value is invalid. ++ ++Unqualified model selectors consider ready providers and then choose the ++highest provider `priority`, with canonical provider ID as the deterministic ++tie-breaker. Supplying a provider pins lookup to that provider. An alias must ++identify only one model within a provider, but reusing it on another provider ++is valid and enables portable workflow selectors. ++ + | Key | Type / values | Default | Description | + |---|---|---|---| +-| `provider` | string | None | Provider ID this model belongs to. | +-| `api_id` | string | model ID | Identifier sent to the provider API. | ++| `api_id` | string | canonical model slug | Opaque identifier sent to this offering's provider API. It is not parsed for routing. | + | `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. | + | `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). | +-| `display_name` | string | model ID | Human-readable model name. | +-| `family` | string | model ID | Family label used for catalog display and matching. | ++| `display_name` | string | model slug | Human-readable model name. | ++| `family` | string | model slug | Family metadata used for catalog display and matching; it is not a routing namespace. | + | `training` | string | None | Training data cutoff label. | + | `knowledge_cutoff` | string or TOML date | None | Public knowledge cutoff label; TOML dates normalize to `YYYY-MM-DD`. | + | `default` | boolean | `false` | Whether this is the provider default model. | + | `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. | + | `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. | +-| `aliases` | array | `[]` | Additional model names accepted by routing and fallback config. | ++| `aliases` | array | `[]` | Additional user-facing selectors. Each selector must be unique within this provider but may be reused by other providers. | + | `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. | + +-## `[llm.models..limits]` ++## `[llm.providers..models..limits]` + + | Key | Type / values | Default | Description | + |---|---|---|---| + | `context_window` | integer | None | Maximum context window size in tokens. | + | `max_output` | integer | None | Maximum output tokens, if known. | + +-## `[llm.models..features]` ++## `[llm.providers..models..features]` + + | Key | Type / values | Default | Description | + |---|---|---|---| +@@ -330,14 +341,14 @@ cache_input_cost_per_mtok = 0.60 + | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | + | `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). | + +-## `[llm.models..controls]` ++## `[llm.providers..models..controls]` + + | Key | Type / values | Default | Description | + |---|---|---|---| + | `reasoning_effort` | array | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. | + | `speed` | array | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. | + +-## `[llm.models..costs]` ++## `[llm.providers..models..costs]` + + | Key | Type / values | Default | Description | + |---|---|---|---| +@@ -345,11 +356,12 @@ cache_input_cost_per_mtok = 0.60 + | `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. | + | `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. | + +-## `[llm.models..costs.speed.]` ++## `[llm.providers..models..costs.speed.]` + +-Per-speed cost overrides use the same keys as `[llm.models..costs]`. +-Each `` key must be declared in `[llm.models..controls].speed`. +-The `standard` speed is implicit and always uses the base cost table. ++Per-speed cost overrides use the same keys as ++`[llm.providers..models..costs]`. Each `` key must be ++declared in `[llm.providers..models..controls].speed`. The ++`standard` speed is implicit and always uses the base cost table. + + "#, + ); +@@ -389,3 +401,21 @@ See [MCP](/agents/mcp) for transport-specific examples. + fn normalize_doc(doc: &str) -> String { + doc.trim().trim_end_matches('.').to_string() + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn llm_catalog_reference_teaches_provider_scoped_portable_models() { ++ let reference = render_options_reference(); ++ ++ assert!(reference.contains("## `[llm.providers..models.]`")); ++ assert!(reference.contains("[llm.providers.proxy.models.\"team-code-large\"]")); ++ assert!(reference.contains("defaults to the exact canonical model\nslug")); ++ assert!(reference.contains("Supplying a provider pins lookup to that provider")); ++ assert!(reference.contains("reusing it on another provider\nis valid")); ++ assert!(reference.contains("opaque provider wire identifier")); ++ assert!(!reference.contains("## `[llm.models.]`")); ++ } ++} +diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs +index c72e9ea50..7fe5011e2 100644 +--- a/lib/crates/fabro-llm/src/client.rs ++++ b/lib/crates/fabro-llm/src/client.rs +@@ -597,6 +597,7 @@ fn format_additional_speeds(values: &[Speed]) -> String { + + #[cfg(test)] + mod tests { ++ use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; +diff --git a/lib/crates/fabro-llm/src/cost.rs b/lib/crates/fabro-llm/src/cost.rs +index e03ead5de..00371919c 100644 +--- a/lib/crates/fabro-llm/src/cost.rs ++++ b/lib/crates/fabro-llm/src/cost.rs +@@ -25,11 +25,11 @@ pub(crate) fn estimate_cost_usd( + let catalog = catalog?; + // The billing machinery compares ModelRefs against the catalog's + // canonical identity, so resolve model aliases and provider names first. +- let model = catalog.get(model)?; + let provider = catalog.provider(&ProviderId::new(provider))?; ++ let model = catalog.model_on_provider(&provider.id, model)?; + let model_ref = ModelRef { + provider: provider.id.clone(), +- model_id: model.id.clone(), ++ model_id: model.id.to_string(), + speed, + }; + let micros = catalog.price_tokens(&model_ref, tokens)?; +diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs +index 47bca353c..09dc1e632 100644 +--- a/lib/crates/fabro-llm/src/model_test.rs ++++ b/lib/crates/fabro-llm/src/model_test.rs +@@ -132,7 +132,7 @@ fn build_deep_test_params(info: &Model, client: Arc) -> Option) -> ModelRef { + ModelRef { + provider: self.provider.clone(), +- model_id: self.id.clone(), ++ model_id: self.id.to_string(), + speed, + } + } +@@ -544,7 +544,7 @@ fn pricing_for_model_costs( + Some(ModelPricing { + model: ModelRef { + provider: provider_id, +- model_id: model.id.clone(), ++ model_id: model.id.to_string(), + speed, + }, + policy, +diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs +index 9ae07fc8b..0cc3fa0a8 100644 +--- a/lib/crates/fabro-model/src/catalog.rs ++++ b/lib/crates/fabro-model/src/catalog.rs +@@ -12,7 +12,7 @@ use tracing::warn; + use crate::Speed; + use crate::adapter::{AdapterKind, AgentProfileKind}; + use crate::codec::CodecKind; +-use crate::ids::ProviderId; ++use crate::ids::{ModelId, ProviderId}; + use crate::provider::Provider; + use crate::reasoning::ReasoningEffort; + use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; +@@ -39,6 +39,9 @@ pub struct LlmCatalogSettings { + #[derive(Debug, Clone, Default, PartialEq, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct ProviderCatalogSettings { ++ /// Provider-scoped model rows keyed by canonical human-facing model slug. ++ #[serde(default)] ++ pub models: HashMap, + #[serde(default)] + pub display_name: Option, + #[serde(default)] +@@ -382,6 +385,16 @@ static GLOBAL_CATALOG: LazyLock = LazyLock::new(|| { + Catalog::from_builtin_toml().expect("embedded provider TOML files must build a valid catalog") + }); + ++/// A built-in model identifier that was replaced by a provider-scoped model ++/// slug. Retired identifiers are errors rather than aliases: silently accepting ++/// one could route a persisted reference to a different provider. ++#[derive(Debug, Clone, PartialEq, Eq)] ++pub struct RetiredModelIdentifier { ++ pub identifier: String, ++ pub provider: ProviderId, ++ pub model: ModelId, ++} ++ + /// A resolved fallback target: provider name + model ID. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct FallbackTarget { +@@ -528,12 +541,24 @@ pub enum CatalogBuildError { + model: String, + provider: ProviderId, + }, +- #[error("model identifier '{identifier}' is declared by both '{first}' and '{second}'")] +- DuplicateModelIdentifier { ++ #[error( ++ "provider '{provider}' model identifier '{identifier}' is declared by both '{first}' and '{second}'" ++ )] ++ DuplicateProviderModelIdentifier { ++ provider: ProviderId, + identifier: String, +- first: String, +- second: String, ++ first: ModelId, ++ second: ModelId, ++ }, ++ #[error("provider '{provider}' model '{model}' configures an empty api_id")] ++ EmptyModelApiId { ++ provider: ProviderId, ++ model: ModelId, + }, ++ #[error( ++ "legacy model row '{model}' does not name a provider and does not uniquely match a built-in offering" ++ )] ++ AmbiguousLegacyModelProvider { model: String }, + #[error("provider '{provider}' has multiple default models: {models:?}")] + MultipleProviderDefaults { + provider: ProviderId, +@@ -574,17 +599,44 @@ pub enum CatalogBuildError { + UndeclaredSpeedCost { model: String, speed: Speed }, + } + ++/// Failure to select one offering for a user-facing model selector. ++#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] ++pub enum ModelSelectionError { ++ #[error( ++ "model identifier '{identifier}' was retired; use provider '{provider}' with model '{model}'" ++ )] ++ RetiredIdentifier { ++ identifier: String, ++ provider: ProviderId, ++ model: ModelId, ++ }, ++ #[error("unknown model selector '{selector}'")] ++ UnknownSelector { selector: String }, ++ #[error("model selector '{selector}' has no offering on an eligible provider")] ++ NoEligibleOffering { selector: String }, ++ #[error("provider '{provider}' is not available")] ++ UnavailableProvider { provider: ProviderId }, ++ #[error("provider '{provider}' has no model matching selector '{selector}'")] ++ UnknownSelectorOnProvider { ++ provider: ProviderId, ++ selector: String, ++ }, ++} ++ + /// Typed model catalog backed by a `Vec`. + /// + /// Use [`Catalog::builtin()`] for the embedded settings-backed catalog. + #[derive(Debug)] + pub struct Catalog { +- models: Vec, +- providers: Vec, +- model_settings: HashMap, +- model_index: HashMap, +- provider_aliases: HashMap, +- provider_index: HashMap, ++ models: Vec, ++ providers: Vec, ++ model_settings: HashMap<(ProviderId, ModelId), CatalogModelSettings>, ++ offering_index: HashMap<(ProviderId, ModelId), usize>, ++ canonical_candidates: HashMap>, ++ alias_candidates: HashMap>, ++ provider_aliases: HashMap, ++ provider_index: HashMap, ++ retired_identifiers: HashMap, + } + + impl Catalog { +@@ -617,27 +669,25 @@ impl Catalog { + .collect(); + + let mut models_with_settings = Vec::new(); +- let mut model_identifiers = BTreeMap::::new(); ++ let mut model_identifiers = HashMap::>::new(); + let mut defaults_by_provider = HashMap::>::new(); + let mut small_defaults_by_provider = HashMap::>::new(); + +- let mut model_ids = settings.models.keys().cloned().collect::>(); +- model_ids.sort_unstable(); +- for model_id in model_ids { +- let model_settings = settings +- .models +- .get(&model_id) +- .expect("model ID came from settings map keys"); ++ let normalized_models = normalized_model_settings(settings)?; ++ let mut model_keys = normalized_models.keys().cloned().collect::>(); ++ model_keys.sort_unstable(); ++ for (provider_id, model_id) in model_keys { ++ let model_settings = normalized_models ++ .get(&(provider_id.clone(), model_id.clone())) ++ .expect("model key came from normalized settings"); + if model_settings.enabled == Some(false) { + continue; + } + +- let provider_id = +- required_model_string(&model_id, model_settings.provider.as_ref(), "provider")?; + if !known_providers.contains(provider_id.as_str()) { + return Err(CatalogBuildError::UnknownModelProvider { +- model: model_id, +- provider: ProviderId::from(provider_id), ++ model: model_id, ++ provider: provider_id, + }); + } + if !enabled_providers.contains(provider_id.as_str()) { +@@ -649,22 +699,33 @@ impl Catalog { + .expect("enabled provider ID should have provider metadata"); + let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; + +- register_model_identifier(&mut model_identifiers, model.id.clone(), model.id.clone())?; ++ let provider_identifiers = model_identifiers.entry(provider_id.clone()).or_default(); ++ register_model_identifier( ++ provider_identifiers, ++ model.id.as_str().to_string(), ++ model.id.clone(), ++ &provider_id, ++ )?; + for alias in &model.aliases { +- register_model_identifier(&mut model_identifiers, alias.clone(), model.id.clone())?; ++ register_model_identifier( ++ provider_identifiers, ++ alias.clone(), ++ model.id.clone(), ++ &provider_id, ++ )?; + } + + if model.default { + defaults_by_provider + .entry(model.provider.clone()) + .or_default() +- .push(model.id.clone()); ++ .push(model.id.to_string()); + } + if model.small_default { + small_defaults_by_provider + .entry(model.provider.clone()) + .or_default() +- .push(model.id.clone()); ++ .push(model.id.to_string()); + } + models_with_settings.push((model, resolved_settings)); + } +@@ -691,21 +752,25 @@ impl Catalog { + + models_with_settings.sort_by(|(left, _), (right, _)| model_order(left, right)); + warn_multiple_probe_models(&models_with_settings); +- let mut model_settings_by_id = HashMap::new(); ++ let mut model_settings = HashMap::new(); + let mut models = Vec::new(); + for (model, settings) in models_with_settings { +- model_settings_by_id.insert(model.id.clone(), settings); ++ model_settings.insert((model.provider.clone(), model.id.clone()), settings); + models.push(model); + } +- let model_index = build_model_index(&models); ++ let (offering_index, canonical_candidates, alias_candidates) = ++ build_model_indexes(&models); + + Ok(Self { + models, + providers, +- model_settings: model_settings_by_id, +- model_index, ++ model_settings, ++ offering_index, ++ canonical_candidates, ++ alias_candidates, + provider_aliases, + provider_index, ++ retired_identifiers: HashMap::new(), + }) + } + +@@ -713,8 +778,11 @@ impl Catalog { + overrides: &LlmCatalogSettings, + ) -> Result { + let builtins = Self::builtin_settings()?; +- let settings = merge_catalog_settings(overrides.clone(), builtins); +- Self::from_settings(&settings) ++ let overrides = adopt_legacy_models(overrides.clone(), &builtins)?; ++ let settings = merge_catalog_settings(overrides, builtins); ++ let mut catalog = Self::from_settings(&settings)?; ++ catalog.retired_identifiers = builtin_retired_identifiers(); ++ Ok(catalog) + } + + /// Builds a fresh catalog from embedded provider TOML without user +@@ -749,7 +817,13 @@ impl Catalog { + source, + })?; + validate_builtin_fragment(&path, &fragment)?; +- layer.providers.extend(fragment.providers); ++ for (id, provider) in fragment.providers { ++ let provider = match layer.providers.remove(&id) { ++ Some(existing) => merge_provider_settings(provider, existing), ++ None => provider, ++ }; ++ layer.providers.insert(id, provider); ++ } + layer.models.extend(fragment.models); + } + +@@ -757,17 +831,116 @@ impl Catalog { + } + + fn from_builtin_toml() -> Result { +- Self::from_settings(&Self::builtin_settings()?) ++ let mut catalog = Self::from_settings(&Self::builtin_settings()?)?; ++ catalog.retired_identifiers = builtin_retired_identifiers(); ++ Ok(catalog) + } + +- /// Look up a model by ID or alias. ++ /// Look up a selector across all enabled providers using catalog provider ++ /// priority. Runtime callers should prefer [`Self::select_model`] and pass ++ /// their ready-provider set explicitly. + #[must_use] +- pub fn get(&self, id: &str) -> Option<&Model> { +- self.model_index +- .get(id) ++ pub fn get(&self, selector: &str) -> Option<&Model> { ++ self.select_candidates(selector) ++ .and_then(|candidates| candidates.first()) + .and_then(|idx| self.models.get(*idx)) + } + ++ /// Resolve a canonical model ID or alias only on the named provider. ++ #[must_use] ++ pub fn model_on_provider( ++ &self, ++ provider_id: &ProviderId, ++ selector: &str, ++ ) -> Option<&Model> { ++ let provider = self.provider(provider_id)?; ++ if let Some(idx) = self ++ .offering_index ++ .get(&(provider.id.clone(), ModelId::new(selector))) ++ { ++ return self.models.get(*idx); ++ } ++ self.alias_candidates ++ .get(selector)? ++ .iter() ++ .filter_map(|idx| self.models.get(*idx)) ++ .find(|model| model.provider == provider.id) ++ } ++ ++ /// Return the replacement address for a retired built-in identifier. ++ #[must_use] ++ pub fn retired_identifier(&self, identifier: &str) -> Option { ++ let (provider, model) = self.retired_identifiers.get(identifier)?; ++ Some(RetiredModelIdentifier { ++ identifier: identifier.to_string(), ++ provider: provider.clone(), ++ model: model.clone(), ++ }) ++ } ++ ++ /// Select one offering for a selector from caller-supplied eligible ++ /// providers. Canonical ID candidates are considered before aliases; ++ /// candidates are pre-sorted by provider priority descending and canonical ++ /// provider ID ascending. ++ pub fn select_model( ++ &self, ++ selector: &str, ++ explicit_provider: Option<&ProviderId>, ++ eligible_providers: &[ProviderId], ++ ) -> Result<&Model, ModelSelectionError> { ++ if let Some(retired) = self.retired_identifier(selector) { ++ return Err(ModelSelectionError::RetiredIdentifier { ++ identifier: retired.identifier, ++ provider: retired.provider, ++ model: retired.model, ++ }); ++ } ++ ++ let eligible = eligible_providers ++ .iter() ++ .filter_map(|id| self.provider(id).map(|provider| provider.id.clone())) ++ .collect::>(); ++ ++ if let Some(explicit_provider) = explicit_provider { ++ let provider = self ++ .provider(explicit_provider) ++ .ok_or_else(|| ModelSelectionError::UnknownSelectorOnProvider { ++ provider: explicit_provider.clone(), ++ selector: selector.to_string(), ++ })?; ++ if !eligible.contains(&provider.id) { ++ return Err(ModelSelectionError::UnavailableProvider { ++ provider: provider.id.clone(), ++ }); ++ } ++ return self.model_on_provider(&provider.id, selector).ok_or_else(|| { ++ ModelSelectionError::UnknownSelectorOnProvider { ++ provider: provider.id.clone(), ++ selector: selector.to_string(), ++ } ++ }); ++ } ++ ++ let candidates = self ++ .select_candidates(selector) ++ .ok_or_else(|| ModelSelectionError::UnknownSelector { ++ selector: selector.to_string(), ++ })?; ++ candidates ++ .iter() ++ .filter_map(|idx| self.models.get(*idx)) ++ .find(|model| eligible.contains(&model.provider)) ++ .ok_or_else(|| ModelSelectionError::NoEligibleOffering { ++ selector: selector.to_string(), ++ }) ++ } ++ ++ fn select_candidates(&self, selector: &str) -> Option<&Vec> { ++ self.canonical_candidates ++ .get(selector) ++ .or_else(|| self.alias_candidates.get(selector)) ++ } ++ + #[must_use] + pub fn providers(&self) -> &[CatalogProvider] { + &self.providers +@@ -786,7 +959,7 @@ impl Catalog { + let stats = stats_by_provider.entry(model.provider.clone()).or_default(); + stats.model_count = stats.model_count.saturating_add(1); + if model.default { +- stats.default_model = Some(model.id.clone()); ++ stats.default_model = Some(model.id.to_string()); + } + } + +@@ -817,10 +990,29 @@ impl Catalog { + self.provider(id)?.vault_secret_name() + } + ++ /// Resolve settings for the highest-priority offering matching a selector. ++ /// Prefer [`Self::model_settings_on_provider`] or ++ /// [`Self::model_settings_for`] when provider identity is known. ++ #[must_use] ++ pub fn model_settings(&self, selector: &str) -> Option<&CatalogModelSettings> { ++ let model = self.get(selector)?; ++ self.model_settings_for(model) ++ } ++ + #[must_use] +- pub fn model_settings(&self, id: &str) -> Option<&CatalogModelSettings> { +- let model = self.get(id)?; +- self.model_settings.get(&model.id) ++ pub fn model_settings_on_provider( ++ &self, ++ provider: &ProviderId, ++ selector: &str, ++ ) -> Option<&CatalogModelSettings> { ++ let model = self.model_on_provider(provider, selector)?; ++ self.model_settings_for(model) ++ } ++ ++ #[must_use] ++ pub fn model_settings_for(&self, model: &Model) -> Option<&CatalogModelSettings> { ++ self.model_settings ++ .get(&(model.provider.clone(), model.id.clone())) + } + + #[must_use] +@@ -831,9 +1023,8 @@ impl Catalog { + ) -> Option { + let provider = self.provider(provider_id)?; + let model_profile = model_id_or_alias +- .and_then(|model_id| self.get(model_id)) +- .filter(|model| model.provider == provider.id) +- .and_then(|model| self.model_settings.get(&model.id)) ++ .and_then(|model_id| self.model_on_provider(&provider.id, model_id)) ++ .and_then(|model| self.model_settings_for(model)) + .map(|settings| settings.agent_profile); + Some(model_profile.unwrap_or(provider.agent_profile)) + } +@@ -849,9 +1040,8 @@ impl Catalog { + ) -> Option { + let provider = self.provider(provider_id)?; + let model_codec = model_id_or_alias +- .and_then(|model_id| self.get(model_id)) +- .filter(|model| model.provider == provider.id) +- .and_then(|model| self.model_settings.get(&model.id)) ++ .and_then(|model_id| self.model_on_provider(&provider.id, model_id)) ++ .and_then(|model| self.model_settings_for(model)) + .map(|settings| settings.codec); + Some(model_codec.unwrap_or(provider.codec)) + } +@@ -867,9 +1057,8 @@ impl Catalog { + ) -> Option { + let provider = self.provider(provider_id)?; + let model_policy = model_id_or_alias +- .and_then(|model_id| self.get(model_id)) +- .filter(|model| model.provider == provider.id) +- .and_then(|model| self.model_settings.get(&model.id)) ++ .and_then(|model_id| self.model_on_provider(&provider.id, model_id)) ++ .and_then(|model| self.model_settings_for(model)) + .map(|settings| settings.billing_policy); + Some(model_policy.unwrap_or(provider.billing_policy)) + } +@@ -993,8 +1182,7 @@ impl Catalog { + if let Some(model) = self.models.iter().find(|model| { + &model.provider == provider_id + && self +- .model_settings +- .get(&model.id) ++ .model_settings_for(model) + .is_some_and(|settings| settings.probe) + }) { + return Some(model); +@@ -1043,7 +1231,7 @@ impl Catalog { + model: &str, + fallbacks: &HashMap>, + ) -> Vec { +- let Some(reference) = self.get(model) else { ++ let Some(reference) = self.model_on_provider(primary, model) else { + return Vec::new(); + }; + +@@ -1057,22 +1245,250 @@ impl Catalog { + let provider = ProviderId::from(provider_str.clone()); + self.closest(&provider, reference).map(|m| FallbackTarget { + provider: provider_str.clone(), +- model: m.id.clone(), ++ model: m.id.to_string(), + }) + }) + .collect() + } + } + +-fn build_model_index(models: &[Model]) -> HashMap { +- let mut index = HashMap::new(); ++type OfferingIndex = HashMap<(ProviderId, ModelId), usize>; ++type CanonicalCandidates = HashMap>; ++type AliasCandidates = HashMap>; ++ ++fn builtin_retired_identifiers() -> HashMap { ++ const RETIRED: &[(&str, &str, &str)] = &[ ++ ("openai.gpt-5.5", "bedrock-openai", "gpt-5.5"), ++ ("openai.gpt-5.4", "bedrock-openai", "gpt-5.4"), ++ ( ++ "us.anthropic.claude-sonnet-4-6", ++ "bedrock", ++ "claude-sonnet-4-6", ++ ), ++ ( ++ "us.anthropic.claude-opus-4-8", ++ "bedrock", ++ "claude-opus-4-8", ++ ), ++ ( ++ "us.anthropic.claude-haiku-4-5", ++ "bedrock", ++ "claude-haiku-4-5", ++ ), ++ ("openai.gpt-oss-120b", "bedrock", "gpt-oss-120b"), ++ ("openai.gpt-oss-20b", "bedrock", "gpt-oss-20b"), ++ ("amazon.nova-2-lite", "bedrock", "nova-2-lite"), ++ ("meta.llama4-maverick", "bedrock", "llama-4-maverick"), ++ ( ++ "mistral.mistral-large-3", ++ "bedrock", ++ "mistral-large-3", ++ ), ++ ("mistral.devstral-2", "bedrock", "devstral-2"), ++ ("deepseek.v3-2", "bedrock", "deepseek-v3.2"), ++ ("moonshotai.kimi-k2.5", "bedrock", "kimi-k2.5"), ++ ("zai.glm-5", "bedrock", "glm-5"), ++ ("minimax.minimax-m2.5", "bedrock", "minimax-m2.5"), ++ ( ++ "nvidia.nemotron-3-super", ++ "bedrock", ++ "nemotron-3-super", ++ ), ++ ( ++ "us.anthropic.claude-fable-5", ++ "bedrock", ++ "claude-fable-5", ++ ), ++ ( ++ "anthropic/claude-opus-4-7", ++ "openrouter", ++ "claude-opus-4-7", ++ ), ++ ( ++ "anthropic/claude-sonnet-4-6", ++ "openrouter", ++ "claude-sonnet-4-6", ++ ), ++ ( ++ "anthropic/claude-haiku-4-5", ++ "openrouter", ++ "claude-haiku-4-5", ++ ), ++ ("openai/gpt-5.4", "openrouter", "gpt-5.4"), ++ ("openai/gpt-5.5", "openrouter", "gpt-5.5"), ++ ( ++ "google/gemini-3.1-pro-preview", ++ "openrouter", ++ "gemini-3.1-pro-preview", ++ ), ++ ( ++ "google/gemini-3.5-flash", ++ "openrouter", ++ "gemini-3.5-flash", ++ ), ++ ("xiaomi/mimo-v2.5-pro", "openrouter", "mimo-v2.5-pro"), ++ ( ++ "minimax/minimax-m2.7", ++ "openrouter", ++ "minimax-m2.7", ++ ), ++ ( ++ "deepseek/deepseek-v4-pro", ++ "openrouter", ++ "deepseek-v4-pro", ++ ), ++ ( ++ "deepseek/deepseek-v4-flash", ++ "openrouter", ++ "deepseek-v4-flash", ++ ), ++ ("moonshotai/kimi-k2.6", "openrouter", "kimi-k2.6"), ++ ("moonshotai/kimi-k3", "openrouter", "kimi-k3"), ++ ( ++ "poolside/laguna-s-2.1", ++ "openrouter", ++ "laguna-s-2.1", ++ ), ++ ( ++ "poolside/laguna-xs-2.1", ++ "openrouter", ++ "laguna-xs-2.1", ++ ), ++ ("qwen/qwen3-coder", "openrouter", "qwen3-coder"), ++ ("qwen/qwen3.6-flash", "openrouter", "qwen3.6-flash"), ++ ("z-ai/glm-5.2", "openrouter", "glm-5.2"), ++ ("z-ai/glm-4.6", "openrouter", "glm-4.6"), ++ ( ++ "nvidia/nemotron-3-super-120b-a12b", ++ "openrouter", ++ "nemotron-3-super", ++ ), ++ ("mistralai/devstral-2512", "openrouter", "devstral-2"), ++ ]; ++ ++ RETIRED ++ .iter() ++ .map(|(identifier, provider, model)| { ++ ( ++ (*identifier).to_string(), ++ (ProviderId::new(*provider), ModelId::new(*model)), ++ ) ++ }) ++ .collect() ++} ++ ++fn build_model_indexes( ++ models: &[Model], ++) -> (OfferingIndex, CanonicalCandidates, AliasCandidates) { ++ let mut offering_index = HashMap::new(); ++ let mut canonical_candidates = HashMap::>::new(); ++ let mut alias_candidates = HashMap::>::new(); + for (idx, model) in models.iter().enumerate() { +- index.insert(model.id.clone(), idx); ++ offering_index.insert((model.provider.clone(), model.id.clone()), idx); ++ canonical_candidates ++ .entry(model.id.clone()) ++ .or_default() ++ .push(idx); + for alias in &model.aliases { +- index.insert(alias.clone(), idx); ++ alias_candidates.entry(alias.clone()).or_default().push(idx); ++ } ++ } ++ (offering_index, canonical_candidates, alias_candidates) ++} ++ ++fn adopt_legacy_models( ++ mut overrides: LlmCatalogSettings, ++ builtins: &LlmCatalogSettings, ++) -> Result { ++ let legacy_models = std::mem::take(&mut overrides.models); ++ for (identifier, mut settings) in legacy_models { ++ let provider = match settings.provider.take() { ++ Some(provider) if !provider.is_empty() => ProviderId::new(provider), ++ _ => unique_builtin_provider_for_identifier(builtins, &identifier) ++ .ok_or_else(|| CatalogBuildError::AmbiguousLegacyModelProvider { ++ model: identifier.clone(), ++ })?, ++ }; ++ let canonical_model = builtins ++ .providers ++ .get(provider.as_str()) ++ .and_then(|provider_settings| { ++ provider_settings ++ .models ++ .iter() ++ .find(|(model_id, model_settings)| { ++ model_id.as_str() == identifier ++ || model_settings ++ .aliases ++ .as_ref() ++ .is_some_and(|aliases| aliases.iter().any(|alias| alias == &identifier)) ++ }) ++ .map(|(model_id, _)| model_id.clone()) ++ }) ++ .unwrap_or(identifier); ++ let provider_settings = overrides ++ .providers ++ .entry(provider.into_inner()) ++ .or_default(); ++ let merged = match provider_settings.models.remove(&canonical_model) { ++ Some(scoped) => merge_model_settings(settings, scoped), ++ None => settings, ++ }; ++ provider_settings.models.insert(canonical_model, merged); ++ } ++ Ok(overrides) ++} ++ ++fn unique_builtin_provider_for_identifier( ++ builtins: &LlmCatalogSettings, ++ identifier: &str, ++) -> Option { ++ let mut matches = builtins.providers.iter().filter_map(|(provider, settings)| { ++ settings ++ .models ++ .iter() ++ .any(|(model_id, model)| { ++ model_id == identifier ++ || model ++ .aliases ++ .as_ref() ++ .is_some_and(|aliases| aliases.iter().any(|alias| alias == identifier)) ++ }) ++ .then(|| ProviderId::new(provider)) ++ }); ++ let provider = matches.next()?; ++ matches.next().is_none().then_some(provider) ++} ++ ++fn normalized_model_settings( ++ settings: &LlmCatalogSettings, ++) -> Result, CatalogBuildError> { ++ let mut normalized = HashMap::new(); ++ ++ for (provider, provider_settings) in &settings.providers { ++ let provider_id = ProviderId::new(provider); ++ for (model_id, model_settings) in &provider_settings.models { ++ normalized.insert( ++ (provider_id.clone(), model_id.clone()), ++ model_settings.clone(), ++ ); + } + } +- index ++ ++ // Legacy top-level rows remain an input-only compatibility shape. A ++ // provider is required here; fabro-config performs catalog-aware adoption ++ // for provider-less rows before constructing these settings. ++ for (model_id, model_settings) in &settings.models { ++ let provider = required_model_string(model_id, model_settings.provider.as_ref(), "provider")?; ++ let key = (ProviderId::new(provider), model_id.clone()); ++ let merged = match normalized.remove(&key) { ++ Some(scoped) => merge_model_settings(model_settings.clone(), scoped), ++ None => model_settings.clone(), ++ }; ++ normalized.insert(key, merged); ++ } ++ ++ Ok(normalized) + } + + fn merge_catalog_settings( +@@ -1115,9 +1531,24 @@ fn merge_provider_settings( + priority: higher.priority.or(fallback.priority), + enabled: higher.enabled.or(fallback.enabled), + aliases: higher.aliases.or(fallback.aliases), ++ models: merge_model_maps(higher.models, fallback.models), + } + } + ++fn merge_model_maps( ++ higher: HashMap, ++ mut fallback: HashMap, ++) -> HashMap { ++ for (id, model) in higher { ++ let model = match fallback.remove(&id) { ++ Some(fallback_model) => merge_model_settings(model, fallback_model), ++ None => model, ++ }; ++ fallback.insert(id, model); ++ } ++ fallback ++} ++ + fn merge_model_settings( + higher: ModelCatalogSettings, + fallback: ModelCatalogSettings, +@@ -1418,7 +1849,7 @@ fn build_model( + let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; + + let model = Model { +- id: model_id.to_string(), ++ id: ModelId::new(model_id), + provider: provider.id.clone(), + family, + display_name, +@@ -1436,6 +1867,12 @@ fn build_model( + small_default: settings.small_default.unwrap_or_default(), + configured: false, + }; ++ if settings.api_id.as_deref() == Some("") { ++ return Err(CatalogBuildError::EmptyModelApiId { ++ provider: provider.id.clone(), ++ model: ModelId::new(model_id), ++ }); ++ } + let catalog_settings = CatalogModelSettings { + api_id: settings + .api_id +@@ -1458,7 +1895,7 @@ fn warn_multiple_probe_models(models_with_settings: &[(Model, CatalogModelSettin + probes_by_provider + .entry(model.provider.clone()) + .or_default() +- .push(model.id.clone()); ++ .push(model.id.to_string()); + } + } + +@@ -1675,16 +2112,20 @@ fn register_provider_identifier( + } + + fn register_model_identifier( +- identifiers: &mut BTreeMap, ++ identifiers: &mut BTreeMap, + identifier: String, +- owner: String, ++ owner: ModelId, ++ provider: &ProviderId, + ) -> Result<(), CatalogBuildError> { + match identifiers.get(&identifier) { +- Some(existing) if existing != &owner => Err(CatalogBuildError::DuplicateModelIdentifier { +- identifier, +- first: existing.clone(), +- second: owner, +- }), ++ Some(existing) if existing != &owner => { ++ Err(CatalogBuildError::DuplicateProviderModelIdentifier { ++ provider: provider.clone(), ++ identifier, ++ first: existing.clone(), ++ second: owner, ++ }) ++ } + _ => { + identifiers.insert(identifier, owner); + Ok(()) +@@ -1733,6 +2174,20 @@ fn validate_builtin_fragment( + }); + } + } ++ let provider = fragment ++ .providers ++ .get(expected) ++ .expect("provider count and ID were validated"); ++ if !fragment.models.is_empty() && !provider.models.is_empty() { ++ // Embedded fragments are canonical output rather than compatibility ++ // inputs; mixing shapes would make ownership unclear. ++ return Err(CatalogBuildError::BuiltinModelProviderMismatch { ++ path: path.to_string(), ++ model: "".to_string(), ++ expected: expected.to_string(), ++ actual: "top-level models".to_string(), ++ }); ++ } + Ok(()) + } + +@@ -1762,6 +2217,255 @@ mod tests { + toml::from_str(source).expect("fixture should parse as an LLM settings layer") + } + ++ const PORTABLE_MODEL_SETTINGS: &str = r#" ++[providers.openai] ++display_name = "OpenAI" ++adapter = "openai" ++priority = 90 ++ ++[providers.openai.models."gpt-5.6-sol"] ++display_name = "GPT-5.6 Sol" ++family = "gpt-5" ++aliases = ["gpt-56-sol"] ++default = true ++ ++[providers.openai.models."gpt-5.6-sol".limits] ++context_window = 1000 ++ ++[providers.openai.models."gpt-5.6-sol".features] ++tools = true ++vision = false ++reasoning = true ++ ++[providers.openrouter] ++display_name = "OpenRouter" ++adapter = "openai_compatible" ++base_url = "https://openrouter.invalid/v1" ++priority = 25 ++ ++[providers.openrouter.models."gpt-5.6-sol"] ++api_id = "openai/gpt-5.6-sol" ++display_name = "GPT-5.6 Sol (via OpenRouter)" ++family = "gpt-5" ++aliases = ["gpt-56-sol"] ++default = true ++ ++[providers.openrouter.models."gpt-5.6-sol".limits] ++context_window = 1000 ++ ++[providers.openrouter.models."gpt-5.6-sol".features] ++tools = true ++vision = false ++reasoning = true ++"#; ++ ++ fn portable_catalog() -> Catalog { ++ Catalog::from_settings(&minimal_settings(PORTABLE_MODEL_SETTINGS)) ++ .expect("portable fixture should build") ++ } ++ ++ #[test] ++ fn provider_aware_catalog_allows_shared_canonical_ids_and_aliases() { ++ let catalog = portable_catalog(); ++ let openai = catalog ++ .model_on_provider(&ProviderId::new("openai"), "gpt-56-sol") ++ .expect("OpenAI alias should resolve"); ++ let openrouter = catalog ++ .model_on_provider(&ProviderId::new("openrouter"), "gpt-56-sol") ++ .expect("OpenRouter alias should resolve"); ++ ++ assert_eq!(openai.id.as_str(), "gpt-5.6-sol"); ++ assert_eq!(openrouter.id.as_str(), "gpt-5.6-sol"); ++ assert_ne!(openai.provider, openrouter.provider); ++ assert_eq!( ++ catalog ++ .model_settings_for(openai) ++ .expect("OpenAI settings should exist") ++ .api_id, ++ "gpt-5.6-sol" ++ ); ++ assert_eq!( ++ catalog ++ .model_settings_for(openrouter) ++ .expect("OpenRouter settings should exist") ++ .api_id, ++ "openai/gpt-5.6-sol" ++ ); ++ } ++ ++ #[test] ++ fn provider_aware_selection_uses_eligibility_priority_and_explicit_pin() { ++ let catalog = portable_catalog(); ++ let openai = ProviderId::new("openai"); ++ let openrouter = ProviderId::new("openrouter"); ++ ++ assert_eq!( ++ catalog ++ .select_model("gpt-56-sol", None, std::slice::from_ref(&openai)) ++ .unwrap() ++ .provider, ++ openai ++ ); ++ assert_eq!( ++ catalog ++ .select_model("gpt-56-sol", None, std::slice::from_ref(&openrouter)) ++ .unwrap() ++ .provider, ++ openrouter ++ ); ++ assert_eq!( ++ catalog ++ .select_model("gpt-56-sol", None, &[openrouter.clone(), openai.clone()]) ++ .unwrap() ++ .provider, ++ openai ++ ); ++ assert_eq!( ++ catalog ++ .select_model( ++ "gpt-56-sol", ++ Some(&openrouter), ++ &[openai.clone(), openrouter.clone()], ++ ) ++ .unwrap() ++ .provider, ++ openrouter ++ ); ++ assert!(matches!( ++ catalog.select_model("gpt-56-sol", Some(&openrouter), &[openai]), ++ Err(ModelSelectionError::UnavailableProvider { provider }) ++ if provider == openrouter ++ )); ++ } ++ ++ #[test] ++ fn provider_aware_selection_ties_by_canonical_provider_id() { ++ let settings = PORTABLE_MODEL_SETTINGS ++ .replace("priority = 90", "priority = 25"); ++ let catalog = Catalog::from_settings(&minimal_settings(&settings)).unwrap(); ++ ++ assert_eq!( ++ catalog ++ .select_model( ++ "gpt-56-sol", ++ None, ++ &[ProviderId::new("openrouter"), ProviderId::new("openai")], ++ ) ++ .unwrap() ++ .provider, ++ ProviderId::new("openai") ++ ); ++ } ++ ++ #[test] ++ fn canonical_id_candidates_shadow_cross_provider_alias_candidates() { ++ let settings = minimal_settings( ++ r#" ++[providers.canonical] ++adapter = "openai" ++priority = 1 ++ ++[providers.canonical.models.pin] ++display_name = "Canonical Pin" ++family = "pin" ++default = true ++[providers.canonical.models.pin.limits] ++context_window = 1000 ++[providers.canonical.models.pin.features] ++tools = false ++vision = false ++reasoning = false ++ ++[providers.alias] ++adapter = "openai" ++priority = 100 ++ ++[providers.alias.models.other] ++display_name = "Alias Pin" ++family = "pin" ++aliases = ["pin"] ++default = true ++[providers.alias.models.other.limits] ++context_window = 1000 ++[providers.alias.models.other.features] ++tools = false ++vision = false ++reasoning = false ++"#, ++ ); ++ let catalog = Catalog::from_settings(&settings).unwrap(); ++ let canonical = ProviderId::new("canonical"); ++ let alias = ProviderId::new("alias"); ++ ++ assert_eq!( ++ catalog ++ .select_model("pin", None, &[alias.clone(), canonical.clone()]) ++ .unwrap() ++ .provider, ++ canonical ++ ); ++ assert!(matches!( ++ catalog.select_model("pin", None, std::slice::from_ref(&alias)), ++ Err(ModelSelectionError::NoEligibleOffering { selector }) if selector == "pin" ++ )); ++ assert_eq!( ++ catalog ++ .select_model("pin", Some(&alias), std::slice::from_ref(&alias)) ++ .unwrap() ++ .id ++ .as_str(), ++ "other" ++ ); ++ } ++ ++ #[test] ++ fn same_provider_identifier_collision_is_rejected() { ++ let source = PORTABLE_MODEL_SETTINGS.replace( ++ "[providers.openai.models.\"gpt-5.6-sol\".limits]", ++ r#"[providers.openai.models.other] ++display_name = "Other" ++family = "gpt-5" ++aliases = ["gpt-56-sol"] ++[providers.openai.models.other.limits] ++context_window = 1000 ++[providers.openai.models.other.features] ++tools = true ++vision = false ++reasoning = true ++ ++[providers.openai.models."gpt-5.6-sol".limits]"#, ++ ); ++ let err = Catalog::from_settings(&minimal_settings(&source)).unwrap_err(); ++ ++ assert!(matches!( ++ err, ++ CatalogBuildError::DuplicateProviderModelIdentifier { ++ provider, ++ identifier, ++ first, ++ second, ++ } if provider == ProviderId::new("openai") ++ && identifier == "gpt-56-sol" ++ && first.as_str() == "gpt-5.6-sol" ++ && second.as_str() == "other" ++ )); ++ } ++ ++ #[test] ++ fn explicitly_empty_api_id_is_rejected() { ++ let settings = PORTABLE_MODEL_SETTINGS.replace( ++ "display_name = \"GPT-5.6 Sol\"", ++ "api_id = \"\"\ndisplay_name = \"GPT-5.6 Sol\"", ++ ); ++ let err = Catalog::from_settings(&minimal_settings(&settings)).unwrap_err(); ++ ++ assert!(matches!( ++ err, ++ CatalogBuildError::EmptyModelApiId { provider, model } ++ if provider == ProviderId::new("openai") && model.as_str() == "gpt-5.6-sol" ++ )); ++ } ++ + const BEDROCK_SIGV4_LAYER: &str = r#" + [providers.bedrock] + adapter = "bedrock" +@@ -2807,8 +3511,15 @@ reasoning = false + + assert!(matches!( + err, +- CatalogBuildError::DuplicateModelIdentifier { identifier, first, second } +- if identifier == "shared" && first == "one" && second == "two" ++ CatalogBuildError::DuplicateProviderModelIdentifier { ++ provider, ++ identifier, ++ first, ++ second, ++ } if provider == ProviderId::new("test") ++ && identifier == "shared" ++ && first.as_str() == "one" ++ && second.as_str() == "two" + )); + } + +diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +index 0a1587197..95649e214 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +@@ -9,18 +9,16 @@ priority = 100 + credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"] + header = { custom = "x-api-key" } + +-[models."claude-fable-5"] +-provider = "anthropic" +-api_id = "claude-fable-5" ++[providers.anthropic.models."claude-fable-5"] + display_name = "Claude Fable 5" + family = "claude-5" + aliases = ["fable", "claude-fable"] + +-[models."claude-fable-5".limits] ++[providers.anthropic.models."claude-fable-5".limits] + context_window = 1000000 + max_output = 128000 + +-[models."claude-fable-5".features] ++[providers.anthropic.models."claude-fable-5".features] + tools = true + vision = true + reasoning = true +@@ -28,14 +26,12 @@ reasoning_effort = "always_adaptive" + prompt_cache = true + sampling_params = false + +-[models."claude-fable-5".costs] ++[providers.anthropic.models."claude-fable-5".costs] + input_cost_per_mtok = 10.0 + output_cost_per_mtok = 50.0 + cache_input_cost_per_mtok = 1.0 + +-[models."claude-opus-4-8"] +-provider = "anthropic" +-api_id = "claude-opus-4-8" ++[providers.anthropic.models."claude-opus-4-8"] + display_name = "Claude Opus 4.8" + family = "claude-4" + training = "2026-01-01" +@@ -43,11 +39,11 @@ knowledge_cutoff = "Jan 2026" + estimated_output_tps = 25 + aliases = ["opus", "claude-opus"] + +-[models."claude-opus-4-8".limits] ++[providers.anthropic.models."claude-opus-4-8".limits] + context_window = 1000000 + max_output = 128000 + +-[models."claude-opus-4-8".features] ++[providers.anthropic.models."claude-opus-4-8".features] + tools = true + vision = true + reasoning = true +@@ -55,33 +51,31 @@ reasoning_effort = "levels" + prompt_cache = true + sampling_params = false + +-[models."claude-opus-4-8".controls] ++[providers.anthropic.models."claude-opus-4-8".controls] + speed = ["fast"] + +-[models."claude-opus-4-8".costs] ++[providers.anthropic.models."claude-opus-4-8".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 25.0 + cache_input_cost_per_mtok = 0.5 + +-[models."claude-opus-4-8".costs.speed.fast] ++[providers.anthropic.models."claude-opus-4-8".costs.speed.fast] + input_cost_per_mtok = 10.0 + output_cost_per_mtok = 50.0 + cache_input_cost_per_mtok = 1.0 + +-[models."claude-opus-4-7"] +-provider = "anthropic" +-api_id = "claude-opus-4-7" ++[providers.anthropic.models."claude-opus-4-7"] + display_name = "Claude Opus 4.7" + family = "claude-4" + training = "2025-08-01" + knowledge_cutoff = "May 2025" + estimated_output_tps = 25 + +-[models."claude-opus-4-7".limits] ++[providers.anthropic.models."claude-opus-4-7".limits] + context_window = 1000000 + max_output = 128000 + +-[models."claude-opus-4-7".features] ++[providers.anthropic.models."claude-opus-4-7".features] + tools = true + vision = true + reasoning = true +@@ -89,82 +83,76 @@ reasoning_effort = "levels" + prompt_cache = true + sampling_params = false + +-[models."claude-opus-4-7".controls] ++[providers.anthropic.models."claude-opus-4-7".controls] + speed = ["fast"] + +-[models."claude-opus-4-7".costs] ++[providers.anthropic.models."claude-opus-4-7".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 25.0 + cache_input_cost_per_mtok = 0.5 + +-[models."claude-opus-4-7".costs.speed.fast] ++[providers.anthropic.models."claude-opus-4-7".costs.speed.fast] + input_cost_per_mtok = 30.0 + output_cost_per_mtok = 150.0 + cache_input_cost_per_mtok = 3.0 + +-[models."claude-opus-4-6"] +-provider = "anthropic" +-api_id = "claude-opus-4-6" ++[providers.anthropic.models."claude-opus-4-6"] + display_name = "Claude Opus 4.6" + family = "claude-4" + training = "2025-08-01" + knowledge_cutoff = "May 2025" + estimated_output_tps = 25 + +-[models."claude-opus-4-6".limits] ++[providers.anthropic.models."claude-opus-4-6".limits] + context_window = 1000000 + max_output = 128000 + +-[models."claude-opus-4-6".features] ++[providers.anthropic.models."claude-opus-4-6".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."claude-opus-4-6".controls] ++[providers.anthropic.models."claude-opus-4-6".controls] + speed = ["fast"] + +-[models."claude-opus-4-6".costs] ++[providers.anthropic.models."claude-opus-4-6".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 25.0 + cache_input_cost_per_mtok = 0.5 + +-[models."claude-opus-4-6".costs.speed.fast] ++[providers.anthropic.models."claude-opus-4-6".costs.speed.fast] + input_cost_per_mtok = 30.0 + output_cost_per_mtok = 150.0 + cache_input_cost_per_mtok = 3.0 + +-[models."claude-sonnet-4-5"] +-provider = "anthropic" +-api_id = "claude-sonnet-4-5" ++[providers.anthropic.models."claude-sonnet-4-5"] + display_name = "Claude Sonnet 4.5" + family = "claude-4" + training = "2025-08-01" + knowledge_cutoff = "May 2025" + estimated_output_tps = 50 + +-[models."claude-sonnet-4-5".limits] ++[providers.anthropic.models."claude-sonnet-4-5".limits] + context_window = 200000 + max_output = 64000 + +-[models."claude-sonnet-4-5".features] ++[providers.anthropic.models."claude-sonnet-4-5".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + +-[models."claude-sonnet-4-5".controls] ++[providers.anthropic.models."claude-sonnet-4-5".controls] + reasoning_effort = ["low", "medium", "high", "xhigh", "max"] + +-[models."claude-sonnet-4-5".costs] ++[providers.anthropic.models."claude-sonnet-4-5".costs] + input_cost_per_mtok = 3.0 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.3 + +-[models."claude-sonnet-4-6"] +-provider = "anthropic" +-api_id = "claude-sonnet-4-6" ++[providers.anthropic.models."claude-sonnet-4-6"] + display_name = "Claude Sonnet 4.6" + family = "claude-4" + training = "2025-08-01" +@@ -173,25 +161,23 @@ default = true + estimated_output_tps = 50 + aliases = ["sonnet", "claude-sonnet"] + +-[models."claude-sonnet-4-6".limits] ++[providers.anthropic.models."claude-sonnet-4-6".limits] + context_window = 200000 + max_output = 64000 + +-[models."claude-sonnet-4-6".features] ++[providers.anthropic.models."claude-sonnet-4-6".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."claude-sonnet-4-6".costs] ++[providers.anthropic.models."claude-sonnet-4-6".costs] + input_cost_per_mtok = 3.0 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.3 + +-[models."claude-haiku-4-5"] +-provider = "anthropic" +-api_id = "claude-haiku-4-5" ++[providers.anthropic.models."claude-haiku-4-5"] + display_name = "Claude Haiku 4.5" + family = "claude-4" + training = "2025-08-01" +@@ -201,17 +187,17 @@ aliases = ["haiku", "claude-haiku"] + probe = true + small_default = true + +-[models."claude-haiku-4-5".limits] ++[providers.anthropic.models."claude-haiku-4-5".limits] + context_window = 200000 + max_output = 8192 + +-[models."claude-haiku-4-5".features] ++[providers.anthropic.models."claude-haiku-4-5".features] + tools = true + vision = true + reasoning = false + prompt_cache = true + +-[models."claude-haiku-4-5".costs] ++[providers.anthropic.models."claude-haiku-4-5".costs] + input_cost_per_mtok = 0.8 + output_cost_per_mtok = 4.0 + cache_input_cost_per_mtok = 0.08 +diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml b/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml +index 20b011b9c..21384d9b2 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml +@@ -35,41 +35,41 @@ credentials = [ + # [llm.providers.bedrock-openai] + # enabled = true + +-[models."openai.gpt-5.5"] +-provider = "bedrock-openai" ++[providers.bedrock-openai.models."gpt-5.5"] ++api_id = "openai.gpt-5.5" + display_name = "GPT-5.5 (Bedrock)" + family = "gpt-5" + default = true + +-[models."openai.gpt-5.5".limits] ++[providers.bedrock-openai.models."gpt-5.5".limits] + context_window = 272000 + max_output = 128000 + +-[models."openai.gpt-5.5".features] ++[providers.bedrock-openai.models."gpt-5.5".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."openai.gpt-5.5".costs] ++[providers.bedrock-openai.models."gpt-5.5".costs] + input_cost_per_mtok = 5.5 + output_cost_per_mtok = 33.0 + +-[models."openai.gpt-5.4"] +-provider = "bedrock-openai" ++[providers.bedrock-openai.models."gpt-5.4"] ++api_id = "openai.gpt-5.4" + display_name = "GPT-5.4 (Bedrock)" + family = "gpt-5" + +-[models."openai.gpt-5.4".limits] ++[providers.bedrock-openai.models."gpt-5.4".limits] + context_window = 272000 + max_output = 128000 + +-[models."openai.gpt-5.4".features] ++[providers.bedrock-openai.models."gpt-5.4".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."openai.gpt-5.4".costs] ++[providers.bedrock-openai.models."gpt-5.4".costs] + input_cost_per_mtok = 2.75 + output_cost_per_mtok = 16.5 +diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml b/lib/crates/fabro-model/src/catalog/providers/bedrock.toml +index f6f217a4f..aeed22e46 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/bedrock.toml +@@ -45,68 +45,67 @@ credentials = [ + # file because its Bedrock deployment pins sampling parameters and requires an + # extra data-sharing opt-in. + +-[models."us.anthropic.claude-sonnet-4-6"] +-provider = "bedrock" ++[providers.bedrock.models."claude-sonnet-4-6"] ++api_id = "us.anthropic.claude-sonnet-4-6" + display_name = "Claude Sonnet 4.6 (Bedrock)" + family = "claude-4" + billing_policy = "anthropic" + default = true + +-[models."us.anthropic.claude-sonnet-4-6".limits] ++[providers.bedrock.models."claude-sonnet-4-6".limits] + context_window = 1000000 + max_output = 64000 + +-[models."us.anthropic.claude-sonnet-4-6".features] ++[providers.bedrock.models."claude-sonnet-4-6".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + +-[models."us.anthropic.claude-sonnet-4-6".costs] ++[providers.bedrock.models."claude-sonnet-4-6".costs] + input_cost_per_mtok = 3.0 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.3 + +-[models."us.anthropic.claude-opus-4-8"] +-provider = "bedrock" ++[providers.bedrock.models."claude-opus-4-8"] ++api_id = "us.anthropic.claude-opus-4-8" + display_name = "Claude Opus 4.8 (Bedrock)" + family = "claude-4" + billing_policy = "anthropic" + +-[models."us.anthropic.claude-opus-4-8".limits] ++[providers.bedrock.models."claude-opus-4-8".limits] + context_window = 1000000 + max_output = 128000 + +-[models."us.anthropic.claude-opus-4-8".features] ++[providers.bedrock.models."claude-opus-4-8".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + +-[models."us.anthropic.claude-opus-4-8".costs] ++[providers.bedrock.models."claude-opus-4-8".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 25.0 + cache_input_cost_per_mtok = 0.5 + +-[models."us.anthropic.claude-haiku-4-5"] +-provider = "bedrock" ++[providers.bedrock.models."claude-haiku-4-5"] + api_id = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + display_name = "Claude Haiku 4.5 (Bedrock)" + family = "claude-4" + billing_policy = "anthropic" + small_default = true + +-[models."us.anthropic.claude-haiku-4-5".limits] ++[providers.bedrock.models."claude-haiku-4-5".limits] + context_window = 200000 + max_output = 64000 + +-[models."us.anthropic.claude-haiku-4-5".features] ++[providers.bedrock.models."claude-haiku-4-5".features] + tools = true + vision = true + reasoning = false + prompt_cache = true + +-[models."us.anthropic.claude-haiku-4-5".costs] ++[providers.bedrock.models."claude-haiku-4-5".costs] + input_cost_per_mtok = 1.0 + output_cost_per_mtok = 5.0 + cache_input_cost_per_mtok = 0.1 +@@ -116,149 +115,142 @@ cache_input_cost_per_mtok = 0.1 + # GPT-5.5/5.4 are NOT here: on Bedrock they are Responses-API-only on the + # bedrock-mantle endpoint (no Converse), a named follow-up route. + +-[models."openai.gpt-oss-120b"] +-provider = "bedrock" ++[providers.bedrock.models."gpt-oss-120b"] + api_id = "openai.gpt-oss-120b-1:0" + display_name = "GPT-OSS 120B (Bedrock)" + family = "gpt-oss" + billing_policy = "openai" + agent_profile = "openai" + +-[models."openai.gpt-oss-120b".limits] ++[providers.bedrock.models."gpt-oss-120b".limits] + context_window = 128000 + max_output = 16384 + +-[models."openai.gpt-oss-120b".features] ++[providers.bedrock.models."gpt-oss-120b".features] + tools = true + vision = false + reasoning = true + +-[models."openai.gpt-oss-120b".costs] ++[providers.bedrock.models."gpt-oss-120b".costs] + input_cost_per_mtok = 0.15 + output_cost_per_mtok = 0.60 + +-[models."openai.gpt-oss-20b"] +-provider = "bedrock" ++[providers.bedrock.models."gpt-oss-20b"] + api_id = "openai.gpt-oss-20b-1:0" + display_name = "GPT-OSS 20B (Bedrock)" + family = "gpt-oss" + billing_policy = "openai" + agent_profile = "openai" + +-[models."openai.gpt-oss-20b".limits] ++[providers.bedrock.models."gpt-oss-20b".limits] + context_window = 128000 + max_output = 16384 + +-[models."openai.gpt-oss-20b".features] ++[providers.bedrock.models."gpt-oss-20b".features] + tools = true + vision = false + reasoning = true + +-[models."openai.gpt-oss-20b".costs] ++[providers.bedrock.models."gpt-oss-20b".costs] + input_cost_per_mtok = 0.07 + output_cost_per_mtok = 0.30 + + # ---------- Amazon Nova ---------- + +-[models."amazon.nova-2-lite"] +-provider = "bedrock" ++[providers.bedrock.models."nova-2-lite"] + api_id = "global.amazon.nova-2-lite-v1:0" + display_name = "Nova 2 Lite (Bedrock)" + family = "nova-2" + billing_policy = "openai" + agent_profile = "openai" + +-[models."amazon.nova-2-lite".limits] ++[providers.bedrock.models."nova-2-lite".limits] + context_window = 1000000 + # Bedrock caps Nova output at 65535 (2^16 - 1); 65536 trips + # "maximum tokens exceeds the model limit of 65535" since the prompt handler + # defaults max_tokens to max_output. + max_output = 65535 + +-[models."amazon.nova-2-lite".features] ++[providers.bedrock.models."nova-2-lite".features] + tools = true + vision = true + reasoning = false + +-[models."amazon.nova-2-lite".costs] ++[providers.bedrock.models."nova-2-lite".costs] + input_cost_per_mtok = 0.30 + output_cost_per_mtok = 2.50 + + # ---------- Open-weights ---------- + +-[models."meta.llama4-maverick"] +-provider = "bedrock" ++[providers.bedrock.models."llama-4-maverick"] + api_id = "us.meta.llama4-maverick-17b-instruct-v1:0" + display_name = "Llama 4 Maverick (Bedrock)" + family = "llama-4" + billing_policy = "openai" + agent_profile = "openai" + +-[models."meta.llama4-maverick".limits] ++[providers.bedrock.models."llama-4-maverick".limits] + context_window = 1000000 + max_output = 8192 + +-[models."meta.llama4-maverick".features] ++[providers.bedrock.models."llama-4-maverick".features] + tools = true + vision = true + reasoning = false + +-[models."mistral.mistral-large-3"] +-provider = "bedrock" ++[providers.bedrock.models."mistral-large-3"] + api_id = "mistral.mistral-large-3-675b-instruct" + display_name = "Mistral Large 3 (Bedrock)" + family = "mistral-large" + billing_policy = "openai" + agent_profile = "openai" + +-[models."mistral.mistral-large-3".limits] ++[providers.bedrock.models."mistral-large-3".limits] + context_window = 256000 + max_output = 32768 + +-[models."mistral.mistral-large-3".features] ++[providers.bedrock.models."mistral-large-3".features] + tools = true + vision = true + reasoning = false + +-[models."mistral.mistral-large-3".costs] ++[providers.bedrock.models."mistral-large-3".costs] + input_cost_per_mtok = 0.50 + output_cost_per_mtok = 1.50 + +-[models."mistral.devstral-2"] +-provider = "bedrock" ++[providers.bedrock.models."devstral-2"] + api_id = "mistral.devstral-2-123b" + display_name = "Devstral 2 (Bedrock)" + family = "devstral" + billing_policy = "openai" + agent_profile = "openai" + +-[models."mistral.devstral-2".limits] ++[providers.bedrock.models."devstral-2".limits] + context_window = 256000 + max_output = 32768 + +-[models."mistral.devstral-2".features] ++[providers.bedrock.models."devstral-2".features] + tools = true + vision = false + reasoning = false + +-[models."deepseek.v3-2"] +-provider = "bedrock" ++[providers.bedrock.models."deepseek-v3.2"] + api_id = "deepseek.v3.2" + display_name = "DeepSeek V3.2 (Bedrock)" + family = "deepseek-v3" + billing_policy = "openai" + agent_profile = "openai" + +-[models."deepseek.v3-2".limits] ++[providers.bedrock.models."deepseek-v3.2".limits] + context_window = 164000 + max_output = 8192 + +-[models."deepseek.v3-2".features] ++[providers.bedrock.models."deepseek-v3.2".features] + tools = true + vision = false + reasoning = true + +-[models."deepseek.v3-2".costs] ++[providers.bedrock.models."deepseek-v3.2".costs] + input_cost_per_mtok = 0.62 + output_cost_per_mtok = 1.85 + +@@ -267,92 +259,90 @@ output_cost_per_mtok = 1.85 + # "The provided model identifier is invalid"), so this row needs an explicit + # `api_id` confirmed against `aws bedrock list-inference-profiles` before it + # ships. Re-add with: +-# [models."qwen.qwen3-coder-next"] +-# provider = "bedrock" ++# [providers.bedrock.models."qwen3-coder-next"] + # api_id = "" + # display_name = "Qwen3 Coder Next (Bedrock)" + # family = "qwen3" + # billing_policy = "openai" + # agent_profile = "openai" +-# [models."qwen.qwen3-coder-next".limits] ++# [providers.bedrock.models."qwen3-coder-next".limits] + # context_window = 256000 + # max_output = 16384 +-# [models."qwen.qwen3-coder-next".features] ++# [providers.bedrock.models."qwen3-coder-next".features] + # tools = true + +-[models."moonshotai.kimi-k2.5"] +-provider = "bedrock" ++[providers.bedrock.models."kimi-k2.5"] ++api_id = "moonshotai.kimi-k2.5" + display_name = "Kimi K2.5 (Bedrock)" + family = "kimi-k2" + billing_policy = "openai" + agent_profile = "openai" + +-[models."moonshotai.kimi-k2.5".limits] ++[providers.bedrock.models."kimi-k2.5".limits] + context_window = 262144 + max_output = 16384 + +-[models."moonshotai.kimi-k2.5".features] ++[providers.bedrock.models."kimi-k2.5".features] + tools = true + vision = true + reasoning = false + +-[models."moonshotai.kimi-k2.5".costs] ++[providers.bedrock.models."kimi-k2.5".costs] + input_cost_per_mtok = 0.60 + output_cost_per_mtok = 3.00 + +-[models."zai.glm-5"] +-provider = "bedrock" ++[providers.bedrock.models."glm-5"] ++api_id = "zai.glm-5" + display_name = "GLM 5 (Bedrock)" + family = "glm" + billing_policy = "openai" + agent_profile = "openai" + +-[models."zai.glm-5".limits] ++[providers.bedrock.models."glm-5".limits] + context_window = 200000 + max_output = 128000 + +-[models."zai.glm-5".features] ++[providers.bedrock.models."glm-5".features] + tools = true + vision = false + reasoning = false + +-[models."zai.glm-5".costs] ++[providers.bedrock.models."glm-5".costs] + input_cost_per_mtok = 1.00 + output_cost_per_mtok = 3.20 + +-[models."minimax.minimax-m2.5"] +-provider = "bedrock" ++[providers.bedrock.models."minimax-m2.5"] ++api_id = "minimax.minimax-m2.5" + display_name = "MiniMax M2.5 (Bedrock)" + family = "minimax-m2" + billing_policy = "openai" + agent_profile = "openai" + +-[models."minimax.minimax-m2.5".limits] ++[providers.bedrock.models."minimax-m2.5".limits] + context_window = 196000 + max_output = 8192 + +-[models."minimax.minimax-m2.5".features] ++[providers.bedrock.models."minimax-m2.5".features] + tools = true + vision = false + reasoning = false + +-[models."minimax.minimax-m2.5".costs] ++[providers.bedrock.models."minimax-m2.5".costs] + input_cost_per_mtok = 0.30 + output_cost_per_mtok = 1.20 + +-[models."nvidia.nemotron-3-super"] +-provider = "bedrock" ++[providers.bedrock.models."nemotron-3-super"] + api_id = "nvidia.nemotron-super-3-120b" + display_name = "Nemotron 3 Super (Bedrock)" + family = "nemotron-3" + billing_policy = "openai" + agent_profile = "openai" + +-[models."nvidia.nemotron-3-super".limits] ++[providers.bedrock.models."nemotron-3-super".limits] + context_window = 256000 + max_output = 32768 + +-[models."nvidia.nemotron-3-super".features] ++[providers.bedrock.models."nemotron-3-super".features] + tools = true + vision = false + reasoning = false +@@ -365,24 +355,24 @@ reasoning = false + # reasoning_effort stays undeclared here (requests carrying one are + # rejected up front rather than silently dropped). + +-[models."us.anthropic.claude-fable-5"] +-provider = "bedrock" ++[providers.bedrock.models."claude-fable-5"] ++api_id = "us.anthropic.claude-fable-5" + display_name = "Claude Fable 5 (Bedrock)" + family = "claude-5" + billing_policy = "anthropic" + +-[models."us.anthropic.claude-fable-5".limits] ++[providers.bedrock.models."claude-fable-5".limits] + context_window = 1000000 + max_output = 128000 + +-[models."us.anthropic.claude-fable-5".features] ++[providers.bedrock.models."claude-fable-5".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + sampling_params = false + +-[models."us.anthropic.claude-fable-5".costs] ++[providers.bedrock.models."claude-fable-5".costs] + input_cost_per_mtok = 10.0 + output_cost_per_mtok = 50.0 + cache_input_cost_per_mtok = 1.0 +diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml +index 74ffc91de..a03c2a249 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml +@@ -9,9 +9,7 @@ priority = 80 + credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"] + header = { custom = "x-goog-api-key" } + +-[models."gemini-3.1-pro-preview"] +-provider = "gemini" +-api_id = "gemini-3.1-pro-preview" ++[providers.gemini.models."gemini-3.1-pro-preview"] + display_name = "Gemini 3.1 Pro (Preview)" + family = "gemini-3" + training = "2025-01-01" +@@ -19,24 +17,22 @@ knowledge_cutoff = "January 2025" + estimated_output_tps = 85 + aliases = ["gemini-pro"] + +-[models."gemini-3.1-pro-preview".limits] ++[providers.gemini.models."gemini-3.1-pro-preview".limits] + context_window = 1048576 + max_output = 65536 + +-[models."gemini-3.1-pro-preview".features] ++[providers.gemini.models."gemini-3.1-pro-preview".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gemini-3.1-pro-preview".costs] ++[providers.gemini.models."gemini-3.1-pro-preview".costs] + input_cost_per_mtok = 2.0 + output_cost_per_mtok = 12.0 + cache_input_cost_per_mtok = 0.5 + +-[models."gemini-3.1-pro-preview-customtools"] +-provider = "gemini" +-api_id = "gemini-3.1-pro-preview-customtools" ++[providers.gemini.models."gemini-3.1-pro-preview-customtools"] + display_name = "Gemini 3.1 Pro Custom Tools (Preview)" + family = "gemini-3" + training = "2025-01-01" +@@ -44,24 +40,22 @@ knowledge_cutoff = "January 2025" + estimated_output_tps = 85 + aliases = ["gemini-customtools"] + +-[models."gemini-3.1-pro-preview-customtools".limits] ++[providers.gemini.models."gemini-3.1-pro-preview-customtools".limits] + context_window = 1048576 + max_output = 65536 + +-[models."gemini-3.1-pro-preview-customtools".features] ++[providers.gemini.models."gemini-3.1-pro-preview-customtools".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gemini-3.1-pro-preview-customtools".costs] ++[providers.gemini.models."gemini-3.1-pro-preview-customtools".costs] + input_cost_per_mtok = 2.0 + output_cost_per_mtok = 12.0 + cache_input_cost_per_mtok = 0.5 + +-[models."gemini-3.5-flash"] +-provider = "gemini" +-api_id = "gemini-3.5-flash" ++[providers.gemini.models."gemini-3.5-flash"] + display_name = "Gemini 3.5 Flash" + family = "gemini-3" + training = "2025-01-01" +@@ -70,24 +64,22 @@ default = true + estimated_output_tps = 150 + aliases = ["gemini-35-flash"] + +-[models."gemini-3.5-flash".limits] ++[providers.gemini.models."gemini-3.5-flash".limits] + context_window = 1048576 + max_output = 65536 + +-[models."gemini-3.5-flash".features] ++[providers.gemini.models."gemini-3.5-flash".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gemini-3.5-flash".costs] ++[providers.gemini.models."gemini-3.5-flash".costs] + input_cost_per_mtok = 1.5 + output_cost_per_mtok = 9.0 + cache_input_cost_per_mtok = 0.15 + +-[models."gemini-3-flash-preview"] +-provider = "gemini" +-api_id = "gemini-3-flash-preview" ++[providers.gemini.models."gemini-3-flash-preview"] + display_name = "Gemini 3 Flash (Preview)" + family = "gemini-3" + training = "2025-01-01" +@@ -95,24 +87,22 @@ knowledge_cutoff = "January 2025" + estimated_output_tps = 150 + aliases = ["gemini-flash"] + +-[models."gemini-3-flash-preview".limits] ++[providers.gemini.models."gemini-3-flash-preview".limits] + context_window = 1048576 + max_output = 65536 + +-[models."gemini-3-flash-preview".features] ++[providers.gemini.models."gemini-3-flash-preview".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gemini-3-flash-preview".costs] ++[providers.gemini.models."gemini-3-flash-preview".costs] + input_cost_per_mtok = 0.5 + output_cost_per_mtok = 3.0 + cache_input_cost_per_mtok = 0.125 + +-[models."gemini-3.1-flash-lite"] +-provider = "gemini" +-api_id = "gemini-3.1-flash-lite" ++[providers.gemini.models."gemini-3.1-flash-lite"] + display_name = "Gemini 3.1 Flash Lite" + family = "gemini-3" + training = "2025-01-01" +@@ -121,17 +111,17 @@ estimated_output_tps = 200 + aliases = ["gemini-flash-lite", "gemini-3.1-flash-lite-preview"] + small_default = true + +-[models."gemini-3.1-flash-lite".limits] ++[providers.gemini.models."gemini-3.1-flash-lite".limits] + context_window = 1048576 + max_output = 65536 + +-[models."gemini-3.1-flash-lite".features] ++[providers.gemini.models."gemini-3.1-flash-lite".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gemini-3.1-flash-lite".costs] ++[providers.gemini.models."gemini-3.1-flash-lite".costs] + input_cost_per_mtok = 0.25 + output_cost_per_mtok = 1.5 + cache_input_cost_per_mtok = 0.025 +diff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/crates/fabro-model/src/catalog/providers/inception.toml +index 1d2b08a64..965120f27 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/inception.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/inception.toml +@@ -8,25 +8,23 @@ priority = 40 + [providers.inception.auth] + credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"] + +-[models."mercury-2"] +-provider = "inception" +-api_id = "mercury-2" ++[providers.inception.models."mercury-2"] + display_name = "Mercury 2" + family = "mercury" + default = true + estimated_output_tps = 1000 + aliases = ["mercury"] + +-[models."mercury-2".limits] ++[providers.inception.models."mercury-2".limits] + context_window = 131072 + max_output = 50000 + +-[models."mercury-2".features] ++[providers.inception.models."mercury-2".features] + tools = true + vision = false + reasoning = true + reasoning_effort = "levels" + +-[models."mercury-2".costs] ++[providers.inception.models."mercury-2".costs] + input_cost_per_mtok = 0.25 + output_cost_per_mtok = 0.75 +diff --git a/lib/crates/fabro-model/src/catalog/providers/kimi.toml b/lib/crates/fabro-model/src/catalog/providers/kimi.toml +index c57779116..daa4b20c2 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/kimi.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/kimi.toml +@@ -8,46 +8,42 @@ priority = 70 + [providers.kimi.auth] + credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] + +-[models."kimi-k2.5"] +-provider = "kimi" +-api_id = "kimi-k2.5" ++[providers.kimi.models."kimi-k2.5"] + display_name = "Kimi K2.5" + family = "kimi-k2" + training = "2025-10-01" + knowledge_cutoff = "October 2025" + estimated_output_tps = 50 + +-[models."kimi-k2.5".limits] ++[providers.kimi.models."kimi-k2.5".limits] + context_window = 262144 + max_output = 32768 + +-[models."kimi-k2.5".features] ++[providers.kimi.models."kimi-k2.5".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + sampling_params = false + +-[models."kimi-k2.5".costs] ++[providers.kimi.models."kimi-k2.5".costs] + input_cost_per_mtok = 0.6 + output_cost_per_mtok = 3.0 + cache_input_cost_per_mtok = 0.1 + +-[models."kimi-k3"] +-provider = "kimi" +-api_id = "kimi-k3" ++[providers.kimi.models."kimi-k3"] + display_name = "Kimi K3" + family = "kimi-k3" + default = true + aliases = ["kimi"] + +-[models."kimi-k3".limits] ++[providers.kimi.models."kimi-k3".limits] + context_window = 1048576 + # K3 accepts explicit completion budgets up to 1048576, but Fabro also uses + # max_output as the default request budget. Match Kimi's 131072-token default. + max_output = 131072 + +-[models."kimi-k3".features] ++[providers.kimi.models."kimi-k3".features] + tools = true + vision = true + reasoning = true +@@ -55,10 +51,10 @@ reasoning_effort = "always_adaptive" + prompt_cache = true + sampling_params = false + +-[models."kimi-k3".controls] ++[providers.kimi.models."kimi-k3".controls] + reasoning_effort = ["low", "high", "max"] + +-[models."kimi-k3".costs] ++[providers.kimi.models."kimi-k3".costs] + input_cost_per_mtok = 3.0 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.3 +diff --git a/lib/crates/fabro-model/src/catalog/providers/litellm.toml b/lib/crates/fabro-model/src/catalog/providers/litellm.toml +index 55f5aef15..1307378c1 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/litellm.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/litellm.toml +@@ -14,18 +14,17 @@ credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"] + # enabled = true + # base_url = "http://localhost:4000/v1" + # +-# [llm.models."litellm-gpt-5"] +-# provider = "litellm" ++# [llm.providers.litellm.models."litellm-gpt-5"] + # api_id = "gpt-5" + # display_name = "LiteLLM GPT-5" + # family = "litellm" + # default = true + # +-# [llm.models."litellm-gpt-5".limits] ++# [llm.providers.litellm.models."litellm-gpt-5".limits] + # context_window = 128000 + # max_output = 8192 + # +-# [llm.models."litellm-gpt-5".features] ++# [llm.providers.litellm.models."litellm-gpt-5".features] + # tools = true + # vision = false + # reasoning = false +diff --git a/lib/crates/fabro-model/src/catalog/providers/minimax.toml b/lib/crates/fabro-model/src/catalog/providers/minimax.toml +index 172a6fd6a..e68dfc290 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/minimax.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/minimax.toml +@@ -8,24 +8,22 @@ priority = 50 + [providers.minimax.auth] + credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"] + +-[models."minimax-m2.5"] +-provider = "minimax" +-api_id = "minimax-m2.5" ++[providers.minimax.models."minimax-m2.5"] + display_name = "Minimax M2.5" + family = "minimax-m2" + default = true + estimated_output_tps = 45 + aliases = ["minimax"] + +-[models."minimax-m2.5".limits] ++[providers.minimax.models."minimax-m2.5".limits] + context_window = 196608 + max_output = 16384 + +-[models."minimax-m2.5".features] ++[providers.minimax.models."minimax-m2.5".features] + tools = true + vision = false + reasoning = false + +-[models."minimax-m2.5".costs] ++[providers.minimax.models."minimax-m2.5".costs] + input_cost_per_mtok = 0.3 + output_cost_per_mtok = 1.2 +diff --git a/lib/crates/fabro-model/src/catalog/providers/ollama.toml b/lib/crates/fabro-model/src/catalog/providers/ollama.toml +index bf3b16f5e..78dc5db69 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/ollama.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/ollama.toml +@@ -9,18 +9,17 @@ enabled = false + # Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true` + # above) to expose it through the OpenAI-compatible adapter. + # +-# [models."qwen3.5"] +-# provider = "ollama" ++# [providers.ollama.models."qwen3.5"] + # api_id = "qwen3.5:latest" + # display_name = "Qwen3.5" + # family = "qwen3.5" + # default = true + # aliases = ["ollama-qwen3.5"] + # +-# [models."qwen3.5".limits] ++# [providers.ollama.models."qwen3.5".limits] + # context_window = 32768 + # +-# [models."qwen3.5".features] ++# [providers.ollama.models."qwen3.5".features] + # tools = true + # vision = false + # reasoning = false +diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml +index 56fe7a03c..9ae21c91f 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/openai.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml +@@ -8,9 +8,7 @@ priority = 90 + [providers.openai.auth] + credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY", "vault:OPENAI_CODEX"] + +-[models."gpt-5.6-sol"] +-provider = "openai" +-api_id = "gpt-5.6-sol" ++[providers.openai.models."gpt-5.6-sol"] + display_name = "GPT-5.6 Sol" + family = "gpt-5" + training = "2026-02-16" +@@ -18,75 +16,69 @@ knowledge_cutoff = "February 16, 2026" + default = true + aliases = ["gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] + +-[models."gpt-5.6-sol".limits] ++[providers.openai.models."gpt-5.6-sol".limits] + context_window = 272000 + max_output = 128000 + +-[models."gpt-5.6-sol".features] ++[providers.openai.models."gpt-5.6-sol".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."gpt-5.6-sol".costs] ++[providers.openai.models."gpt-5.6-sol".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 30.0 + cache_input_cost_per_mtok = 0.5 + +-[models."gpt-5.6-terra"] +-provider = "openai" +-api_id = "gpt-5.6-terra" ++[providers.openai.models."gpt-5.6-terra"] + display_name = "GPT-5.6 Terra" + family = "gpt-5" + training = "2026-02-16" + knowledge_cutoff = "February 16, 2026" + aliases = ["gpt56-terra", "gpt-56-terra"] + +-[models."gpt-5.6-terra".limits] ++[providers.openai.models."gpt-5.6-terra".limits] + context_window = 272000 + max_output = 128000 + +-[models."gpt-5.6-terra".features] ++[providers.openai.models."gpt-5.6-terra".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."gpt-5.6-terra".costs] ++[providers.openai.models."gpt-5.6-terra".costs] + input_cost_per_mtok = 2.5 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.25 + +-[models."gpt-5.6-luna"] +-provider = "openai" +-api_id = "gpt-5.6-luna" ++[providers.openai.models."gpt-5.6-luna"] + display_name = "GPT-5.6 Luna" + family = "gpt-5" + training = "2026-02-16" + knowledge_cutoff = "February 16, 2026" + aliases = ["gpt56-luna", "gpt-56-luna"] + +-[models."gpt-5.6-luna".limits] ++[providers.openai.models."gpt-5.6-luna".limits] + context_window = 272000 + max_output = 128000 + +-[models."gpt-5.6-luna".features] ++[providers.openai.models."gpt-5.6-luna".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."gpt-5.6-luna".costs] ++[providers.openai.models."gpt-5.6-luna".costs] + input_cost_per_mtok = 1.0 + output_cost_per_mtok = 6.0 + cache_input_cost_per_mtok = 0.1 + +-[models."gpt-5.4"] +-provider = "openai" +-api_id = "gpt-5.4" ++[providers.openai.models."gpt-5.4"] + display_name = "GPT-5.4" + family = "gpt-5" + training = "2025-08-31" +@@ -94,24 +86,22 @@ knowledge_cutoff = "April 2025" + estimated_output_tps = 70 + aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] + +-[models."gpt-5.4".limits] ++[providers.openai.models."gpt-5.4".limits] + context_window = 272000 + max_output = 128000 + +-[models."gpt-5.4".features] ++[providers.openai.models."gpt-5.4".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gpt-5.4".costs] ++[providers.openai.models."gpt-5.4".costs] + input_cost_per_mtok = 2.5 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.25 + +-[models."gpt-5.5"] +-provider = "openai" +-api_id = "gpt-5.5" ++[providers.openai.models."gpt-5.5"] + display_name = "GPT-5.5" + family = "gpt-5" + training = "2025-12-01" +@@ -119,24 +109,22 @@ knowledge_cutoff = "December 2025" + estimated_output_tps = 70 + aliases = ["gpt55", "gpt-55"] + +-[models."gpt-5.5".limits] ++[providers.openai.models."gpt-5.5".limits] + context_window = 272000 + max_output = 128000 + +-[models."gpt-5.5".features] ++[providers.openai.models."gpt-5.5".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gpt-5.5".costs] ++[providers.openai.models."gpt-5.5".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 30.0 + cache_input_cost_per_mtok = 0.5 + +-[models."gpt-5.5-pro"] +-provider = "openai" +-api_id = "gpt-5.5-pro" ++[providers.openai.models."gpt-5.5-pro"] + display_name = "GPT-5.5 Pro" + family = "gpt-5" + training = "2025-12-01" +@@ -144,24 +132,22 @@ knowledge_cutoff = "December 2025" + estimated_output_tps = 20 + aliases = ["gpt55-pro", "gpt-55-pro"] + +-[models."gpt-5.5-pro".limits] ++[providers.openai.models."gpt-5.5-pro".limits] + context_window = 1050000 + max_output = 128000 + +-[models."gpt-5.5-pro".features] ++[providers.openai.models."gpt-5.5-pro".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gpt-5.5-pro".costs] ++[providers.openai.models."gpt-5.5-pro".costs] + input_cost_per_mtok = 30.0 + output_cost_per_mtok = 180.0 + cache_input_cost_per_mtok = 3.0 + +-[models."gpt-5.4-pro"] +-provider = "openai" +-api_id = "gpt-5.4-pro" ++[providers.openai.models."gpt-5.4-pro"] + display_name = "GPT-5.4 Pro" + family = "gpt-5" + training = "2025-08-31" +@@ -169,24 +155,22 @@ knowledge_cutoff = "April 2025" + estimated_output_tps = 20 + aliases = ["gpt54-pro", "gpt-54-pro"] + +-[models."gpt-5.4-pro".limits] ++[providers.openai.models."gpt-5.4-pro".limits] + context_window = 1047576 + max_output = 128000 + +-[models."gpt-5.4-pro".features] ++[providers.openai.models."gpt-5.4-pro".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gpt-5.4-pro".costs] ++[providers.openai.models."gpt-5.4-pro".costs] + input_cost_per_mtok = 30.0 + output_cost_per_mtok = 180.0 + cache_input_cost_per_mtok = 3.0 + +-[models."gpt-5.4-mini"] +-provider = "openai" +-api_id = "gpt-5.4-mini" ++[providers.openai.models."gpt-5.4-mini"] + display_name = "GPT-5.4 Mini" + family = "gpt-5" + training = "2025-08-31" +@@ -196,17 +180,17 @@ aliases = ["gpt54-mini", "gpt-54-mini", "gpt-5.3-codex-spark", "codex-spark"] + probe = true + small_default = true + +-[models."gpt-5.4-mini".limits] ++[providers.openai.models."gpt-5.4-mini".limits] + context_window = 272000 + max_output = 128000 + +-[models."gpt-5.4-mini".features] ++[providers.openai.models."gpt-5.4-mini".features] + tools = true + vision = true + reasoning = true + reasoning_effort = "levels" + +-[models."gpt-5.4-mini".costs] ++[providers.openai.models."gpt-5.4-mini".costs] + input_cost_per_mtok = 0.75 + output_cost_per_mtok = 4.5 + cache_input_cost_per_mtok = 0.075 +diff --git a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml +index 86fd43319..bad07290a 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml +@@ -33,262 +33,249 @@ credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"] + # best-effort estimates; OpenRouter returns the authoritative usage.cost + # in-band on every response. + +-[models."anthropic/claude-opus-4-7"] +-provider = "openrouter" ++[providers.openrouter.models."claude-opus-4-7"] + api_id = "anthropic/claude-opus-4.7" + display_name = "Claude Opus 4.7 (via OpenRouter)" + family = "claude-4" + billing_policy = "anthropic" + +-[models."anthropic/claude-opus-4-7".limits] ++[providers.openrouter.models."claude-opus-4-7".limits] + context_window = 1000000 + max_output = 128000 + +-[models."anthropic/claude-opus-4-7".features] ++[providers.openrouter.models."claude-opus-4-7".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + +-[models."anthropic/claude-opus-4-7".costs] ++[providers.openrouter.models."claude-opus-4-7".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 25.0 + cache_input_cost_per_mtok = 0.5 + +-[models."anthropic/claude-sonnet-4-6"] +-provider = "openrouter" ++[providers.openrouter.models."claude-sonnet-4-6"] + api_id = "anthropic/claude-sonnet-4.6" + display_name = "Claude Sonnet 4.6 (via OpenRouter)" + family = "claude-4" + billing_policy = "anthropic" + default = true + +-[models."anthropic/claude-sonnet-4-6".limits] ++[providers.openrouter.models."claude-sonnet-4-6".limits] + context_window = 1000000 + max_output = 64000 + +-[models."anthropic/claude-sonnet-4-6".features] ++[providers.openrouter.models."claude-sonnet-4-6".features] + tools = true + vision = true + reasoning = true + prompt_cache = true + +-[models."anthropic/claude-sonnet-4-6".costs] ++[providers.openrouter.models."claude-sonnet-4-6".costs] + input_cost_per_mtok = 3.0 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.3 + +-[models."anthropic/claude-haiku-4-5"] +-provider = "openrouter" ++[providers.openrouter.models."claude-haiku-4-5"] + api_id = "anthropic/claude-haiku-4.5" + display_name = "Claude Haiku 4.5 (via OpenRouter)" + family = "claude-4" + billing_policy = "anthropic" + small_default = true + +-[models."anthropic/claude-haiku-4-5".limits] ++[providers.openrouter.models."claude-haiku-4-5".limits] + context_window = 200000 + max_output = 8192 + +-[models."anthropic/claude-haiku-4-5".features] ++[providers.openrouter.models."claude-haiku-4-5".features] + tools = true + vision = true + reasoning = false + prompt_cache = true + +-[models."anthropic/claude-haiku-4-5".costs] ++[providers.openrouter.models."claude-haiku-4-5".costs] + input_cost_per_mtok = 1.0 + output_cost_per_mtok = 5.0 + cache_input_cost_per_mtok = 0.1 + + # ---------- OpenAI via OpenRouter ---------- + +-[models."openai/gpt-5.4"] +-provider = "openrouter" ++[providers.openrouter.models."gpt-5.4"] + api_id = "openai/gpt-5.4" + display_name = "GPT-5.4 (via OpenRouter)" + family = "gpt-5" + +-[models."openai/gpt-5.4".limits] ++[providers.openrouter.models."gpt-5.4".limits] + context_window = 1050000 + max_output = 32768 + +-[models."openai/gpt-5.4".features] ++[providers.openrouter.models."gpt-5.4".features] + tools = true + vision = true + reasoning = true + +-[models."openai/gpt-5.4".costs] ++[providers.openrouter.models."gpt-5.4".costs] + input_cost_per_mtok = 2.5 + output_cost_per_mtok = 15.0 + +-[models."openai/gpt-5.5"] +-provider = "openrouter" ++[providers.openrouter.models."gpt-5.5"] + api_id = "openai/gpt-5.5" + display_name = "GPT-5.5 (via OpenRouter)" + family = "gpt-5" + +-[models."openai/gpt-5.5".limits] ++[providers.openrouter.models."gpt-5.5".limits] + context_window = 1050000 + max_output = 32768 + +-[models."openai/gpt-5.5".features] ++[providers.openrouter.models."gpt-5.5".features] + tools = true + vision = true + reasoning = true + +-[models."openai/gpt-5.5".costs] ++[providers.openrouter.models."gpt-5.5".costs] + input_cost_per_mtok = 5.0 + output_cost_per_mtok = 30.0 + + # ---------- Google Gemini via OpenRouter ---------- + +-[models."google/gemini-3.1-pro-preview"] +-provider = "openrouter" ++[providers.openrouter.models."gemini-3.1-pro-preview"] + api_id = "google/gemini-3.1-pro-preview" + display_name = "Gemini 3.1 Pro Preview (via OpenRouter)" + family = "gemini-3" + +-[models."google/gemini-3.1-pro-preview".limits] ++[providers.openrouter.models."gemini-3.1-pro-preview".limits] + context_window = 1048576 + max_output = 65536 + +-[models."google/gemini-3.1-pro-preview".features] ++[providers.openrouter.models."gemini-3.1-pro-preview".features] + tools = true + vision = true + reasoning = true + +-[models."google/gemini-3.1-pro-preview".costs] ++[providers.openrouter.models."gemini-3.1-pro-preview".costs] + input_cost_per_mtok = 2.0 + output_cost_per_mtok = 12.0 + +-[models."google/gemini-3.5-flash"] +-provider = "openrouter" ++[providers.openrouter.models."gemini-3.5-flash"] + api_id = "google/gemini-3.5-flash" + display_name = "Gemini 3.5 Flash (via OpenRouter)" + family = "gemini-3" + +-[models."google/gemini-3.5-flash".limits] ++[providers.openrouter.models."gemini-3.5-flash".limits] + context_window = 1048576 + max_output = 65536 + +-[models."google/gemini-3.5-flash".features] ++[providers.openrouter.models."gemini-3.5-flash".features] + tools = true + vision = true + reasoning = false + +-[models."google/gemini-3.5-flash".costs] ++[providers.openrouter.models."gemini-3.5-flash".costs] + input_cost_per_mtok = 1.5 + output_cost_per_mtok = 9.0 + + # ---------- Open-weights models ---------- + +-[models."xiaomi/mimo-v2.5-pro"] +-provider = "openrouter" ++[providers.openrouter.models."mimo-v2.5-pro"] + api_id = "xiaomi/mimo-v2.5-pro" + display_name = "Xiaomi MiMo v2.5 Pro" + family = "mimo-v2" + +-[models."xiaomi/mimo-v2.5-pro".limits] ++[providers.openrouter.models."mimo-v2.5-pro".limits] + context_window = 1050000 + max_output = 16384 + +-[models."xiaomi/mimo-v2.5-pro".features] ++[providers.openrouter.models."mimo-v2.5-pro".features] + tools = true + vision = false + reasoning = false + +-[models."xiaomi/mimo-v2.5-pro".costs] ++[providers.openrouter.models."mimo-v2.5-pro".costs] + input_cost_per_mtok = 0.435 + output_cost_per_mtok = 0.87 + +-[models."minimax/minimax-m2.7"] +-provider = "openrouter" ++[providers.openrouter.models."minimax-m2.7"] + api_id = "minimax/minimax-m2.7" + display_name = "MiniMax M2.7" + family = "minimax-m2" + +-[models."minimax/minimax-m2.7".limits] ++[providers.openrouter.models."minimax-m2.7".limits] + context_window = 200000 + max_output = 16384 + +-[models."minimax/minimax-m2.7".features] ++[providers.openrouter.models."minimax-m2.7".features] + tools = true + vision = false + reasoning = false + +-[models."minimax/minimax-m2.7".costs] ++[providers.openrouter.models."minimax-m2.7".costs] + input_cost_per_mtok = 0.28 + output_cost_per_mtok = 1.20 + +-[models."deepseek/deepseek-v4-pro"] +-provider = "openrouter" ++[providers.openrouter.models."deepseek-v4-pro"] + api_id = "deepseek/deepseek-v4-pro" + display_name = "DeepSeek V4 Pro" + family = "deepseek-v4" + +-[models."deepseek/deepseek-v4-pro".limits] ++[providers.openrouter.models."deepseek-v4-pro".limits] + context_window = 1050000 + max_output = 16384 + +-[models."deepseek/deepseek-v4-pro".features] ++[providers.openrouter.models."deepseek-v4-pro".features] + tools = true + vision = false + reasoning = true + +-[models."deepseek/deepseek-v4-pro".costs] ++[providers.openrouter.models."deepseek-v4-pro".costs] + input_cost_per_mtok = 0.435 + output_cost_per_mtok = 0.87 + +-[models."deepseek/deepseek-v4-flash"] +-provider = "openrouter" ++[providers.openrouter.models."deepseek-v4-flash"] + api_id = "deepseek/deepseek-v4-flash" + display_name = "DeepSeek V4 Flash" + family = "deepseek-v4" + +-[models."deepseek/deepseek-v4-flash".limits] ++[providers.openrouter.models."deepseek-v4-flash".limits] + context_window = 1050000 + max_output = 16384 + +-[models."deepseek/deepseek-v4-flash".features] ++[providers.openrouter.models."deepseek-v4-flash".features] + tools = true + vision = false + reasoning = false + +-[models."deepseek/deepseek-v4-flash".costs] ++[providers.openrouter.models."deepseek-v4-flash".costs] + input_cost_per_mtok = 0.10 + output_cost_per_mtok = 0.20 + +-[models."moonshotai/kimi-k2.6"] +-provider = "openrouter" ++[providers.openrouter.models."kimi-k2.6"] + api_id = "moonshotai/kimi-k2.6" + display_name = "Kimi K2.6" + family = "kimi-k2" + +-[models."moonshotai/kimi-k2.6".limits] ++[providers.openrouter.models."kimi-k2.6".limits] + context_window = 262144 + max_output = 16384 + +-[models."moonshotai/kimi-k2.6".features] ++[providers.openrouter.models."kimi-k2.6".features] + tools = true + vision = false + reasoning = false + +-[models."moonshotai/kimi-k2.6".costs] ++[providers.openrouter.models."kimi-k2.6".costs] + input_cost_per_mtok = 0.73 + output_cost_per_mtok = 3.49 + +-[models."moonshotai/kimi-k3"] +-provider = "openrouter" ++[providers.openrouter.models."kimi-k3"] + api_id = "moonshotai/kimi-k3" + display_name = "Kimi K3 (via OpenRouter)" + family = "kimi-k3" + +-[models."moonshotai/kimi-k3".limits] ++[providers.openrouter.models."kimi-k3".limits] + context_window = 1048576 + max_output = 131072 + +-[models."moonshotai/kimi-k3".features] ++[providers.openrouter.models."kimi-k3".features] + tools = true + vision = true + reasoning = true +@@ -296,47 +283,45 @@ reasoning_effort = "always_adaptive" + prompt_cache = true + sampling_params = false + +-[models."moonshotai/kimi-k3".controls] ++[providers.openrouter.models."kimi-k3".controls] + reasoning_effort = ["low", "high", "max"] + +-[models."moonshotai/kimi-k3".costs] ++[providers.openrouter.models."kimi-k3".costs] + input_cost_per_mtok = 3.0 + output_cost_per_mtok = 15.0 + cache_input_cost_per_mtok = 0.3 + +-[models."poolside/laguna-s-2.1"] +-provider = "openrouter" ++[providers.openrouter.models."laguna-s-2.1"] + api_id = "poolside/laguna-s-2.1" + display_name = "Laguna S 2.1 (via OpenRouter)" + family = "laguna-2" + +-[models."poolside/laguna-s-2.1".limits] ++[providers.openrouter.models."laguna-s-2.1".limits] + context_window = 1048576 + max_output = 131072 + +-[models."poolside/laguna-s-2.1".features] ++[providers.openrouter.models."laguna-s-2.1".features] + tools = true + vision = false + reasoning = true + prompt_cache = true + sampling_params = true + +-[models."poolside/laguna-s-2.1".costs] ++[providers.openrouter.models."laguna-s-2.1".costs] + input_cost_per_mtok = 0.10 + output_cost_per_mtok = 0.20 + cache_input_cost_per_mtok = 0.01 + +-[models."poolside/laguna-xs-2.1"] +-provider = "openrouter" ++[providers.openrouter.models."laguna-xs-2.1"] + api_id = "poolside/laguna-xs-2.1" + display_name = "Laguna XS 2.1 (via OpenRouter)" + family = "laguna-2" + +-[models."poolside/laguna-xs-2.1".limits] ++[providers.openrouter.models."laguna-xs-2.1".limits] + context_window = 262144 + max_output = 32768 + +-[models."poolside/laguna-xs-2.1".features] ++[providers.openrouter.models."laguna-xs-2.1".features] + tools = true + vision = false + reasoning = true +@@ -345,127 +330,121 @@ sampling_params = true + + # Current promotional rate. OpenRouter's authoritative in-band usage.cost + # supersedes this estimate on completed responses. +-[models."poolside/laguna-xs-2.1".costs] ++[providers.openrouter.models."laguna-xs-2.1".costs] + input_cost_per_mtok = 0.06 + output_cost_per_mtok = 0.12 + cache_input_cost_per_mtok = 0.03 + +-[models."qwen/qwen3-coder"] +-provider = "openrouter" ++[providers.openrouter.models."qwen3-coder"] + api_id = "qwen/qwen3-coder" + display_name = "Qwen3 Coder" + family = "qwen3" + +-[models."qwen/qwen3-coder".limits] ++[providers.openrouter.models."qwen3-coder".limits] + context_window = 1050000 + max_output = 16384 + +-[models."qwen/qwen3-coder".features] ++[providers.openrouter.models."qwen3-coder".features] + tools = true + vision = false + reasoning = false + +-[models."qwen/qwen3-coder".costs] ++[providers.openrouter.models."qwen3-coder".costs] + input_cost_per_mtok = 0.22 + output_cost_per_mtok = 1.80 + +-[models."qwen/qwen3.6-flash"] +-provider = "openrouter" ++[providers.openrouter.models."qwen3.6-flash"] + api_id = "qwen/qwen3.6-flash" + display_name = "Qwen3.6 Flash" + family = "qwen3" + +-[models."qwen/qwen3.6-flash".limits] ++[providers.openrouter.models."qwen3.6-flash".limits] + context_window = 1000000 + max_output = 16384 + +-[models."qwen/qwen3.6-flash".features] ++[providers.openrouter.models."qwen3.6-flash".features] + tools = true + vision = false + reasoning = false + +-[models."qwen/qwen3.6-flash".costs] ++[providers.openrouter.models."qwen3.6-flash".costs] + input_cost_per_mtok = 0.1875 + output_cost_per_mtok = 1.125 + +-[models."z-ai/glm-5.2"] +-provider = "openrouter" ++[providers.openrouter.models."glm-5.2"] + api_id = "z-ai/glm-5.2" + display_name = "GLM 5.2 (via OpenRouter)" + family = "glm-5" + +-[models."z-ai/glm-5.2".limits] ++[providers.openrouter.models."glm-5.2".limits] + context_window = 1048576 + max_output = 131072 + +-[models."z-ai/glm-5.2".features] ++[providers.openrouter.models."glm-5.2".features] + tools = true + vision = false + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."z-ai/glm-5.2".controls] ++[providers.openrouter.models."glm-5.2".controls] + reasoning_effort = ["high", "xhigh"] + +-[models."z-ai/glm-5.2".costs] ++[providers.openrouter.models."glm-5.2".costs] + input_cost_per_mtok = 0.784 + output_cost_per_mtok = 2.464 + cache_input_cost_per_mtok = 0.1456 + +-[models."z-ai/glm-4.6"] +-provider = "openrouter" ++[providers.openrouter.models."glm-4.6"] + api_id = "z-ai/glm-4.6" + display_name = "GLM 4.6" + family = "glm-4" + +-[models."z-ai/glm-4.6".limits] ++[providers.openrouter.models."glm-4.6".limits] + context_window = 203000 + max_output = 16384 + +-[models."z-ai/glm-4.6".features] ++[providers.openrouter.models."glm-4.6".features] + tools = true + vision = false + reasoning = false + +-[models."z-ai/glm-4.6".costs] ++[providers.openrouter.models."glm-4.6".costs] + input_cost_per_mtok = 0.43 + output_cost_per_mtok = 1.74 + +-[models."nvidia/nemotron-3-super-120b-a12b"] +-provider = "openrouter" ++[providers.openrouter.models."nemotron-3-super"] + api_id = "nvidia/nemotron-3-super-120b-a12b" + display_name = "NVIDIA Nemotron 3 Super 120B" + family = "nemotron-3" + +-[models."nvidia/nemotron-3-super-120b-a12b".limits] ++[providers.openrouter.models."nemotron-3-super".limits] + context_window = 1000000 + max_output = 16384 + +-[models."nvidia/nemotron-3-super-120b-a12b".features] ++[providers.openrouter.models."nemotron-3-super".features] + tools = true + vision = false + reasoning = false + +-[models."nvidia/nemotron-3-super-120b-a12b".costs] ++[providers.openrouter.models."nemotron-3-super".costs] + input_cost_per_mtok = 0.09 + output_cost_per_mtok = 0.45 + +-[models."mistralai/devstral-2512"] +-provider = "openrouter" ++[providers.openrouter.models."devstral-2"] + api_id = "mistralai/devstral-2512" + display_name = "Devstral 2512" + family = "devstral" + +-[models."mistralai/devstral-2512".limits] ++[providers.openrouter.models."devstral-2".limits] + context_window = 262144 + max_output = 16384 + +-[models."mistralai/devstral-2512".features] ++[providers.openrouter.models."devstral-2".features] + tools = true + vision = false + reasoning = false + +-[models."mistralai/devstral-2512".costs] ++[providers.openrouter.models."devstral-2".costs] + input_cost_per_mtok = 0.40 + output_cost_per_mtok = 2.00 +diff --git a/lib/crates/fabro-model/src/catalog/providers/poolside.toml b/lib/crates/fabro-model/src/catalog/providers/poolside.toml +index 8fd1b6855..65ce87246 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/poolside.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/poolside.toml +@@ -8,19 +8,18 @@ priority = 65 + [providers.poolside.auth] + credentials = ["env:POOLSIDE_API_KEY", "vault:POOLSIDE_API_KEY"] + +-[models."laguna-s-2.1"] +-provider = "poolside" ++[providers.poolside.models."laguna-s-2.1"] + api_id = "poolside/laguna-s-2.1" + display_name = "Laguna S 2.1" + family = "laguna-2" + default = true + aliases = ["laguna", "laguna-s"] + +-[models."laguna-s-2.1".limits] ++[providers.poolside.models."laguna-s-2.1".limits] + context_window = 1048576 + max_output = 131072 + +-[models."laguna-s-2.1".features] ++[providers.poolside.models."laguna-s-2.1".features] + tools = true + vision = false + reasoning = true +@@ -30,13 +29,12 @@ sampling_params = true + # Poolside Platform is free for a limited preview period. Keep the published + # paid hosted rate as Fabro's durable estimate for paid access and post-preview + # usage. +-[models."laguna-s-2.1".costs] ++[providers.poolside.models."laguna-s-2.1".costs] + input_cost_per_mtok = 0.10 + output_cost_per_mtok = 0.20 + cache_input_cost_per_mtok = 0.01 + +-[models."laguna-xs-2.1"] +-provider = "poolside" ++[providers.poolside.models."laguna-xs-2.1"] + api_id = "poolside/laguna-xs-2.1" + display_name = "Laguna XS 2.1" + family = "laguna-2" +@@ -44,11 +42,11 @@ small_default = true + probe = true + aliases = ["laguna-xs"] + +-[models."laguna-xs-2.1".limits] ++[providers.poolside.models."laguna-xs-2.1".limits] + context_window = 262144 + max_output = 32768 + +-[models."laguna-xs-2.1".features] ++[providers.poolside.models."laguna-xs-2.1".features] + tools = true + vision = false + reasoning = true +@@ -57,7 +55,7 @@ sampling_params = true + + # Poolside Platform is free for a limited preview period. These are Poolside's + # published paid endpoint rates. +-[models."laguna-xs-2.1".costs] ++[providers.poolside.models."laguna-xs-2.1".costs] + input_cost_per_mtok = 0.10 + output_cost_per_mtok = 0.20 + cache_input_cost_per_mtok = 0.05 +diff --git a/lib/crates/fabro-model/src/catalog/providers/venice.toml b/lib/crates/fabro-model/src/catalog/providers/venice.toml +index bb91aeada..dc97c4ab4 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/venice.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/venice.toml +@@ -8,43 +8,39 @@ aliases = ["venice-ai"] + [providers.venice.auth] + credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"] + +-[models."venice-uncensored-1-2"] +-provider = "venice" +-api_id = "venice-uncensored-1-2" ++[providers.venice.models."venice-uncensored-1-2"] + display_name = "Venice Uncensored 1.2" + family = "venice-uncensored" + default = true + aliases = ["venice-uncensored", "vu"] + +-[models."venice-uncensored-1-2".limits] ++[providers.venice.models."venice-uncensored-1-2".limits] + context_window = 128000 + max_output = 8192 + +-[models."venice-uncensored-1-2".features] ++[providers.venice.models."venice-uncensored-1-2".features] + tools = true + vision = true + reasoning = false + +-[models."venice-uncensored-1-2".costs] ++[providers.venice.models."venice-uncensored-1-2".costs] + input_cost_per_mtok = 0.2 + output_cost_per_mtok = 0.9 + +-[models."venice-uncensored-role-play"] +-provider = "venice" +-api_id = "venice-uncensored-role-play" ++[providers.venice.models."venice-uncensored-role-play"] + display_name = "Venice Uncensored Role Play" + family = "venice-uncensored" + aliases = ["venice-roleplay", "vrp"] + +-[models."venice-uncensored-role-play".limits] ++[providers.venice.models."venice-uncensored-role-play".limits] + context_window = 128000 + max_output = 4096 + +-[models."venice-uncensored-role-play".features] ++[providers.venice.models."venice-uncensored-role-play".features] + tools = true + vision = true + reasoning = false + +-[models."venice-uncensored-role-play".costs] ++[providers.venice.models."venice-uncensored-role-play".costs] + input_cost_per_mtok = 0.5 + output_cost_per_mtok = 2.0 +diff --git a/lib/crates/fabro-model/src/catalog/providers/zai.toml b/lib/crates/fabro-model/src/catalog/providers/zai.toml +index e720df4da..c6d70cd64 100644 +--- a/lib/crates/fabro-model/src/catalog/providers/zai.toml ++++ b/lib/crates/fabro-model/src/catalog/providers/zai.toml +@@ -8,50 +8,46 @@ priority = 60 + [providers.zai.auth] + credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"] + +-[models."glm-5.2"] +-provider = "zai" +-api_id = "glm-5.2" ++[providers.zai.models."glm-5.2"] + display_name = "GLM 5.2" + family = "glm-5" + default = true + aliases = ["glm", "glm5"] + +-[models."glm-5.2".limits] ++[providers.zai.models."glm-5.2".limits] + context_window = 1048576 + max_output = 131072 + +-[models."glm-5.2".features] ++[providers.zai.models."glm-5.2".features] + tools = true + vision = false + reasoning = true + reasoning_effort = "levels" + prompt_cache = true + +-[models."glm-5.2".controls] ++[providers.zai.models."glm-5.2".controls] + reasoning_effort = ["high", "max"] + +-[models."glm-5.2".costs] ++[providers.zai.models."glm-5.2".costs] + input_cost_per_mtok = 1.4 + output_cost_per_mtok = 4.4 + cache_input_cost_per_mtok = 0.26 + +-[models."glm-4.7"] +-provider = "zai" +-api_id = "glm-4.7" ++[providers.zai.models."glm-4.7"] + display_name = "GLM 4.7" + family = "glm-4" + estimated_output_tps = 100 + aliases = ["glm4"] + +-[models."glm-4.7".limits] ++[providers.zai.models."glm-4.7".limits] + context_window = 202752 + max_output = 16384 + +-[models."glm-4.7".features] ++[providers.zai.models."glm-4.7".features] + tools = true + vision = false + reasoning = false + +-[models."glm-4.7".costs] ++[providers.zai.models."glm-4.7".costs] + input_cost_per_mtok = 0.6 + output_cost_per_mtok = 2.2 +diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs +index 18d1bdd86..94b6f043e 100644 +--- a/lib/crates/fabro-model/src/ids.rs ++++ b/lib/crates/fabro-model/src/ids.rs +@@ -101,9 +101,12 @@ impl AsRef for ProviderId { + } + } + +-/// Stable model identifier — either the canonical catalog ID or one of its +-/// declared aliases. +-#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] ++/// Stable, canonical human-facing model slug. ++/// ++/// Aliases are selectors that resolve to a `ModelId`; they are never model ++/// IDs themselves. The same canonical slug may identify one offering on each ++/// provider, so an offering's full identity is `(ProviderId, ModelId)`. ++#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct ModelId(String); + +@@ -123,12 +126,32 @@ impl ModelId { + } + } + ++impl std::borrow::Borrow for ModelId { ++ fn borrow(&self) -> &str { ++ self.as_str() ++ } ++} ++ ++impl std::ops::Deref for ModelId { ++ type Target = str; ++ ++ fn deref(&self) -> &Self::Target { ++ self.as_str() ++ } ++} ++ + impl fmt::Display for ModelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + ++impl fmt::Debug for ModelId { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ fmt::Debug::fmt(&self.0, f) ++ } ++} ++ + impl From<&str> for ModelId { + fn from(s: &str) -> Self { + Self(s.to_string()) +@@ -147,6 +170,30 @@ impl AsRef for ModelId { + } + } + ++impl PartialEq for ModelId { ++ fn eq(&self, other: &str) -> bool { ++ self.as_str() == other ++ } ++} ++ ++impl PartialEq<&str> for ModelId { ++ fn eq(&self, other: &&str) -> bool { ++ self.as_str() == *other ++ } ++} ++ ++impl PartialEq for str { ++ fn eq(&self, other: &ModelId) -> bool { ++ self == other.as_str() ++ } ++} ++ ++impl PartialEq for &str { ++ fn eq(&self, other: &ModelId) -> bool { ++ *self == other.as_str() ++ } ++} ++ + #[cfg(test)] + mod tests { + use super::*; +diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs +index 8dcbd3270..312067f9a 100644 +--- a/lib/crates/fabro-model/src/types.rs ++++ b/lib/crates/fabro-model/src/types.rs +@@ -1,6 +1,6 @@ + use serde::{Deserialize, Serialize}; + +-use crate::ids::ProviderId; ++use crate::ids::{ModelId, ProviderId}; + + // --- 2.9 Model --- + +@@ -78,7 +78,7 @@ pub struct ModelCosts { + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct Model { +- pub id: String, ++ pub id: ModelId, + pub provider: ProviderId, + pub family: String, + pub display_name: String, +@@ -210,7 +210,7 @@ mod tests { + #[test] + fn inherent_methods_return_correct_values() { + let info = Model { +- id: "model-id".to_string(), ++ id: ModelId::new("model-id"), + provider: ProviderId::new("provider-id"), + family: "family".to_string(), + display_name: "Display Name".to_string(), diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..f2327288b --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "failed", + "notes": null, + "failure_reason": "LLM error: Invalid request to openrouter: This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 46521. To increase, visit https://openrouter.ai/settings/credits and add more credits", + "timestamp": "2026-07-23T03:40:57.538523441Z" +} \ No newline at end of file diff --git a/stages/006-simplify_fable@1/prompt.md b/stages/006-simplify_fable@1/prompt.md new file mode 100644 index 000000000..2fb91101f --- /dev/null +++ b/stages/006-simplify_fable@1/prompt.md @@ -0,0 +1,386 @@ +Goal: # Provider-aware model aliases and API IDs + +## Outcome + +Fabro workflows can name a model with one stable model slug or alias and run unchanged against whichever provider the operator has available. A model offering is identified by `(provider, ModelId)`, so the same `ModelId` and the same alias may appear on multiple providers. For an unqualified selector, Fabro filters to ready providers and then uses provider priority to choose one offering deterministically. + +The motivating behavior is: + +| Ready providers | Selector | Selected offering | +| --- | --- | --- | +| OpenAI only | `gpt-56-sol` | OpenAI's `gpt-5.6-sol` | +| OpenRouter only | `gpt-56-sol` | OpenRouter's `gpt-5.6-sol` offering | +| OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because its provider priority is higher | +| OpenAI and OpenRouter, explicit `provider = "openrouter"` | `gpt-56-sol` | OpenRouter, because an explicit provider is a pin | + +The provider-facing API identifier remains an implementation detail of the selected offering. It defaults to the canonical model slug and can be overridden with `api_id` when a provider uses another convention. + +## Scope and design decisions + +### Vocabulary and identity + +- `ProviderId` identifies who serves the request, such as `openai` or `openrouter`. +- `ModelId` is the canonical, human-facing model slug, such as `gpt-5.6-sol` or `claude-opus-4-8`. It never means an alias. +- An alias is an alternate user-facing selector, such as `gpt-56-sol` or `opus`. +- An offering is one provider's route to one `ModelId`. Its stable identity is `(ProviderId, ModelId)`. +- `api_id` is the opaque string sent to that offering's provider API. +- `family` remains model metadata used for display and matching; it is not a routing namespace and is not combined with `provider` or `api_id`. + +Do not add a separate runtime `LogicalModel` type. Use the existing `Model` as the provider-specific offering and use the existing `ModelId` newtype for its canonical ID. Internally, tuple keys `(ProviderId, ModelId)` are enough; do not add an `OfferingId` type unless implementation pressure demonstrates a real invariant it would protect. + +### Canonical configuration shape + +Move model declarations under their provider, but keep the human model slug as the model table key: + +```toml +[llm.providers.openai] +priority = 90 + +[llm.providers.openai.models."gpt-5.6-sol"] +display_name = "GPT-5.6 Sol" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true + +[llm.providers.openrouter] +priority = 25 + +[llm.providers.openrouter.models."gpt-5.6-sol"] +api_id = "openai/gpt-5.6-sol" +display_name = "GPT-5.6 Sol (via OpenRouter)" +family = "gpt-5" +aliases = ["gpt-56-sol"] +default = true +``` + +This shape provides a natural unique key without making humans author an API identifier or repeat `provider = "..."` inside every model. Model settings continue to field-merge by provider and model slug across configuration layers. + +At catalog build time: + +```text +effective_api_id = configured api_id, otherwise ModelId's exact slug +``` + +Reject an explicitly empty `api_id`. Do not perform provider-specific string rewrites, prefix inference, or template expansion. A future template feature may be authoring sugar that produces the same resolved `api_id`, but it is not part of this change. + +### Alias and selection semantics + +Build candidate sets rather than a global `identifier -> one model` map: + +- Canonical model IDs may repeat across providers. +- Aliases may repeat across providers and may point to different canonical model IDs on different providers. This supports both strict synonyms and portable role-like aliases. +- Within one provider, a canonical ID or alias must identify exactly one offering. Reject two models on the same provider that claim the same alias. +- Across providers, an alias may collide with a canonical `ModelId`; the canonical-before-alias check order keeps canonical IDs reliable pins, and the shadowed alias stays reachable through its provider-qualified form. Within one provider, the previous rule already rejects the collision. +- An explicit provider restricts lookup to that provider and bypasses provider priority. +- An unqualified selector considers only eligible providers, then sorts by provider priority descending and canonical provider ID ascending. +- A canonical `ModelId` match is checked before alias matches. +- Disabled providers and disabled offerings are absent from candidate sets. + +"Eligible" must be supplied by the caller rather than inferred inside the catalog: + +- Runtime calls use providers whose adapters registered successfully. This accounts for credentials and adapter initialization, not merely an enabled catalog row. +- Static validation explicitly uses all enabled catalog providers and proves that at least one candidate exists without claiming that credentials are available. +- An explicit but unavailable provider remains a pin and produces a clear unavailable-provider error; Fabro must not silently switch it. + +When a run is created, resolve every implicit selector once and persist the chosen canonical model ID and provider. Resume uses that materialized choice; it does not reconsider provider priority because credentials changed. Runtime fallbacks remain the mechanism for handling a later provider failure. + +Preserve the existing passthrough behavior for uncatalogued models: when a provider is explicit, send the unknown model string unchanged and use the provider's default route policy. An unknown unqualified model may use the runtime's default ready provider as it does today, but it cannot participate in alias-based provider selection. + +## Implementation plan + +### 1. Make configuration provider-scoped + +Files centered on: + +- `lib/crates/fabro-config/src/layers/llm.rs` +- `lib/crates/fabro-config/src/builders.rs` +- `lib/crates/fabro-model/src/catalog.rs` +- `lib/crates/fabro-model/src/catalog/providers/*.toml` + +Changes: + +1. Add `models: MergeMap` to `ProviderSettings` and the equivalent model map to `ProviderCatalogSettings`. +2. Remove `provider` from the canonical model-row shape; the containing provider supplies it. +3. Normalize catalog data into provider/model pairs before catalog building, preserving layer precedence independently for each pair. +4. Convert every built-in provider TOML to `[providers..models.""]`. +5. Re-key OpenRouter, Bedrock, and other aggregator offerings by Fabro's model slug rather than their provider API ID. Retain explicit `api_id` overrides for `author/model`, Bedrock profile IDs, deployment names, and other exceptions. +6. Remove redundant `api_id` fields where they equal the model slug. +7. Do not add an unverified provider offering merely to match the motivating example; exercise the exact example with a catalog fixture and use existing verified cross-provider models in the built-in catalog. + +Compatibility: + +- Accept the current `[llm.models.""]` plus `provider = "..."` form as a temporary input shape. A row that omits provider adopts the provider of the unique known offering matching its id or alias; if none or several match, fail with an error naming the row. +- Normalize each source layer into the canonical provider-scoped form before combining layers, so old and new definitions retain correct precedence. +- Reject a single source that defines the same `(provider, model)` through both syntaxes instead of choosing silently. +- Keep built-ins and documentation exclusively on the new syntax. Do not add a filesystem rewrite migration yet because LLM catalog layers can come from more places than one owned settings file; the compatibility parser covers all of those boundaries safely. +- Ship a retired-identifier map for re-keyed built-in ids (old catalog key to provider plus new slug). Any selector or persisted model reference matching a retired id fails with a typed error naming the new address; nothing silently re-routes. One mechanism covers old config references, workflow graphs, and resumed pre-change runs. + +### 2. Rebuild catalog identity and indexes + +Files centered on: + +- `lib/crates/fabro-model/src/ids.rs` +- `lib/crates/fabro-model/src/types.rs` +- `lib/crates/fabro-model/src/catalog.rs` +- `lib/crates/fabro-model/src/model_ref.rs` +- `lib/crates/fabro-model/src/billing.rs` + +Changes: + +1. Change `Model.id` from `String` to the transparent `ModelId` newtype and correct `ModelId` documentation so aliases are not described as model IDs. JSON remains a plain string. +2. Key resolved model settings by `(ProviderId, ModelId)` rather than model ID alone. +3. Replace the one-to-one `model_index` with: + - an offering index keyed by `(ProviderId, ModelId)`; + - canonical-ID candidates keyed by `ModelId`; + - alias candidates keyed by alias string. +4. Pre-sort candidate vectors with the catalog's provider ordering so every caller receives the same priority and tie-break behavior. +5. Replace global `Catalog::get`-style assumptions with explicit methods: + - lookup on a named provider; + - selection from an eligible-provider set; + - lookup of settings from a resolved `Model` offering; + - listing every offering, optionally by provider. +6. Make pricing, billing, codec, profile, probe, default, and closest-model lookups use the composite identity. Resolve the run-level default model with the same selection algorithm (default-flagged candidates from eligible providers, ordered by provider priority) without requiring providers to agree on their defaults. +7. Replace `DuplicateModelIdentifier` with provider-scoped validation errors that name the provider, selector, and conflicting model IDs. +8. Add a typed selection error that distinguishes an unknown selector from a known selector with no eligible offering. Preserve error sources and render strings only at CLI/API boundaries. + +### 3. Centralize provider-aware resolution + +Files centered on: + +- `lib/crates/fabro-model/src/catalog.rs` +- `lib/crates/fabro-types/src/settings/model_ref.rs` +- `lib/crates/fabro-workflow/src/handler/llm/routing.rs` +- `lib/crates/fabro-workflow/src/transforms/model_resolution.rs` +- `lib/crates/fabro-workflow/src/run_materialization.rs` +- `lib/crates/fabro-workflow/src/operations/start.rs` + +Changes: + +1. Implement one catalog selection algorithm taking a selector, optional explicit provider, and eligible provider IDs. +2. Make generic `ModelRef` parsing classify bare versus provider-qualified input only. It must not try to infer a unique provider for a bare alias, because a valid alias may now have several provider candidates. +3. Keep the existing `provider/model` qualified syntax in this change. The model slug never contains the provider API ID, so OpenRouter's slash is no longer part of the user-facing model address. +4. Update workflow graph model resolution and run materialization to receive the ready-provider snapshot already collected during run creation. +5. Materialize aliases to canonical `(provider, ModelId)` values in both node attributes and run defaults before persistence. +6. Keep static validation credential-independent by resolving against all enabled candidates only for existence/capability checks. +7. Update fallback resolution so: + - a provider-only fallback still selects the closest compatible model; + - a provider-qualified model/alias resolves within that provider; + - a bare model/alias uses the fallback-time eligible set and provider priority; + - provider-name/model-name ambiguity becomes a user-facing typed error; today AmbiguousModelRef is silently swallowed by fallback resolution, so pin this behavior change with a test. + +### 4. Resolve the offering before LLM dispatch + +Files centered on: + +- `lib/crates/fabro-llm/src/client.rs` +- `lib/crates/fabro-llm/src/adapter_registry.rs` +- `lib/crates/fabro-llm/src/providers/common.rs` +- provider adapter modules under `lib/crates/fabro-llm/src/providers/` + +Changes: + +1. For requests without an explicit provider, select among the client's successfully registered providers using catalog priority. +2. For requests with an explicit provider, resolve the model or alias only on that provider and fail if the adapter is unavailable. +3. Canonicalize a cloned request to the selected `ModelId` before validation, costing, and dispatch; leave caller-owned request data unchanged. +4. Resolve route metadata and `api_id` from the selected composite offering. Provider adapters must pass their own canonical provider ID into catalog lookups rather than looking up settings by model string alone. +5. Ensure response costing and billing use the same resolved offering that was dispatched. +6. Keep explicit-provider unknown-model passthrough intact. + +### 5. Update server, API, CLI, and web identities + +Files centered on: + +- `docs/public/api-reference/fabro-api.yaml` +- `lib/crates/fabro-server/src/server/handler/models.rs` +- `lib/crates/fabro-server/src/server/handler/sessions.rs` +- `lib/crates/fabro-cli/src/commands/model.rs` +- `apps/fabro-web/app/routes/settings-models.tsx` +- generated clients in `lib/crates/fabro-api` and `lib/packages/fabro-api-client` + +Changes: + +1. Continue returning one `Model` row per offering from `GET /models`. Document that `id` is unique within a provider and that `(provider, id)` is the resource identity. +2. Add an optional `provider` query parameter to `POST /models/{id}/test`. With a provider it tests that exact offering; without one it selects among ready providers by priority. +3. Include `provider` in `ModelTestResult` so the tested offering is explicit. +4. Make model-test lookup, auth issues, and probing use the selected offering rather than a global first match. +5. Update the CLI so bulk tests always pass each row's provider, and an explicit `--provider` plus `--model` remains pinned. Match returned results by `(provider, id)`. +6. Update the settings models page to key row state by `(provider, id)` and send the provider when testing a row; duplicate IDs must render and update independently. +7. Update session/playground/completion resolution to use ready provider IDs and persist or return the selected provider alongside the canonical model. Enumerate the OpenAPI schema changes this implies for session, playground, and completion resources; sessions currently store only a bare model-id string. +8. Regenerate Rust and TypeScript API clients from the OpenAPI source after changing the contract. + +### 6. Document the mental model + +Files centered on: + +- `lib/crates/fabro-dev/src/commands/docs_options_reference.rs` +- `docs/public/reference/user-configuration.mdx` (generated region) +- `docs/public/core-concepts/models.mdx` +- `docs/public/execution/run-configuration.mdx` +- `docs/public/execution/failures.mdx` + +Document: + +1. Provider, model slug, family metadata, alias, and API ID as distinct terms. +2. Provider-scoped model configuration and the `api_id = model slug` default. +3. The OpenAI/OpenRouter portability example and the priority table from this plan. +4. Explicit provider selection as a pin and unqualified selection as availability plus priority. +5. Alias reuse across providers, including the same-provider ambiguity rule. +6. Resolution-once behavior for persisted runs and the separate role of runtime fallback chains. +7. API IDs as opaque provider wire values that workflows should not reference. + +Run `cargo dev docs refresh` after editing the generator-owned reference. + +## Test plan + +### Catalog and configuration tests + +Add focused unit tests proving: + +- two providers can declare the same canonical `ModelId`; +- two providers can declare the same alias; +- only OpenAI eligible selects OpenAI; +- only OpenRouter eligible selects OpenRouter and its overridden API ID; +- both eligible select the higher-priority provider; +- equal priorities use canonical provider ID as the tie-breaker; +- an explicit provider overrides priority; +- a disabled or ineligible provider is not selected; +- two different models on one provider cannot claim the same alias; +- an unqualified selector matching both a canonical ID and another provider's alias selects the canonical model, while the alias offering stays reachable provider-qualified; +- omitted `api_id` resolves to the exact model slug; +- explicit `api_id` is preserved and an empty override is rejected; +- provider/model layer merges do not overwrite the same slug on another provider; +- the temporary old config shape normalizes correctly and a same-source old/new collision errors clearly. +- a provider-less legacy row adopts the unique matching offering's provider, and a retired built-in id fails with the typed error naming its replacement. + +### Routing and wire tests + +Add `fabro-llm` tests with fake registered providers or local capture servers that submit the same alias under three availability configurations. Assert the selected adapter and the exact wire model value, including OpenRouter's `author/model` override. Also cover explicit provider, unknown passthrough, request-control validation, and cost lookup on duplicate model IDs. + +### Workflow tests + +Add crate-level workflow tests that create the same workflow with: + +- only the direct provider ready; +- only the aggregator ready; +- both ready; +- an explicit lower-priority provider. + +Assert the persisted graph and run settings contain the selected canonical model and provider. Add a resume-oriented test showing that changing the ready provider set does not re-resolve a materialized run. Add fallback tests for a shared bare alias and a provider-qualified alias, including propagation of a provider/model ambiguity error. + +### API, CLI, and web tests + +- Server: list two rows with the same ID but different providers; filter by provider; test each exact offering; test priority selection when provider is omitted. +- CLI: bulk model tests do not conflate duplicate IDs, and JSON output includes the selected provider. +- Web: duplicate-ID rows have independent React keys and test-result state, and each request includes the row provider. +- API generation: retain the existing `Model` Rust type replacement and add or update JSON parity/type-identity coverage as required by the API policy. + +Use unit/crate integration tests for catalog and routing behavior. Use the existing command/API test layers only for their public contracts; no live provider credentials are required. + +## Verification + +Run, in this order: + +```sh +cargo build -p fabro-api +cd lib/packages/fabro-api-client && bun run generate +cargo dev docs refresh +cargo nextest run -p fabro-model +cargo nextest run -p fabro-config +cargo nextest run -p fabro-llm +cargo nextest run -p fabro-workflow +cargo nextest run -p fabro-server +cd apps/fabro-web && bun test +cd apps/fabro-web && bun run typecheck +cargo dev docs check +cargo +nightly-2026-04-14 fmt --check --all +cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace +cargo build --workspace +``` + +Before accepting any changed snapshots, run `cargo insta pending-snapshots` and inspect the complete pending set. + +## Completion criteria + +- A workflow using one shared alias runs unchanged for an OpenAI-only operator and an OpenRouter-only operator. +- When both are ready, provider priority selects deterministically. +- Explicit provider selection always pins the provider. +- The selected offering's exact `api_id` reaches the provider wire request. +- No catalog, routing, billing, API, CLI, or UI lookup treats model ID alone as a globally unique offering identity. +- Built-ins and public documentation use provider-scoped model-slug keys and omit redundant API IDs. +- Existing user catalog syntax remains readable through the compatibility normalization path. + +## Unresolved questions + +- What release or date should end support for the legacy top-level `[llm.models]` syntax? This does not block implementation; the plan keeps it as a compatibility input and makes the new provider-scoped form canonical. + + +## Completed stages +- **toolchain**: succeeded + - Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1` + - Output: + ``` + cargo 1.96.0 (30a34c682 2026-05-25) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: failed + +## Context +- failure_class: deterministic +- failure_signature: implement|deterministic|api_deterministic|openrouter|invalid_request + + +# Simplify: Code Review and Cleanup + +Review all changed files for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run \`git diff\` (or \`git diff HEAD\` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the ${AGENT_TOOL_NAME} tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead. +3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. **Redundant state**: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones +3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction +4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. **Stringly-typed code**: using raw strings where constants, enums (string unions), or branded types already exist in the codebase +6. **Unnecessary JSX nesting**: wrapper Boxes/elements that add no layout value — check if inner component props (flexShrink, alignItems, etc.) already provide the needed behavior +7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds) + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. **Missed concurrency**: independent operations run sequentially when they could run in parallel +3. **Hot-path bloat**: new blocking work added to startup or per-request/per-render hot paths +4. **Recurring no-op updates**: state/store updates inside polling loops, intervals, or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed. Also: if a wrapper function takes an updater/reducer callback, verify it honors same-reference returns (or whatever the "no change" signal is) — otherwise callers' early-return no-ops are silently defeated +5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +6. **Memory**: unbounded data structures, missing cleanup, event listener leaks +7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). diff --git a/stages/006-simplify_fable@1/provider_used.json b/stages/006-simplify_fable@1/provider_used.json new file mode 100644 index 000000000..84b808d90 --- /dev/null +++ b/stages/006-simplify_fable@1/provider_used.json @@ -0,0 +1,6 @@ +{ + "mode": "agent", + "provider": "openrouter", + "model": "anthropic/claude-fable-5", + "reasoning_effort": "xhigh" +} \ No newline at end of file