From 5f49cdf9dac4855e13074f36fb695887c0c80bfc Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 17:26:57 -0600 Subject: [PATCH] Delete fabro-model and document the lithos catalog overlay fabro-model's ids and billing rollup now live in fabro-types, and its pricing, catalog, provider TOMLs, and legacy index are replaced by the lithos built-in catalog plus the Fabro policy layer. Regenerate the configuration reference for the `[llm]` overlay and `metadata.fabro`, and rewrite the SDK, models, and integration docs for the lithos provider and model shapes. Co-Authored-By: Claude Fable 5.1 --- docs/public/core-concepts/models.mdx | 100 +- docs/public/execution/run-configuration.mdx | 2 +- docs/public/integrations/bedrock.mdx | 16 +- docs/public/integrations/fireworks.mdx | 23 +- docs/public/integrations/litellm.mdx | 40 +- docs/public/integrations/modal.mdx | 34 +- docs/public/integrations/openrouter.mdx | 25 +- docs/public/integrations/poolside.mdx | 2 +- docs/public/reference/cli.mdx | 2 +- docs/public/reference/sdk.mdx | 744 +- docs/public/reference/user-configuration.mdx | 233 +- .../src/commands/docs_options_reference.rs | 200 +- lib/foundation/fabro-dev/tests/it/policy.rs | 68 - lib/foundation/fabro-model/Cargo.toml | 31 - lib/foundation/fabro-model/src/adapter.rs | 130 - lib/foundation/fabro-model/src/billing.rs | 1446 ---- .../fabro-model/src/bootstrap_catalog.rs | 22 - lib/foundation/fabro-model/src/catalog.rs | 7529 ----------------- .../src/catalog/providers/anthropic.toml | 261 - .../src/catalog/providers/bedrock-openai.toml | 75 - .../src/catalog/providers/bedrock.toml | 409 - .../src/catalog/providers/deepseek.toml | 74 - .../src/catalog/providers/fireworks.toml | 281 - .../src/catalog/providers/gemini.toml | 127 - .../src/catalog/providers/inception.toml | 30 - .../src/catalog/providers/litellm.toml | 30 - .../src/catalog/providers/minimax.toml | 29 - .../src/catalog/providers/modal.toml | 53 - .../src/catalog/providers/moonshot.toml | 67 - .../src/catalog/providers/ollama.toml | 25 - .../src/catalog/providers/openai.toml | 201 - .../src/catalog/providers/openrouter.toml | 699 -- .../src/catalog/providers/poolside.toml | 61 - .../src/catalog/providers/venice.toml | 212 - .../src/catalog/providers/zai.toml | 53 - lib/foundation/fabro-model/src/codec.rs | 117 - lib/foundation/fabro-model/src/ids.rs | 209 - lib/foundation/fabro-model/src/lib.rs | 32 - lib/foundation/fabro-model/src/model_ref.rs | 109 - lib/foundation/fabro-model/src/model_test.rs | 33 - lib/foundation/fabro-model/src/provider.rs | 95 - lib/foundation/fabro-model/src/reasoning.rs | 160 - lib/foundation/fabro-model/src/types.rs | 287 - 43 files changed, 408 insertions(+), 13968 deletions(-) delete mode 100644 lib/foundation/fabro-model/Cargo.toml delete mode 100644 lib/foundation/fabro-model/src/adapter.rs delete mode 100644 lib/foundation/fabro-model/src/billing.rs delete mode 100644 lib/foundation/fabro-model/src/bootstrap_catalog.rs delete mode 100644 lib/foundation/fabro-model/src/catalog.rs delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/anthropic.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/bedrock.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/deepseek.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/fireworks.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/gemini.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/inception.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/litellm.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/minimax.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/modal.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/moonshot.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/ollama.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/openai.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/openrouter.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/poolside.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/venice.toml delete mode 100644 lib/foundation/fabro-model/src/catalog/providers/zai.toml delete mode 100644 lib/foundation/fabro-model/src/codec.rs delete mode 100644 lib/foundation/fabro-model/src/ids.rs delete mode 100644 lib/foundation/fabro-model/src/lib.rs delete mode 100644 lib/foundation/fabro-model/src/model_ref.rs delete mode 100644 lib/foundation/fabro-model/src/model_test.rs delete mode 100644 lib/foundation/fabro-model/src/provider.rs delete mode 100644 lib/foundation/fabro-model/src/reasoning.rs delete mode 100644 lib/foundation/fabro-model/src/types.rs diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index d04232b55..a6f0dcd36 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -78,97 +78,77 @@ 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. Models are nested under their provider, so two providers can expose the same model slug without overwriting each other. +Fabro's catalog is the [lithos-llm](https://docs.rs/lithos-llm) built-in catalog with Fabro's policy layer applied. The `[llm]` table in settings is a third layer over both: a lithos catalog overlay that adds providers and models or changes existing entries. Later layers win. Tables merge key by key and every other value replaces. Models are nested under their provider, so two providers can expose the same model id without overwriting each other. + +Provider and model facts use lithos field names: `adapter`, `codec`, `base_url`, `auth`, `limits`, `capabilities`, `pricing`. Fabro policy lives under `metadata.fabro` on the provider or model: credentials, agent profile, `enabled`, default roles, and display metadata. See [Settings Configuration](/reference/user-configuration#llm) for every key. ```toml title="settings.toml" [llm.providers.proxy] display_name = "Acme Gateway" -adapter = "openai_compatible" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://llm-gateway.example.com/v1" +auth = { type = "bearer" } aliases = ["gateway"] +default_model = "team-code-large" -[llm.providers.proxy.auth] +[llm.providers.proxy.metadata.fabro] +agent_profile = "anthropic" credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] -[llm.providers.proxy.extra_headers] +[llm.providers.proxy.metadata.fabro.extra_headers] x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" [llm.providers.proxy.models."team-code-large"] -api_id = "provider-wire-model-name" -agent_profile = "anthropic" display_name = "Team Code Large" -family = "team-code" -default = true -small_default = true aliases = ["team-code"] +api_model = "provider-wire-model-name" +limits = { context_tokens = 200000, max_output_tokens = 32000 } +capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } } +protocol_options = { reasoning_effort_levels = true } +pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 } + +[llm.providers.proxy.models."team-code-large".metadata.fabro] +family = "team-code" +small_default = true estimated_output_tps = 80 - -[llm.providers.proxy.models."team-code-large".limits] -context_window = 200000 -max_output = 32000 - -[llm.providers.proxy.models."team-code-large".features] -tools = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[llm.providers.proxy.models."team-code-large".controls] -reasoning_effort = ["low", "medium", "high"] -speed = ["fast"] - -[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.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 ``` 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" [llm.providers.litellm] -enabled = true base_url = "http://localhost:4000/v1" +default_model = "litellm-gpt-5" + +[llm.providers.litellm.metadata.fabro] +enabled = true [llm.providers.litellm.models."litellm-gpt-5"] -api_id = "gpt-5" display_name = "LiteLLM GPT-5" -family = "litellm" -default = true - -[llm.providers.litellm.models."litellm-gpt-5".limits] -context_window = 128000 -max_output = 8192 - -[llm.providers.litellm.models."litellm-gpt-5".features] -tools = true -vision = false -reasoning = false +api_model = "gpt-5" +limits = { context_tokens = 128000, max_output_tokens = 8192 } +capabilities = { text = true, tools = true } ``` -`api_id` is the opaque model name sent to that provider's API. It defaults to the exact model slug, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value. +`api_model` is the model name sent to that provider's API. It defaults to the exact model id, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value. -Historical built-in catalog keys that exposed provider API IDs remain accepted as compatibility selectors. Fabro normalizes a primary or node selector such as `openai/gpt-5.6-sol` to the canonical `gpt-5.6-sol` slug before normal provider-aware selection. With no provider pin, the highest-priority ready offering wins; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Fabro also normalizes these keys in legacy top-level `[llm.models]` rows without rewriting the settings file. +A `provider/model` selector such as `openai/gpt-5.6-sol` pins the provider and names the model by id, alias, or wire id. A bare selector with no provider pin picks the highest-priority ready offering; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Providers with `allow_passthrough = true` also accept `provider/model` selectors for models the catalog does not list. -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. +Model roles are separate: the provider's `default_model` controls normal model selection for workflow execution, while `small_default = true` under `metadata.fabro` marks the provider's small utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's default model. -Provider auth is declared in `[llm.providers..auth]` with ordered `env:` or `vault:` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers..auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only interpolation headers and no API-key auth — go in `extra_headers` as literal text or `{{ secrets.NAME }}` tokens. Put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal. +Provider auth has two parts. The lithos `auth` scheme says how a credential is sent: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }` for providers that take several secret headers, `{ type = "none" }`, or `{ type = "aws" }`. Fabro's `metadata.fabro.credentials` says where the secret comes from, as ordered `env:`, `vault:`, or `aws_sigv4` refs; the first that resolves wins. Custom headers for any provider go in `metadata.fabro.extra_headers` as literal text or `{{ secrets.NAME }}` tokens. Put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal. Workflow runs also add `x-session-id: ` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `extra_headers` takes precedence. -Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values. +Provider `metadata.fabro.agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values. Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately. -Provider `billing_policy` defaults from `adapter` and controls usage-cost estimation. Use `openai`, `anthropic`, `gemini`, or `none`. Model rows may override it for models whose billing family differs from their provider's — for example, Claude models served through OpenRouter set `billing_policy = "anthropic"` so cache reads and writes price correctly. +Costs come from the lithos `pricing` table on each model row. Each token bucket (input, output, reasoning, cache read, cache write) prices at its own rate, with optional long-context and speed tiers. Providers that return an authoritative charge, such as OpenRouter, override the catalog estimate; the billing record says which source it came from. Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted. @@ -180,14 +160,14 @@ Fabro ships a built-in [Venice](/integrations/venice) provider with a curated ca ### Poolside -Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values. +Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_model` values. ### OpenRouter Fabro ships an [OpenRouter](/integrations/openrouter) provider definition with a curated model catalog, disabled by default. Enable it in settings and store an API key with `fabro provider login --provider openrouter`: ```toml title="settings.toml" -[llm.providers.openrouter] +[llm.providers.openrouter.metadata.fabro] enabled = true ``` @@ -197,8 +177,10 @@ Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disa ```toml title="settings.toml" [llm.providers.modal] -enabled = true base_url = "https://your-endpoint.modal.run/v1" + +[llm.providers.modal.metadata.fabro] +enabled = true ``` Store both token values in the Fabro server vault: @@ -214,8 +196,10 @@ Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with ```toml title="settings.toml" [llm.providers.bedrock] -enabled = true base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + +[llm.providers.bedrock.metadata.fabro] +enabled = true ``` ### Ollama @@ -223,11 +207,11 @@ base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" Fabro ships an Ollama provider definition that is disabled by default. Enable it in settings when you want Fabro to route through a local Ollama server: ```toml title="settings.toml" -[llm.providers.ollama] +[llm.providers.ollama.metadata.fabro] enabled = true ``` -Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.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 explicit `[llm.providers.ollama.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`. ## Default models diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index 699c3b3c4..587c92d8e 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -195,7 +195,7 @@ speed = "fast" | Field | Description | |---|---| -| `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. | +| `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"minimal"`, `"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. | #### Fallback lists with splice diff --git a/docs/public/integrations/bedrock.mdx b/docs/public/integrations/bedrock.mdx index 5852dfdb7..8d4ba9d20 100644 --- a/docs/public/integrations/bedrock.mdx +++ b/docs/public/integrations/bedrock.mdx @@ -27,8 +27,10 @@ Add the provider override to `~/.fabro/settings.toml`: _version = 1 [llm.providers.bedrock] -enabled = true base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" + +[llm.providers.bedrock.metadata.fabro] +enabled = true ``` The SigV4 signing region is derived from `base_url` — change it to your Region's endpoint (`https://bedrock-runtime..amazonaws.com`, FIPS and China endpoints included). @@ -50,7 +52,7 @@ Runs read the bearer token from the vault only. Workers start from a cleared env **AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically. The catalog declares this as the `aws_sigv4` credential source: ```toml -[llm.providers.bedrock.auth] +[llm.providers.bedrock.metadata.fabro] credentials = ["env:AWS_BEARER_TOKEN_BEDROCK", "env:BEDROCK_API_KEY", "vault:AWS_BEARER_TOKEN_BEDROCK", "vault:BEDROCK_API_KEY", "aws_sigv4"] ``` @@ -60,7 +62,7 @@ The key resolves from the process environment first (either name), then the serv **Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* To run Converse models on SigV4 while using a mantle-only bearer key for GPT-5.x, pin the Converse provider to SigV4 explicitly: ```toml -[llm.providers.bedrock.auth] +[llm.providers.bedrock.metadata.fabro] credentials = ["aws_sigv4"] ``` @@ -83,7 +85,7 @@ The built-in catalog curates Converse-capable models, using cross-region inferen | `moonshotai.kimi-k2.5`, `zai.glm-5` | | | `minimax.minimax-m2.5`, `nvidia.nemotron-3-super` | | -Any other Converse-capable Bedrock model can be added as a settings model entry with `provider = "bedrock"` and the Bedrock model or inference-profile id as `api_id`. +Any other Converse-capable Bedrock model can be added under `[llm.providers.bedrock.models.""]` with the Bedrock model or inference-profile id as `api_model`. Not included on this provider: Claude Mythos 5 (Anthropic-Messages-only on `bedrock-mantle`, limited preview). OpenAI's frontier models live on the companion `bedrock-openai` provider below. @@ -92,7 +94,7 @@ Not included on this provider: Claude Mythos 5 (Anthropic-Messages-only on `bedr GPT-5.5 and GPT-5.4 on Bedrock are served only by the `bedrock-mantle` endpoint's OpenAI Responses API — a different surface than Converse. Fabro ships a companion `bedrock-openai` provider for them: the same AWS account and `AWS_BEARER_TOKEN_BEDROCK` key, pointed at the mantle endpoint over the OpenAI dialect. ```toml title="settings.toml" -[llm.providers.bedrock-openai] +[llm.providers.bedrock-openai.metadata.fabro] enabled = true # regional: change to https://bedrock-mantle..api.aws/openai/v1 ``` @@ -113,7 +115,7 @@ fabro run workflow.fabro --model deepseek.v3-2 ## Prompt caching -Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price Anthropic-style via the per-model `billing_policy`. +Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price at the row's `cached_input_usd_micros_per_million` and `cache_write_usd_micros_per_million` rates. ## Converse extensions @@ -142,7 +144,7 @@ Bedrock-specific request fields pass through verbatim via `provider_options.bedr **"data retention mode 'default' is not available for this model"** — Fable 5 / Mythos-class models require opting into data sharing first; see [Model access and approvals](#model-access-and-approvals). -**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_id` (from `aws bedrock list-inference-profiles`) on the model entry. +**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_model` (from `aws bedrock list-inference-profiles`) on the model entry. **`ValidationException` mentioning on-demand throughput** — The model requires an inference-profile id; use the `us.`/`global.`-prefixed id from the catalog rather than the bare model id. diff --git a/docs/public/integrations/fireworks.mdx b/docs/public/integrations/fireworks.mdx index e18198851..0a525fc31 100644 --- a/docs/public/integrations/fireworks.mdx +++ b/docs/public/integrations/fireworks.mdx @@ -17,7 +17,7 @@ Fabro runs execute through a Fabro server. Add the provider override to the sett ```toml title="settings.toml" _version = 1 -[llm.providers.fireworks] +[llm.providers.fireworks.metadata.fabro] enabled = true ``` @@ -44,7 +44,7 @@ export FIREWORKS_API_KEY=fw_... ## Included models -The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_id` values: +The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_model` values: | Fabro model slug | Fireworks API ID / notes | | --- | --- | @@ -59,21 +59,14 @@ The built-in catalog gives Fireworks offerings the same human-facing model slugs | `gpt-oss-120b` | `accounts/fireworks/models/gpt-oss-120b` | | `gpt-oss-20b` | `accounts/fireworks/models/gpt-oss-20b`; provider small default | -Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_id` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`): +Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_model` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`): ```toml title="settings.toml" [llm.providers.fireworks.models."llama-4-maverick"] -api_id = "accounts/fireworks/models/llama4-maverick-instruct-basic" display_name = "Llama 4 Maverick" -family = "llama-4" - -[llm.providers.fireworks.models."llama-4-maverick".limits] -context_window = 1000000 - -[llm.providers.fireworks.models."llama-4-maverick".features] -tools = true -vision = false -reasoning = false +api_model = "accounts/fireworks/models/llama4-maverick-instruct-basic" +limits = { context_tokens = 1000000, max_output_tokens = 16384 } +capabilities = { text = true, tools = true } ``` Note that Fireworks' `GET /v1/models` endpoint only returns a featured subset of serverless models; a model absent from that list may still be servable. Verify custom additions with `fabro model test`. @@ -117,7 +110,7 @@ Fireworks caches prompt prefixes automatically — no cache breakpoints or reque ## Costs -Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports `cost_source = "estimated"` from catalog rates. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog. +Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports the cost source as `catalog`. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog. ## Troubleshooting @@ -127,7 +120,7 @@ Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/s **402 / insufficient credits** — Serverless inference requires prepaid credit; check your balance in the [Fireworks billing dashboard](https://app.fireworks.ai/settings/billing). -**Unknown model** — Confirm the model's `api_id` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model `. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive. +**Unknown model** — Confirm the model's `api_model` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model `. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive. ## Further reading diff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx index c79ba275e..cc726ad1b 100644 --- a/docs/public/integrations/litellm.mdx +++ b/docs/public/integrations/litellm.mdx @@ -21,26 +21,20 @@ Add the provider override and one or more model entries to `~/.fabro/settings.to _version = 1 [llm.providers.litellm] -enabled = true base_url = "http://localhost:4000/v1" +default_model = "litellm-gpt-5" + +[llm.providers.litellm.metadata.fabro] +enabled = true [llm.providers.litellm.models."litellm-gpt-5"] -api_id = "gpt-5" display_name = "LiteLLM GPT-5" -family = "litellm" -default = true - -[llm.providers.litellm.models."litellm-gpt-5".limits] -context_window = 128000 -max_output = 8192 - -[llm.providers.litellm.models."litellm-gpt-5".features] -tools = true -vision = false -reasoning = false +api_model = "gpt-5" +limits = { context_tokens = 128000, max_output_tokens = 8192 } +capabilities = { text = true, tools = true } ``` -`api_id` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. +`api_model` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. ## Configure credentials @@ -94,22 +88,14 @@ Declare each LiteLLM-routed model explicitly so Fabro knows its provider, contex ```toml title="settings.toml" [llm.providers.litellm.models."litellm-fast"] -api_id = "fast-model" display_name = "LiteLLM Fast" -family = "litellm" aliases = ["fast"] - -[llm.providers.litellm.models."litellm-fast".limits] -context_window = 64000 -max_output = 4096 - -[llm.providers.litellm.models."litellm-fast".features] -tools = true -vision = false -reasoning = false +api_model = "fast-model" +limits = { context_tokens = 64000, max_output_tokens = 4096 } +capabilities = { text = true, tools = true } ``` -Only one model for a provider should set `default = true`. You may also mark one small/cheap utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted. +The provider's `default_model` names its default. You may also mark one small utility model with `small_default = true` under its `metadata.fabro` table; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted. ## Troubleshooting @@ -117,7 +103,7 @@ Only one model for a provider should set `default = true`. You may also mark one **Connection refused** — Confirm the LiteLLM proxy is running and that `base_url` is reachable from the Fabro process. For Docker deployments, `localhost` means the Fabro container unless you point it at a host or service name. -**Unknown model from LiteLLM** — Check that the model's `api_id` matches the model name configured in LiteLLM, then run `fabro model test --model `. +**Unknown model from LiteLLM** — Check that the model's `api_model` matches the model name configured in LiteLLM, then run `fabro model test --model `. ## Further reading diff --git a/docs/public/integrations/modal.mdx b/docs/public/integrations/modal.mdx index 6edb816a1..8d66df21e 100644 --- a/docs/public/integrations/modal.mdx +++ b/docs/public/integrations/modal.mdx @@ -45,8 +45,10 @@ Add the provider override to the settings file used by the Fabro server. Include _version = 1 [llm.providers.modal] -enabled = true base_url = "https://your-endpoint.modal.run/v1" + +[llm.providers.modal.metadata.fabro] +enabled = true ``` The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint. @@ -113,45 +115,27 @@ digraph Example { ## Direct SDK environment credentials -The built-in Modal provider reads its two headers from the Fabro vault. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference. +The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the vault secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference. -For direct SDK use, enable Modal and set its endpoint URL in the catalog: +For direct SDK use, enable Modal and set its endpoint URL in the `[llm]` overlay, then build the client with `fabro_llm::build_client` over a `VaultCredentialSource` whose vault holds both secrets. The catalog you pass to the client must be built from the same settings file with `fabro_llm::build_catalog`. ```toml title="settings.toml" [llm.providers.modal] -enabled = true base_url = "https://your-endpoint.modal.run/v1" -``` -Then read both environment variables explicitly and create a typed credential after constructing `catalog` from those settings: - -```rust -use fabro_auth::ApiCredential; -use fabro_llm::client::Client; -use std::collections::HashMap; - -let credential = ApiCredential::with_extra_headers( - "modal", - HashMap::from([ - ("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?), - ( - "Modal-Secret".to_string(), - std::env::var("MODAL_TOKEN_SECRET")?, - ), - ]), -); -let client = Client::from_credentials(vec![credential], catalog).await?; +[llm.providers.modal.metadata.fabro] +enabled = true ``` ## Costs -Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports `cost_source = "estimated"`. +Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports the cost source as `catalog`. Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill. ## Troubleshooting -**"provider 'modal' uses openai_compatible adapter but does not configure base_url"** — Add the Modal endpoint URL under `[llm.providers.modal]`. Include `/v1`. +**Modal requests fail with 404** — Add the Modal endpoint URL as `base_url` under `[llm.providers.modal]`. Include `/v1`. **Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient. diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index d82dbc4e7..34d1c4116 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -17,7 +17,7 @@ Fabro runs execute through a Fabro server. Add the provider override to the sett ```toml title="settings.toml" _version = 1 -[llm.providers.openrouter] +[llm.providers.openrouter.metadata.fabro] enabled = true ``` @@ -44,7 +44,7 @@ export OPENROUTER_API_KEY=sk-or-v1-... ## Included models -The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_id` values: +The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_model` values: | Fabro model slug | OpenRouter API ID / notes | | --- | --- | @@ -60,21 +60,14 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug | `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs | | `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs | -Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_id`: +Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_model`: ```toml title="settings.toml" [llm.providers.openrouter.models."llama-4-maverick"] -api_id = "meta-llama/llama-4-maverick" display_name = "Llama 4 Maverick" -family = "llama-4" - -[llm.providers.openrouter.models."llama-4-maverick".limits] -context_window = 1000000 - -[llm.providers.openrouter.models."llama-4-maverick".features] -tools = true -vision = false -reasoning = false +api_model = "meta-llama/llama-4-maverick" +limits = { context_tokens = 1000000, max_output_tokens = 16384 } +capabilities = { text = true, tools = true } ``` ## Use OpenRouter models @@ -112,7 +105,7 @@ digraph Example { ## Cost telemetry -Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as `cost_usd` with `cost_source = "authoritative"` on completion responses. Other providers populate the same fields from catalog price estimates with `cost_source = "estimated"`. +Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as the response `cost` with source `provider`. Other providers populate the same field from catalog price estimates with source `catalog`. The catalog prices on OpenRouter model rows are best-effort estimates used only before the authoritative figure arrives (for example, mid-stream rollups). @@ -137,7 +130,7 @@ OpenRouter's [provider routing preferences](https://openrouter.ai/docs/guides/ro Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `X-Title`) by default, so self-hosted installations stay anonymous on OpenRouter's public app leaderboard. Workflow runs do send `x-session-id: ` for request grouping; an explicit provider `extra_headers` value for that header takes precedence. To opt in to attribution: ```toml title="settings.toml" -[llm.providers.openrouter.extra_headers] +[llm.providers.openrouter.metadata.fabro.extra_headers] "HTTP-Referer" = "https://your-site.example" "X-Title" = "Your App" ``` @@ -150,7 +143,7 @@ Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, ` **402 / insufficient credits** — Paid OpenRouter models require prepaid credit; check your balance at [openrouter.ai/credits](https://openrouter.ai/credits). -**Unknown model** — Confirm the model's `api_id` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model `. +**Unknown model** — Confirm the model's `api_model` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model `. ## Further reading diff --git a/docs/public/integrations/poolside.mdx b/docs/public/integrations/poolside.mdx index 7a2fd0627..36f34fb9d 100644 --- a/docs/public/integrations/poolside.mdx +++ b/docs/public/integrations/poolside.mdx @@ -97,7 +97,7 @@ For direct API or SDK requests, disable thinking through `provider_options.pools Enable OpenRouter and configure its separate API key as described in the [OpenRouter integration](/integrations/openrouter): ```toml title="settings.toml" -[llm.providers.openrouter] +[llm.providers.openrouter.metadata.fabro] enabled = true ``` diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index a24a8b389..c5a0ff1d4 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -698,7 +698,7 @@ fabro model test [OPTIONS] | `-j, --jobs ` | Number of model tests to run concurrently in bulk mode
Default: `4` | | `-m, --model ` | Test a specific model | | `-p, --provider ` | Filter by provider | -| `--reasoning-effort ` | Request a reasoning-effort level
Values: `low`, `medium`, `high`, `xhigh`, `max` | +| `--reasoning-effort ` | Request a reasoning-effort level (minimal, low, medium, high, xhigh, max) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--tools` | Run a multi-turn tool-use test | diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 12dee0001..40536e052 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -19,30 +19,41 @@ The `fabro-agent` crate provides a session-based AI agent that runs an LLM with fabro-auth = { git = "https://github.com/fabro-sh/fabro" } fabro-agent = { git = "https://github.com/fabro-sh/fabro" } fabro-llm = { git = "https://github.com/fabro-sh/fabro" } -fabro-model = { git = "https://github.com/fabro-sh/fabro" } +fabro-types = { git = "https://github.com/fabro-sh/fabro" } tokio = { version = "1", features = ["full"] } ``` ### Quick start ```rust -use fabro_agent::{ - AnthropicProfile, LocalSandbox, Session, SessionOptions, -}; -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; use std::path::PathBuf; use std::sync::Arc; +use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions}; +use fabro_auth::EnvCredentialSource; +use fabro_llm::ClientOptions; +use fabro_types::{AgentProfileKind, provider_ids}; + #[tokio::main] async fn main() -> Result<(), Box> { - let source = EnvCredentialSource::new(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); - let client = Client::from_source(&source, Arc::clone(&catalog)).await?; + let catalog = Arc::new(fabro_llm::default_catalog()); + let client = fabro_llm::build_client( + (*catalog).clone(), + Arc::new(EnvCredentialSource::new()), + ClientOptions::standard(), + ) + .await? + .client; let sandbox = Arc::new(LocalSandbox::new(PathBuf::from("."))); - let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-5")); + let profile: Arc = Arc::from( + AgentProfileBuilder::new( + AgentProfileKind::Anthropic, + provider_ids::anthropic(), + "claude-sonnet-4.5", + Arc::clone(&catalog), + ) + .build(), + ); let config = SessionOptions::default(); let mut session = Session::new(client, profile, sandbox, config); @@ -181,7 +192,7 @@ pub trait AgentProfile: Send + Sync { } ``` -Built-in profiles: `AnthropicProfile`, `OpenAiProfile`, `GeminiProfile`. +Profiles are built with `AgentProfileBuilder::new(kind, provider, model, catalog)`. The `AgentProfileKind` values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; the catalog's `metadata.fabro.agent_profile` picks one per provider or model. ### Events @@ -289,7 +300,7 @@ All fallible `Session` methods return `Result`: | Variant | Description | |---|---| -| `Llm(SdkError)` | An error from the LLM provider (wraps `fabro_llm::error::SdkError`). | +| `Llm(LlmError)` | An error from the LLM provider (the stored form of a lithos `Error`). | | `SessionClosed` | `process_input` was called on a closed session. | | `InvalidState(String)` | The session is in an unexpected state. | | `ToolExecution(String)` | A tool execution failed. | @@ -299,410 +310,110 @@ All fallible `Session` methods return `Result`: ## LLM client (`fabro-llm`) -The `fabro-llm` crate is a standalone Rust library for calling LLM providers. It provides a unified client that routes requests to Anthropic, OpenAI, Gemini, and other providers, with built-in streaming, tool execution, retries, and middleware. +The `fabro-llm` crate is Fabro's integration layer over [lithos-llm](https://docs.rs/lithos-llm), a provider-neutral LLM catalog and client. lithos owns the request and response vocabulary, the provider catalog, the wire codecs, streaming, and retries. `fabro-llm` adds what Fabro needs on top: building the catalog from lithos built-ins plus Fabro policy and the operator `[llm]` overlay, constructing a client from a Fabro credential source, inlining local file attachments, normalizing reasoning output, one-shot structured output, model probes, and the `fabro exec` server gateway adapter. -You can use it independently of Fabro's workflow engine — add it as a dependency in any Rust project. +Everything below the Fabro layer is the lithos API. `fabro_llm` re-exports the pieces Fabro code touches most: `Client`, `Request`, `Response`, `StreamEvent`, `Error`, `ErrorKind`, `FinishReason`, and the `lithos_catalog`, `types`, `middleware`, `adapter`, and `credentials` modules. See the lithos-llm README for the full client, middleware, and streaming contract. ```toml title="Cargo.toml" [dependencies] fabro-auth = { git = "https://github.com/fabro-sh/fabro" } fabro-llm = { git = "https://github.com/fabro-sh/fabro" } -fabro-model = { git = "https://github.com/fabro-sh/fabro" } +fabro-types = { git = "https://github.com/fabro-sh/fabro" } tokio = { version = "1", features = ["full"] } serde_json = "1" ``` ### Quick start -The simplest path is an environment-backed `CredentialSource`, an explicit `Arc`, then `Client::from_source(&source, catalog)`. That keeps credential and model resolution explicit while still auto-reading environment variables such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY`. +Build a catalog, build a client over a credential source, then send a lithos `Request`. `EnvCredentialSource` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment. ```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{generate, GenerateParams}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; use std::sync::Arc; +use fabro_auth::EnvCredentialSource; +use fabro_llm::{ClientOptions, Request}; + #[tokio::main] async fn main() -> Result<(), Box> { - let source = EnvCredentialSource::new(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); - let client = Client::from_source(&source, Arc::clone(&catalog)).await?; + let catalog = fabro_llm::default_catalog(); + let built = fabro_llm::build_client( + catalog, + Arc::new(EnvCredentialSource::new()), + ClientOptions::standard(), + ) + .await?; + for issue in &built.build_issues { + eprintln!("provider {} is unavailable: {}", issue.provider, issue.cause); + } + let client = built.client; - let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Explain ownership in Rust in two sentences.") - ).await?; + let request = Request::builder() + .model("claude-sonnet-4.5") + .user("Explain ownership in Rust in two sentences.") + .build()?; + let response = client.complete(request).await?; - println!("{}", result.text()); - println!("Tokens used: {}", result.total_usage.total_tokens); + println!("{}", response.text()); + println!("Tokens used: {}", response.usage.input + response.usage.billable_output()); Ok(()) } ``` +### Catalog + +`fabro_llm::default_catalog()` is the lithos built-in catalog with Fabro's policy layer applied. `fabro_llm::build_catalog(&overlay, &env_lookup)` adds an operator `[llm]` overlay on top, the same layering the server and CLI use. `fabro_config::load_llm_overlay(None)` reads that overlay from the active settings file. + +```rust +use fabro_config::load_llm_overlay; + +let overlay = load_llm_overlay(None)?; +let catalog = fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())?; +``` + +The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_providers`, `models`, `model_on_provider`, `default_model`, `probe_model`, `small_default_for_ready`, and `agent_profile`. Disabled providers and models are invisible to every query. `fabro_llm::selection` chooses a provider and model before a request exists, the way run creation and validation do: a known selector resolves to its canonical offering, `provider/model` pins the provider, and an unknown selector on a passthrough provider passes through verbatim. + ### Client -`Client` is the core type that holds provider adapters and middleware. It routes each request to the appropriate provider. +`fabro_llm::build_client(catalog, source, options)` returns a `FabroClient`: the lithos `Client`, the providers that are ready, the providers whose credentials could not be used, and the providers lithos could not build an adapter for. Credentials are read from the `CredentialSource` on every provider attempt, so a refreshed OAuth token is picked up without rebuilding the client. -#### Creating from a credential source +`ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server. + +Credential sources live in `fabro-auth`: `EnvCredentialSource` for the process environment, `VaultCredentialSource` for a Fabro vault with optional environment fallback, and `SqlVaultCredentialSource` for the server's secret store. Fabro looks up a provider's secret through the `metadata.fabro.credentials` refs on its catalog entry. + +#### Requests and responses + +`Request::builder()` is the lithos request builder. `model` takes a `provider/model` route, a model id or alias, or a provider id. `system`, `user`, and `message` add messages; `tool`, `tool_choice`, `response_format`, `max_output_tokens`, `temperature`, `reasoning_effort`, and `speed` set controls. `client.complete(request)` returns a `Response` whose `content` is a list of `ContentPart` values, with `text()` and `tool_calls()` helpers, plus `finish_reason`, `usage`, and `cost`. ```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use std::sync::Arc; +use fabro_llm::Request; +use fabro_types::{Message, Role}; -let source = EnvCredentialSource::new(); -let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); -let client = Client::from_source(&source, Arc::clone(&catalog)).await?; -``` +let request = Request::builder() + .model("openai/gpt-5.4") + .system("You are a helpful assistant.") + .message(Message::text(Role::User, "What is the capital of France?")) + .temperature(0.0) + .build()?; -For env-backed usage, `EnvCredentialSource` checks for API key environment variables and registers adapters for each provider found: - -| Environment variable | Provider | -|---|---| -| `ANTHROPIC_API_KEY` | Anthropic | -| `OPENAI_API_KEY` | OpenAI | -| `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini | -| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence | -| `ZAI_API_KEY` | ZAI | -| `MINIMAX_API_KEY` | Minimax | -| `INCEPTION_API_KEY` | Inception | -| `POOLSIDE_API_KEY` | Poolside | -| `DEEPSEEK_API_KEY` | DeepSeek | -| `OPENROUTER_API_KEY` | OpenRouter, when enabled in settings | - -The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead. - -The built-in Modal definition reads two proxy-token headers from the vault, so `EnvCredentialSource` does not configure it automatically. For direct SDK use, enable Modal and set its endpoint URL in the catalog: - -```toml -[llm.providers.modal] -enabled = true -base_url = "https://your-endpoint.modal.run/v1" -``` - -Then read the two environment variables explicitly and create a typed credential after constructing `catalog` from those settings: - -```rust -use fabro_auth::ApiCredential; -use fabro_llm::client::Client; -use std::collections::HashMap; - -let credential = ApiCredential::with_extra_headers( - "modal", - HashMap::from([ - ("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?), - ( - "Modal-Secret".to_string(), - std::env::var("MODAL_TOKEN_SECRET")?, - ), - ]), -); -let client = Client::from_credentials(vec![credential], catalog).await?; -``` - -#### Creating manually - -```rust -use fabro_llm::client::Client; -use fabro_llm::providers::AnthropicAdapter; -use std::collections::HashMap; -use std::sync::Arc; - -let adapter = AnthropicAdapter::new("sk-ant-...") - .with_base_url("https://custom-proxy.example.com"); - -let mut providers = HashMap::new(); -providers.insert("anthropic".to_string(), Arc::new(adapter) as _); - -let client = Client::new(providers, Some("anthropic".to_string()), vec![]); -``` - -#### Low-level calls - -For direct control without the tool loop, use `complete()` and `stream()` on the client: - -```rust -use fabro_llm::types::{Request, Message}; - -let request = Request { - model: "claude-sonnet-4-5".into(), - messages: vec![Message::user("Hello")], - ..Default::default() -}; - -let response = client.complete(&request).await?; +let response = client.complete(request).await?; println!("{}", response.text()); ``` -### High-level generation - -The `generate()` function wraps the client with automatic tool execution loops, retries, and timeouts. It is the recommended entry point for most use cases. - -#### Basic completion - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{generate, GenerateParams}; - -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .system("You are a helpful assistant.") - .prompt("What is the capital of France?") - .temperature(0.0) -).await?; - -println!("{}", result.text()); -``` - -#### Multi-turn conversations - -Use `.messages()` instead of `.prompt()` to pass a full conversation history: - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::types::Message; - -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .messages(vec![ - Message::user("My name is Alice."), - Message::assistant("Hello Alice! How can I help you?"), - Message::user("What's my name?"), - ]) -).await?; -``` - - -You cannot use both `.prompt()` and `.messages()` on the same request — this returns `SdkError::Configuration`. - - -#### GenerateParams reference - -| Method | Type | Description | -|---|---|---| -| `new(model, client)` | `(impl Into, Arc)` | Required. Model ID or alias plus the client to use | -| `.prompt(text)` | `impl Into` | Convenience: sends a single user message | -| `.messages(msgs)` | `Vec` | Full conversation history | -| `.system(text)` | `impl Into` | System prompt | -| `.tools(tools)` | `Vec` | Tools available to the model | -| `.tool_choice(choice)` | `ToolChoice` | How the model selects tools | -| `.max_tool_rounds(n)` | `u32` | Max tool execution rounds (default: 1) | -| `.temperature(t)` | `f64` | Sampling temperature | -| `.top_p(p)` | `f64` | Nucleus sampling | -| `.max_tokens(n)` | `i64` | Maximum output tokens | -| `.stop_sequences(seqs)` | `Vec` | Stop sequences | -| `.reasoning_effort(level)` | `impl Into` | e.g. `"low"`, `"medium"`, `"high"` | -| `.provider(name)` | `impl Into` | Force a specific provider | -| `.max_retries(n)` | `u32` | Retry count for transient errors (default: 2) | -| `.timeout(config)` | `TimeoutConfig` | Total and per-step timeouts | -| `.abort_signal(token)` | `CancellationToken` | Cancel generation | -| `.stop_when(f)` | `Fn(&[StepResult]) -> bool` | Custom stop condition after each tool round | - -#### GenerateResult - -`GenerateResult` dereferences to `Response`, so you can call response methods directly: - -```rust -let result = generate(params).await?; - -// Response methods (via Deref) -result.text(); // concatenated text output -result.tool_calls(); // Vec from the final response -result.reasoning(); // Option — extended thinking content - -// GenerateResult fields -result.response; // Response — the final LLM response -result.tool_results; // Vec — from the final step -result.total_usage; // Usage — aggregated across all steps -result.steps; // Vec — one per tool round -result.output; // Option — for structured output -``` - -### Tools - -Tools let the model call functions during generation. There are two kinds: - -- **Active tools** have an execute handler — Fabro runs them automatically and feeds results back to the model. -- **Passive tools** have no handler — Fabro returns the tool calls to you in the response. - -#### Defining an active tool - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::tools::Tool; -use serde_json::json; - -let weather = Tool::active( - "get_weather", - "Get the current weather for a city", - json!({ - "type": "object", - "properties": { - "city": { "type": "string", "description": "City name" } - }, - "required": ["city"] - }), - |args, _ctx| async move { - let city = args["city"].as_str().unwrap_or("unknown"); - Ok(json!({ "temperature": "72°F", "city": city })) - }, -); -``` - -#### Using tools with generate - -```rust -# use fabro_auth::EnvCredentialSource; -# use fabro_llm::client::Client; -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("What's the weather in San Francisco?") - .tools(vec![weather]) - .max_tool_rounds(5) -).await?; - -// Inspect the tool execution history -for (i, step) in result.steps.iter().enumerate() { - let calls = step.response.tool_calls(); - println!("Step {i}: {} tool calls, {} results", calls.len(), step.tool_results.len()); -} -``` - -The `generate()` function loops automatically: the model calls tools, Fabro executes them, feeds results back, and repeats until the model stops or `max_tool_rounds` is reached. - -#### Tool choice - -Control how the model selects tools: - -```rust -use fabro_llm::types::ToolChoice; - -// Let the model decide (default) -# use fabro_auth::EnvCredentialSource; -# use fabro_llm::client::Client; -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::Auto); - -// Force a specific tool -GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::Named { - tool_name: "get_weather".into() -}); - -// Force the model to use some tool -GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::Required); - -// Prevent tool use -GenerateParams::new("opus", client.clone()).tool_choice(ToolChoice::None); -``` - -#### Passive tools - -Passive tools let you handle execution yourself: - -```rust -# use fabro_auth::EnvCredentialSource; -# use fabro_llm::client::Client; -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let search = Tool::passive( - "search", - "Search the codebase", - json!({ - "type": "object", - "properties": { - "query": { "type": "string" } - }, - "required": ["query"] - }), -); - -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Find all uses of the Config struct") - .tools(vec![search]) -).await?; - -// Handle tool calls yourself -for call in result.tool_calls() { - println!("Model wants to call {} with {}", call.name, call.arguments); -} -``` +There is no tool-execution loop in `fabro-llm`. The agent loop lives in `fabro-agent`, which decides when to run a tool and feeds results back as `Role::Tool` messages. ### Streaming -#### Text stream - -For simple cases where you only need the text deltas: +`client.stream(request)` returns a lithos `ResponseStream`, a `Stream` of `StreamEvent` values. Events are discriminated by `type` on the wire: `started`, `content_block_start`, `text_delta`, `reasoning_delta`, `tool_call_delta`, `content_block_end`, `usage`, `rate_limits`, and `ended`, which carries the complete `Response`. ```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{stream, GenerateParams}; +use fabro_llm::StreamEvent; use futures::StreamExt; -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let stream_result = stream( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Write a haiku about Rust") -).await?; - -let mut text_stream = stream_result.text_stream(); -while let Some(chunk) = text_stream.next().await { - print!("{}", chunk?); -} -``` - -#### Full event stream - -For fine-grained control, consume `StreamEvent` variants directly: - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{stream, GenerateParams}; -use fabro_llm::types::StreamEvent; -use futures::StreamExt; - -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let mut stream_result = stream( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Explain monads") -).await?; - -while let Some(event) = stream_result.next().await { +let mut stream = client.stream(request).await?; +while let Some(event) = stream.next().await { match event? { - StreamEvent::TextDelta { delta, .. } => print!("{delta}"), - StreamEvent::ReasoningDelta { delta } => eprint!("[thinking] {delta}"), - StreamEvent::ToolCallStart { tool_call } => { - println!("\n> Calling tool: {}", tool_call.name); - } - StreamEvent::StepFinish { usage, .. } => { - println!("\n[step done, {} tokens]", usage.total_tokens); - } - StreamEvent::Finish { response, .. } => { + StreamEvent::TextDelta { text, .. } => print!("{text}"), + StreamEvent::Ended { response } => { println!("\n[done: {:?}]", response.finish_reason); } _ => {} @@ -710,282 +421,95 @@ while let Some(event) = stream_result.next().await { } ``` -#### StreamEvent variants - -| Variant | Description | -|---|---| -| `StreamStart` | Stream opened | -| `TextStart { text_id }` | Text block started | -| `TextDelta { delta, text_id }` | Incremental text chunk | -| `TextEnd { text_id }` | Text block ended | -| `ReasoningStart` | Extended thinking started | -| `ReasoningDelta { delta }` | Incremental reasoning chunk | -| `ReasoningEnd` | Extended thinking ended | -| `ToolCallStart { tool_call }` | Tool call started | -| `ToolCallDelta { tool_call }` | Incremental tool call arguments | -| `ToolCallEnd { tool_call }` | Tool call complete | -| `StepFinish { finish_reason, usage, response, tool_calls, tool_results }` | A tool round completed (more rounds may follow) | -| `Finish { finish_reason, usage, response }` | Generation complete | -| `Error { error, raw }` | Provider error | +A turn that ends with `FinishReason::Length` or `FinishReason::Incomplete` is not complete. Tool calls from such a turn arrive in `response.suppressed_tool_calls` and must not be executed. `fabro-agent` treats both as a retryable failure of the turn. ### Structured output -Generate typed JSON objects that conform to a JSON Schema: +`fabro_llm::structured::complete_object` attaches a JSON Schema as the request's response format and parses the reply: ```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{generate_object, GenerateParams}; +use fabro_llm::{Request, structured}; use serde_json::json; -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; let schema = json!({ "type": "object", "properties": { "name": { "type": "string" }, - "age": { "type": "integer" }, - "hobbies": { - "type": "array", - "items": { "type": "string" } - } + "age": { "type": "integer" } }, - "required": ["name", "age", "hobbies"] + "required": ["name", "age"] }); -let result = generate_object( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Generate a profile for a fictional character"), - schema, -).await?; - -let profile = result.output.expect("structured output"); -println!("Name: {}", profile["name"]); +let request = Request::builder() + .model("claude-sonnet-4.5") + .user("Generate a profile for a fictional character") + .build()?; +let completion = structured::complete_object(&client, request, "profile", schema).await?; +println!("Name: {}", completion.object["name"]); ``` +### Reasoning + +`fabro_llm::reasoning::normalize(&response.content)` folds a response's readable reasoning parts into a `fabro_types::ReasoningOutput` with a summary and a trace. Provider replay data such as signatures and encrypted reasoning never appears in it. + ### Middleware -Middleware intercepts requests and responses for logging, caching, or transformation: - -```rust -use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{Request, Response}; -use fabro_llm::error::SdkError; -use async_trait::async_trait; - -struct LoggingMiddleware; - -#[async_trait] -impl Middleware for LoggingMiddleware { - async fn handle_complete( - &self, - request: Request, - next: NextFn, - ) -> Result { - println!("Request to model: {}", request.model); - let response = next(request).await?; - println!("Response: {} tokens", response.usage.total_tokens); - Ok(response) - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - println!("Streaming request to model: {}", request.model); - next(request).await - } -} -``` - -Add middleware to the client: - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; - -let source = EnvCredentialSource::new(); -let catalog = std::sync::Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); -let mut client = Client::from_source(&source, catalog).await?; -client.add_middleware(std::sync::Arc::new(LoggingMiddleware)); -``` - -### Model catalog - -The crate embeds a catalog of known models with metadata: - -```rust -use fabro_llm::catalog; - -// Look up a model by ID or alias -let info = catalog::get_model_info("opus").unwrap(); -println!("{} ({})", info.display_name, info.provider); -println!("Context: {} tokens", info.limits.context_window); -println!("Tools: {}, Vision: {}", info.features.tools, info.features.vision); - -// List all models for a provider -let models = catalog::list_models(Some("anthropic")); - -// Get the default model for a provider -let default = catalog::default_model_for_provider("openai").unwrap(); - -// Find a capability-matched model on a different provider -let equivalent = catalog::closest_model("gemini", &info); -``` - -See [Models](/core-concepts/models) for the full catalog table. +Middleware is the lithos `Middleware` trait: `handle(&self, call: Call, next: Next)` sees the resolved route and request and returns an `Output` that is either a complete response or a stream. `fabro_llm::attachments::InlineLocalAttachments` is Fabro's own middleware; it rewrites local file references in messages into inline media before dispatch. ### Error handling -All fallible operations return `Result`. The error type classifies failures to enable retry and failover decisions: +Every fallible operation returns `Result`, the lithos error. `error.kind()` is an `ErrorKind` such as `Authentication`, `RateLimit`, `Server`, `ContextLength`, `ContentFilter`, `Timeout`, `StreamDecode`, or `Cancelled`. `error.data()` is the `ErrorData` snapshot Fabro stores in run events; `fabro_llm::LlmError` wraps it. -```rust -use fabro_llm::error::SdkError; +The `fabro_llm::ErrorFacts` trait is implemented for `Error`, `ErrorData`, and `LlmError`, and the classification helpers take any of them: -match result { - Err(SdkError::Provider { kind, detail }) => { - println!("Provider error ({}): {}", detail.provider, detail.message); - if let Some(code) = detail.status_code { - println!("HTTP {code}"); - } - } - Err(SdkError::RequestTimeout { message, .. }) => println!("Timeout: {message}"), - Err(SdkError::Network { message, .. }) => println!("Network: {message}"), - Err(SdkError::Interrupt { message }) => println!("Cancelled: {message}"), - Err(e) => println!("Other: {e}"), - Ok(_) => {} -} -``` - -#### Error classification - -Every `SdkError` exposes classification methods: - -| Method | Returns | Description | -|---|---|---| -| `retryable()` | `bool` | Safe to retry with the same provider (e.g. rate limit, server error) | -| `failover_eligible()` | `bool` | Safe to try a different provider | -| `retry_after()` | `Option` | Seconds to wait before retrying (from provider `Retry-After` header) | -| `status_code()` | `Option` | HTTP status code, if applicable | -| `provider_name()` | `&str` | Which provider returned the error | - -#### Provider error kinds - -| Kind | HTTP status | Retryable | Failover | -|---|---|---|---| -| `Authentication` | 401 | No | No | -| `AccessDenied` | 403 | No | No | -| `NotFound` | 404 | No | No | -| `InvalidRequest` | 400 | No | No | -| `RateLimit` | 429 | Yes | Yes | -| `Server` | 500, 502, 503 | Yes | Yes | -| `ContentFilter` | varies | No | No | -| `ContextLength` | varies | No | No | -| `QuotaExceeded` | varies | No | Yes | +| Function | Description | +|---|---| +| `is_retryable(&error)` | Safe to retry with the same provider, from lithos's retry classification | +| `failover_eligible(&error)` | Safe to try a different provider | +| `is_auth_error(&error)` | The credential was missing or rejected | +| `is_cancelled(&error)` | The caller cancelled the call | +| `failure_signature_hint(&error)` | A stable string for loop and restart detection | ### Retries -The `generate()` function retries automatically based on `max_retries` (default: 2). For low-level use, the `retry` function wraps any async operation: - -```rust -use fabro_llm::retry::retry; -use fabro_llm::types::RetryPolicy; - -let policy = RetryPolicy { - max_retries: 3, - base_delay: 1.0, - max_delay: 60.0, - backoff_multiplier: 2.0, - jitter: true, - on_retry: None, -}; - -let response = retry(&policy, || { - let c = client.clone(); - let r = request.clone(); - async move { c.complete(&r).await } -}).await?; -``` - -Retry only fires when `error.retryable()` returns `true` and respects `Retry-After` headers. +The lithos `RetryMiddleware` installed by `ClientOptions::standard()` retries a request until its stream delivers visible output. After visible output the client never replays on its own; `fabro-agent` decides whether to replay a turn using `RetryPolicy::next_delay`, the same decision the middleware uses. Insert a `fabro_llm::RetryListener` into a call's context extensions to be told about each retry the middleware performs. ### Cancellation -Pass a `CancellationToken` to interrupt long-running generation: +Pass a `CallContext` with a cancellation token through `complete_with_context` or `stream_with_context`. Cancelling the token ends the call with `ErrorKind::Cancelled`. ```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use tokio_util::sync::CancellationToken; +use fabro_llm::CallContext; -# let source = EnvCredentialSource::new(); -# let catalog = std::sync::Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&fabro_model::catalog::LlmCatalogSettings::default()).unwrap()); -# let client = Client::from_source(&source, catalog).await?; -let token = CancellationToken::new(); -let token_clone = token.clone(); - -// Cancel after 30 seconds +let context = CallContext::new(); +let cancel = context.cancellation().clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(30)).await; - token_clone.cancel(); + cancel.cancel(); }); - -let result = generate( - GenerateParams::new("opus", client.clone()) - .prompt("Write a novel") - .abort_signal(token) -).await; -// Returns SdkError::Interrupt if cancelled +let result = client.complete_with_context(request, context).await; ``` +### Probes + +`fabro_llm::probe::run_model_test(&client, "provider/model", mode, reasoning_effort, timeout)` sends the lithos model probe: one word in `Basic` mode, a two-step tool exchange in `Deep` mode. `probe_provider_with_api_key` validates an operator-supplied key against a provider's probe model before it is stored. + ### Provider adapters -Each provider has a dedicated adapter. All adapters implement the `ProviderAdapter` trait and are interchangeable. +Providers are lithos adapters selected by the catalog `adapter` id: `anthropic`, `openai`, `gemini`, `openai-compatible`, and `bedrock`. A new OpenAI-compatible endpoint needs a catalog entry, not code. -| Adapter | Provider | Constructor | -|---|---|---| -| `AnthropicAdapter` | Anthropic Messages API | `::new(api_key)` | -| `OpenAiAdapter` | OpenAI Responses API | `::new(api_key)` | -| `GeminiAdapter` | Google Gemini API | `::new(api_key)` | -| `OpenAiCompatibleAdapter` | Any OpenAI-compatible endpoint | `::new(api_key, base_url)` | - -All adapters support `.with_base_url()` for proxies or custom endpoints. `OpenAiAdapter` also supports `.with_org_id()` and `.with_project_id()`. - -#### Custom provider - -Implement the `ProviderAdapter` trait to add a new provider: +To add a custom transport, implement the lithos `ProviderAdapter` trait and register it with `ClientOptions::with_adapter`. `fabro_llm::gateway::GatewayAdapter` is Fabro's own example: it posts each request to a Fabro server's completions endpoint, which returns lithos `Response` JSON and streams lithos `StreamEvent` JSON verbatim. ```rust -use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; -use fabro_llm::types::{Request, Response}; -use fabro_llm::error::SdkError; -use async_trait::async_trait; +use std::sync::Arc; -struct MyProvider; +use fabro_llm::ClientOptions; +use fabro_llm::gateway::GatewayAdapter; +use fabro_types::ProviderId; -#[async_trait] -impl ProviderAdapter for MyProvider { - fn name(&self) -> &str { "my-provider" } - - async fn complete(&self, request: &Request) -> Result { - // Call your provider's API - todo!() - } - - async fn stream(&self, request: &Request) -> Result { - // Return a stream of events - todo!() - } -} -``` - -Register it on the client: - -```rust -client.register_provider(Arc::new(MyProvider)).await?; +let adapter = Arc::new(GatewayAdapter::new(Box::new(my_transport))); +let built = fabro_llm::build_offline_client( + catalog, + ClientOptions::default().with_adapter(ProviderId::new("anthropic"), adapter), +)?; ``` diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index 853f6a549..28a1e4cc4 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -35,7 +35,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver |---|---| | CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` | | Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` | -| Shared LLM catalog | `[llm.providers.]`, provider-scoped `[llm.providers..models.]` offerings, limits, features, controls, and costs | +| Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.]`, `[llm.providers..models.]`, and Fabro policy under `metadata.fabro` | | Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | `[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert. @@ -89,36 +89,29 @@ level = "info" [llm.providers.proxy] display_name = "Acme Gateway" -adapter = "openai_compatible" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://llm-gateway.example.com/v1" +auth = { type = "bearer" } aliases = ["gateway"] +default_model = "team-code-large" -[llm.providers.proxy.auth] +[llm.providers.proxy.metadata.fabro] +agent_profile = "anthropic" credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] -[llm.providers.proxy.extra_headers] +[llm.providers.proxy.metadata.fabro.extra_headers] x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" [llm.providers.proxy.models."team-code-large"] -api_id = "provider-wire-model-name" -agent_profile = "anthropic" display_name = "Team Code Large" -default = true aliases = ["team-code"] - -[llm.providers.proxy.models."team-code-large".controls] -reasoning_effort = ["low", "medium", "high"] -speed = ["fast"] - -[llm.providers.proxy.models."team-code-large".costs] -input_cost_per_mtok = 1.50 -output_cost_per_mtok = 8.00 - -[llm.providers.proxy.models."team-code-large".costs.speed.fast] -input_cost_per_mtok = 3.00 -output_cost_per_mtok = 16.00 - +api_model = "provider-wire-model-name" +limits = { context_tokens = 200000, max_output_tokens = 32000 } +capabilities = { text = true, tools = true, reasoning = true, reasoning_effort = { low = true, medium = true, high = true } } +protocol_options = { reasoning_effort_levels = true } +pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000 } ``` All fields are optional. Include only the sections and keys you want to override. A single file can still include both CLI and server sections when you run both processes on one machine, but explicit remote targets do not read remote server state from the local machine. @@ -147,146 +140,114 @@ url = "https://fabro.example.com/api/v1" | `url` | string | None | Required for `type = "http"`; the API base URL. | | `path` | string | None | Required for `type = "unix"`; the absolute Unix socket path. | -## `[llm.providers.]` +## `[llm]` -Define or override an LLM provider. Provider IDs are strings, so custom -providers can be added when they use an adapter Fabro already supports. +The `[llm]` table is a [lithos-llm](https://docs.rs/lithos-llm) catalog +overlay. Fabro builds its model catalog from three layers: the lithos built-in +providers and models, Fabro's policy layer, and this table. Later layers win; +tables merge key by key and every other value replaces. Fabro does not +interpret the table itself. lithos validates it when the catalog is built, and +rejects unknown provider or model fields. + +Fabro-specific policy lives under `metadata.fabro` on a provider or model. +lithos carries that namespace verbatim. ```toml title="settings.toml" [llm.providers.proxy] display_name = "Acme Gateway" -adapter = "openai_compatible" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://llm-gateway.example.com/v1" +auth = { type = "bearer" } priority = 50 -enabled = true aliases = ["gateway"] +default_model = "team-code-large" -[llm.providers.proxy.auth] +[llm.providers.proxy.metadata.fabro] +enabled = true +agent_profile = "anthropic" credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] -[llm.providers.proxy.extra_headers] -x-portkey-api-key = "{{ secrets.portkey_api_key }}" +[llm.providers.proxy.metadata.fabro.extra_headers] +x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" -x-team-secret = "{{ secrets.gateway_team_secret }}" + +[llm.providers.proxy.models."team-code-large"] +display_name = "Team Code Large" +aliases = ["team-code"] +api_model = "provider-wire-model-name" +limits = { context_tokens = 200000, max_output_tokens = 32000 } +capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } } +protocol_options = { reasoning_effort_levels = true } +pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 } + +[llm.providers.proxy.models."team-code-large".metadata.fabro] +family = "team-code" +small_default = true +estimated_output_tps = 80 ``` +## `[llm.providers.]` + +Define or override an LLM provider. The keys are the lithos provider record. + | Key | Type / values | Default | Description | |---|---|---|---| -| `display_name` | string | provider ID | Human-readable provider name. | -| `adapter` | string | built-in value | Adapter registry key, such as `"anthropic"`, `"openai"`, `"gemini"`, or `"openai_compatible"`. Required for new providers. | -| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | derived from `adapter` | Agent profile used for project memory, CLI/ACP command selection, and native session routing. Override only when a provider needs profile behavior different from its adapter. | -| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. | -| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. | -| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. | -| `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 literal text or `{{ secrets.NAME }}` interpolation strings. Put credentials in a secret and reference them with a token, not a bare literal. | -| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. | -| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. | +| `display_name` | string | required for new providers | Human-readable provider name. | +| `adapter` | string | required for new providers | lithos adapter id: `anthropic`, `openai`, `gemini`, `openai-compatible`, or `bedrock`. | +| `codec` | string | required for new providers | Wire codec: `anthropic-messages`, `openai-responses`, `openai-chat`, `gemini-generate`, or `bedrock-converse`. | +| `base_url` | string | required for new providers | Provider API base URL. The `openai-compatible` adapter appends `/v1/chat/completions` unless the URL already ends in a version segment. | +| `auth` | table | required for new providers | Auth scheme: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }`, `{ type = "none" }`, or `{ type = "aws" }`. | +| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection. | | `aliases` | array | `[]` | Additional provider names accepted by model routing and fallback config. | +| `default_model` | string | None | The provider's default model id. | +| `allow_passthrough` | boolean | `false` | Whether `provider/model` selectors may name models the catalog does not list. | +| `default_headers` | table | `{}` | Literal headers attached to every request. Secret-bearing headers belong in `metadata.fabro.extra_headers`. | -## `[llm.providers..models.]` +## `[llm.providers..metadata.fabro]` + +Fabro's provider policy. Every key is optional. + +| Key | Type / values | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `true` | Set `false` to hide a provider from Fabro. Several built-in providers ship disabled. | +| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` \| `"kimi"` \| `"gpt56"` | derived from `adapter` | Agent profile for models on this provider. | +| `api_key_url` | string | None | Where an operator obtains an API key. | +| `credentials` | array | `[]` | Ordered credential refs: `vault:`, `env:`, or `aws_sigv4`. The first that resolves wins. | +| `extra_headers` | table | `{}` | Extra request headers. Values are literal text or `{{ secrets.NAME }}` interpolation strings resolved against the vault. | + +## `[llm.providers..models.]` Define or override one provider's offering of a model. The table key is the -canonical model slug Fabro users reference. An offering's identity is the -pair `(provider, model slug)`, so different providers may use the same slug -and aliases. `api_id` is the opaque model string sent to this provider's API -and defaults to the exact model slug. - -```toml title="settings.toml" -[llm.providers.proxy.models."team-code-large"] -api_id = "provider-wire-model-name" -agent_profile = "anthropic" -display_name = "Team Code Large" -family = "team-code" -default = true -probe = true -enabled = true -aliases = ["team-code"] -estimated_output_tps = 80 - -[llm.providers.proxy.models."team-code-large".limits] -context_window = 200000 -max_output = 32000 - -[llm.providers.proxy.models."team-code-large".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[llm.providers.proxy.models."team-code-large".controls] -reasoning_effort = ["low", "medium", "high"] -speed = ["fast"] - -[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.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 -``` +model id Fabro users reference. An offering's identity is the pair +`(provider, model id)`, so different providers may use the same id and +aliases. `api_model` is the string sent to the provider and defaults to the id. | Key | Type / values | Default | Description | |---|---|---|---| -| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. | -| `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 | required for new models | Human-readable model name. | +| `aliases` | array | `[]` | Additional selectors. Aliases may repeat across providers. | +| `api_model` | string | model id | Wire model identifier sent to this provider. | +| `limits` | `{ context_tokens, max_output_tokens }` | None | Token limits. | +| `capabilities` | table | unknown | Per-capability `true`, `false`, or `"unknown"`: `text`, `images`, `audio`, `documents`, `tools`, `reasoning`, `caching`, `cache_routing`, `sampling`, plus `tool_choice = { required, named }`, `response_format = { json_object, json_schema }`, `reasoning_effort = { minimal, low, medium, high, xhigh, max }`, and `speed = { fast, balanced, economical }`. | +| `protocol_options` | table | `{}` | Encoding flags: `reasoning_effort_levels`, `cache_breakpoints`, `system_turns`. | +| `pricing` | table | None | USD micros per million tokens: `input_usd_micros_per_million`, `output_usd_micros_per_million`, `cached_input_usd_micros_per_million`, `cache_write_usd_micros_per_million`, plus optional `long_context` and `speed` tiers. | + +## `[llm.providers..models..metadata.fabro]` + +Fabro's model policy. Every key is optional. + +| Key | Type / values | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `true` | Set `false` to hide a model from Fabro. | +| `agent_profile` | profile name | provider profile | Agent profile override for this model. | +| `family` | string | model id | Family label for display and grouping. | | `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 selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. | -| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. | - -## `[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.providers..models..features]` - -| Key | Type / values | Default | Description | -|---|---|---|---| -| `tools` | boolean | `false` | Whether the model supports tool calls. | -| `vision` | boolean | `false` | Whether the model accepts image inputs. | -| `reasoning` | boolean | `false` | Whether the model has reasoning behavior. | -| `reasoning_by_default` | boolean | effort-capable models: `true`; other models: `false` | Whether requests reason when no `reasoning_effort` is supplied. Set this explicitly for always-reasoning routes that do not expose an effort control, or for effort-capable routes whose provider defaults reasoning off. | -| `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. | -| `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.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.providers..models..costs]` - -| Key | Type / values | Default | Description | -|---|---|---|---| -| `input_cost_per_mtok` | number | None | Input cost in USD per million tokens. | -| `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.providers..models..costs.speed.]` - -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. +| `knowledge_cutoff` | string | None | Public knowledge cutoff label. | +| `estimated_output_tps` | number | None | Estimated output tokens per second. | +| `small_default` | boolean | `false` | Preferred for small utility calls such as generated run titles. | +| `probe` | boolean | `false` | Preferred for provider connectivity probes. | +| `reasoning_by_default` | boolean | reasoning models with effort levels: `true` | Whether requests reason when no `reasoning_effort` is supplied. | ## `[cli.updates]` diff --git a/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs index 406ec0b49..3fdabda2a 100644 --- a/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/foundation/fabro-dev/src/commands/docs_options_reference.rs @@ -214,146 +214,114 @@ url = "https://fabro.example.com/api/v1" fn render_manual_llm_catalog(output: &mut String) { output.push_str( - r#"## `[llm.providers.]` + r#"## `[llm]` -Define or override an LLM provider. Provider IDs are strings, so custom -providers can be added when they use an adapter Fabro already supports. +The `[llm]` table is a [lithos-llm](https://docs.rs/lithos-llm) catalog +overlay. Fabro builds its model catalog from three layers: the lithos built-in +providers and models, Fabro's policy layer, and this table. Later layers win; +tables merge key by key and every other value replaces. Fabro does not +interpret the table itself. lithos validates it when the catalog is built, and +rejects unknown provider or model fields. + +Fabro-specific policy lives under `metadata.fabro` on a provider or model. +lithos carries that namespace verbatim. ```toml title="settings.toml" [llm.providers.proxy] display_name = "Acme Gateway" -adapter = "openai_compatible" +adapter = "openai-compatible" +codec = "openai-chat" base_url = "https://llm-gateway.example.com/v1" +auth = { type = "bearer" } priority = 50 -enabled = true aliases = ["gateway"] +default_model = "team-code-large" -[llm.providers.proxy.auth] +[llm.providers.proxy.metadata.fabro] +enabled = true +agent_profile = "anthropic" credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] -[llm.providers.proxy.extra_headers] -x-portkey-api-key = "{{ secrets.portkey_api_key }}" +[llm.providers.proxy.metadata.fabro.extra_headers] +x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" -x-team-secret = "{{ secrets.gateway_team_secret }}" + +[llm.providers.proxy.models."team-code-large"] +display_name = "Team Code Large" +aliases = ["team-code"] +api_model = "provider-wire-model-name" +limits = { context_tokens = 200000, max_output_tokens = 32000 } +capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } } +protocol_options = { reasoning_effort_levels = true } +pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 } + +[llm.providers.proxy.models."team-code-large".metadata.fabro] +family = "team-code" +small_default = true +estimated_output_tps = 80 ``` +## `[llm.providers.]` + +Define or override an LLM provider. The keys are the lithos provider record. + | Key | Type / values | Default | Description | |---|---|---|---| -| `display_name` | string | provider ID | Human-readable provider name. | -| `adapter` | string | built-in value | Adapter registry key, such as `"anthropic"`, `"openai"`, `"gemini"`, or `"openai_compatible"`. Required for new providers. | -| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | derived from `adapter` | Agent profile used for project memory, CLI/ACP command selection, and native session routing. Override only when a provider needs profile behavior different from its adapter. | -| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. | -| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. | -| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. | -| `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 literal text or `{{ secrets.NAME }}` interpolation strings. Put credentials in a secret and reference them with a token, not a bare literal. | -| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. | -| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. | +| `display_name` | string | required for new providers | Human-readable provider name. | +| `adapter` | string | required for new providers | lithos adapter id: `anthropic`, `openai`, `gemini`, `openai-compatible`, or `bedrock`. | +| `codec` | string | required for new providers | Wire codec: `anthropic-messages`, `openai-responses`, `openai-chat`, `gemini-generate`, or `bedrock-converse`. | +| `base_url` | string | required for new providers | Provider API base URL. The `openai-compatible` adapter appends `/v1/chat/completions` unless the URL already ends in a version segment. | +| `auth` | table | required for new providers | Auth scheme: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }`, `{ type = "none" }`, or `{ type = "aws" }`. | +| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection. | | `aliases` | array | `[]` | Additional provider names accepted by model routing and fallback config. | +| `default_model` | string | None | The provider's default model id. | +| `allow_passthrough` | boolean | `false` | Whether `provider/model` selectors may name models the catalog does not list. | +| `default_headers` | table | `{}` | Literal headers attached to every request. Secret-bearing headers belong in `metadata.fabro.extra_headers`. | -## `[llm.providers..models.]` +## `[llm.providers..metadata.fabro]` + +Fabro's provider policy. Every key is optional. + +| Key | Type / values | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `true` | Set `false` to hide a provider from Fabro. Several built-in providers ship disabled. | +| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` \| `"kimi"` \| `"gpt56"` | derived from `adapter` | Agent profile for models on this provider. | +| `api_key_url` | string | None | Where an operator obtains an API key. | +| `credentials` | array | `[]` | Ordered credential refs: `vault:`, `env:`, or `aws_sigv4`. The first that resolves wins. | +| `extra_headers` | table | `{}` | Extra request headers. Values are literal text or `{{ secrets.NAME }}` interpolation strings resolved against the vault. | + +## `[llm.providers..models.]` Define or override one provider's offering of a model. The table key is the -canonical model slug Fabro users reference. An offering's identity is the -pair `(provider, model slug)`, so different providers may use the same slug -and aliases. `api_id` is the opaque model string sent to this provider's API -and defaults to the exact model slug. - -```toml title="settings.toml" -[llm.providers.proxy.models."team-code-large"] -api_id = "provider-wire-model-name" -agent_profile = "anthropic" -display_name = "Team Code Large" -family = "team-code" -default = true -probe = true -enabled = true -aliases = ["team-code"] -estimated_output_tps = 80 - -[llm.providers.proxy.models."team-code-large".limits] -context_window = 200000 -max_output = 32000 - -[llm.providers.proxy.models."team-code-large".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[llm.providers.proxy.models."team-code-large".controls] -reasoning_effort = ["low", "medium", "high"] -speed = ["fast"] - -[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.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 -``` +model id Fabro users reference. An offering's identity is the pair +`(provider, model id)`, so different providers may use the same id and +aliases. `api_model` is the string sent to the provider and defaults to the id. | Key | Type / values | Default | Description | |---|---|---|---| -| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. | -| `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 | required for new models | Human-readable model name. | +| `aliases` | array | `[]` | Additional selectors. Aliases may repeat across providers. | +| `api_model` | string | model id | Wire model identifier sent to this provider. | +| `limits` | `{ context_tokens, max_output_tokens }` | None | Token limits. | +| `capabilities` | table | unknown | Per-capability `true`, `false`, or `"unknown"`: `text`, `images`, `audio`, `documents`, `tools`, `reasoning`, `caching`, `cache_routing`, `sampling`, plus `tool_choice = { required, named }`, `response_format = { json_object, json_schema }`, `reasoning_effort = { minimal, low, medium, high, xhigh, max }`, and `speed = { fast, balanced, economical }`. | +| `protocol_options` | table | `{}` | Encoding flags: `reasoning_effort_levels`, `cache_breakpoints`, `system_turns`. | +| `pricing` | table | None | USD micros per million tokens: `input_usd_micros_per_million`, `output_usd_micros_per_million`, `cached_input_usd_micros_per_million`, `cache_write_usd_micros_per_million`, plus optional `long_context` and `speed` tiers. | + +## `[llm.providers..models..metadata.fabro]` + +Fabro's model policy. Every key is optional. + +| Key | Type / values | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `true` | Set `false` to hide a model from Fabro. | +| `agent_profile` | profile name | provider profile | Agent profile override for this model. | +| `family` | string | model id | Family label for display and grouping. | | `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 selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. | -| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. | - -## `[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.providers..models..features]` - -| Key | Type / values | Default | Description | -|---|---|---|---| -| `tools` | boolean | `false` | Whether the model supports tool calls. | -| `vision` | boolean | `false` | Whether the model accepts image inputs. | -| `reasoning` | boolean | `false` | Whether the model has reasoning behavior. | -| `reasoning_by_default` | boolean | effort-capable models: `true`; other models: `false` | Whether requests reason when no `reasoning_effort` is supplied. Set this explicitly for always-reasoning routes that do not expose an effort control, or for effort-capable routes whose provider defaults reasoning off. | -| `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. | -| `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.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.providers..models..costs]` - -| Key | Type / values | Default | Description | -|---|---|---|---| -| `input_cost_per_mtok` | number | None | Input cost in USD per million tokens. | -| `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.providers..models..costs.speed.]` - -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. +| `knowledge_cutoff` | string | None | Public knowledge cutoff label. | +| `estimated_output_tps` | number | None | Estimated output tokens per second. | +| `small_default` | boolean | `false` | Preferred for small utility calls such as generated run titles. | +| `probe` | boolean | `false` | Preferred for provider connectivity probes. | +| `reasoning_by_default` | boolean | reasoning models with effort levels: `true` | Whether requests reason when no `reasoning_effort` is supplied. | "#, ); diff --git a/lib/foundation/fabro-dev/tests/it/policy.rs b/lib/foundation/fabro-dev/tests/it/policy.rs index 2f2da24b6..6f1693b86 100644 --- a/lib/foundation/fabro-dev/tests/it/policy.rs +++ b/lib/foundation/fabro-dev/tests/it/policy.rs @@ -8,48 +8,6 @@ use walkdir::WalkDir; use crate::workspace_root; -/// `fabro_model::bootstrap_catalog` (and its module) is the install/API-key -/// validation hatch from the settings-driven LLM catalog plan. It must -/// **not** appear in request-serving paths — server handlers, workflow -/// operations, agent runtime, hooks, or completion handlers — because those -/// must use the resolved `Arc` threaded through their state. -/// -/// The allowed-callers list below is the policy boundary. Adding a new -/// caller is intentional and requires updating this list. -/// -/// The walker only descends into `lib/`, so non-`lib/` paths (docs, top-level -/// markdown) are not part of the allowlist. -const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ - // The bootstrap module itself. - "lib/foundation/fabro-model/src/bootstrap_catalog", - // Public module declaration for the bootstrap hatch. - "lib/foundation/fabro-model/src/lib.rs", - // Install / first-run / API-key validation flows that legitimately need - // a built-in catalog before any project settings have been loaded. - "lib/components/fabro-install/", - "lib/apps/fabro-cli/src/commands/install/", - "lib/apps/fabro-cli/src/shared/install_", - "lib/apps/fabro-cli/src/shared/api_key_validation", - // Test support modules. - "tests/", - "test_support", - "/tests/it/", - "/tests/policy.rs", -]; - -/// Production runtime code should build catalogs from resolved settings and -/// thread the resulting `Arc` through state. Direct use of -/// `Catalog::builtin()` is reserved for `fabro-model` internals and tests. -const CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ - // The catalog owner may define and test the built-in/default catalog. - "lib/foundation/fabro-model/", - // Tests and test support may use built-ins as fixtures. - "/tests/", - "/tests/it/", - "test_support", - "/tests/policy.rs", -]; - const TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ // The template crate owns the rendering API and its tests. "lib/foundation/fabro-template/src/lib.rs", @@ -70,32 +28,6 @@ const TEMPLATE_RENDER_FORBIDDEN_PATTERNS: &[&str] = &[ "fabro_template::{", ]; -#[test] -fn bootstrap_catalog_references_stay_in_allowlist() { - let violations = source_symbol_violations( - "bootstrap_catalog", - BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS, - ); - - assert!( - violations.is_empty(), - "bootstrap_catalog (install-only) referenced from non-allowlisted source files:\n{}\n\nIf this is intentional, add the path fragment to BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.", - format_violations(violations), - ); -} - -#[test] -fn catalog_builtin_references_stay_in_allowlist() { - let violations = - source_symbol_violations("Catalog::builtin()", CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS); - - assert!( - violations.is_empty(), - "Catalog::builtin() referenced from non-allowlisted production source files:\n{}\n\nRuntime code should use a resolved settings catalog via `Catalog::from_builtin_with_overrides(...)` or an injected `Arc`. If this is intentional test/bootstrap code, add the path fragment to CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.", - format_violations(violations), - ); -} - #[test] fn workflow_template_rendering_call_sites_stay_in_allowlist() { let mut violations = Vec::new(); diff --git a/lib/foundation/fabro-model/Cargo.toml b/lib/foundation/fabro-model/Cargo.toml deleted file mode 100644 index 9c586d27e..000000000 --- a/lib/foundation/fabro-model/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "fabro-model" -edition.workspace = true -version.workspace = true -publish = false -license.workspace = true -description = "LLM model catalog: provider identity, model metadata, and resolution" - -[lib] -doctest = false - -[lints] -workspace = true - -[features] -clap = ["dep:clap"] - -[dependencies] -clap = { workspace = true, optional = true } -fabro-static.workspace = true -http = "1" -rust-embed.workspace = true -serde.workspace = true -serde_json.workspace = true -strum.workspace = true -thiserror.workspace = true -toml.workspace = true -tracing.workspace = true - -[dev-dependencies] -insta.workspace = true diff --git a/lib/foundation/fabro-model/src/adapter.rs b/lib/foundation/fabro-model/src/adapter.rs deleted file mode 100644 index c9eb25b97..000000000 --- a/lib/foundation/fabro-model/src/adapter.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Adapter registry keys shared by the model catalog and LLM factories. -//! -//! Provider/model catalog rows parse adapter strings into [`AdapterKind`]. -//! Runtime code should carry the typed kind instead of re-matching on strings. - -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr, VariantArray}; - -/// Stable adapter identity for protocol/client behavior. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum AdapterKind { - Anthropic, - #[serde(rename = "openai")] - #[strum(to_string = "openai")] - OpenAi, - Gemini, - #[serde(rename = "openai_compatible")] - #[strum(to_string = "openai_compatible")] - OpenAiCompatible, - Bedrock, -} - -impl AdapterKind { - #[must_use] - pub fn as_str(self) -> &'static str { - self.into() - } -} - -impl AsRef for AdapterKind { - fn as_ref(&self) -> &str { - (*self).as_str() - } -} - -/// Internal dispatch key that `fabro-agent` maps to a concrete agent profile. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum AgentProfileKind { - Anthropic, - /// Claude 5 models trained against Anthropic's current coding-agent - /// harness. This remains model-scoped so older Claude models keep the - /// established Anthropic profile. - #[serde(rename = "claude-5")] - #[strum(to_string = "claude-5")] - Claude5, - #[serde(rename = "openai")] - #[strum(to_string = "openai")] - OpenAi, - Gemini, - /// Kimi (Moonshot) models, wherever they are served from. Selected per - /// model rather than per provider, so a Kimi model reached through a - /// gateway such as OpenRouter gets the same profile as one reached - /// directly at `api.moonshot.ai`. - Kimi, - /// GPT-5.6 models (Sol, Terra, Luna), which Codex drives with a narrower - /// core tool set than earlier GPT models: a shell, a file editor, and - /// `update_plan`, plus optional web search. The profile omits dedicated - /// file-read, discovery, and fetch tools. Selected per model rather than - /// per provider, so other models on the `openai` provider keep - /// [`Self::OpenAi`]. - Gpt56, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn adapter_kind_round_trips_as_snake_case() { - for kind in AdapterKind::VARIANTS { - let json = serde_json::to_string(kind).unwrap(); - assert_eq!(json, format!("\"{}\"", kind.as_str())); - let parsed: AdapterKind = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, *kind); - assert_eq!(kind.as_str().parse::().unwrap(), *kind); - } - } - - #[test] - fn bedrock_adapter_kind_roundtrips() { - assert_eq!(AdapterKind::Bedrock.as_str(), "bedrock"); - assert_eq!( - "bedrock".parse::().unwrap(), - AdapterKind::Bedrock - ); - assert!(AdapterKind::VARIANTS.contains(&AdapterKind::Bedrock)); - } - - #[test] - fn agent_profile_kind_round_trips_as_settings_strings() { - for kind in AgentProfileKind::VARIANTS { - let expected = kind.to_string(); - let json = serde_json::to_string(&kind).unwrap(); - assert_eq!(json, format!("\"{expected}\"")); - let parsed: AgentProfileKind = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, *kind); - assert_eq!(expected.parse::().unwrap(), *kind); - } - } -} diff --git a/lib/foundation/fabro-model/src/billing.rs b/lib/foundation/fabro-model/src/billing.rs deleted file mode 100644 index f1c5867fa..000000000 --- a/lib/foundation/fabro-model/src/billing.rs +++ /dev/null @@ -1,1446 +0,0 @@ -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr}; - -use crate::catalog::{BillingPolicy, Catalog, CatalogModelSettings}; -use crate::{Model, ModelCosts, ModelId, ProviderId}; - -const TOKENS_PER_MTOK: i128 = 1_000_000; -const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5; -const ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR: i64 = 4; -const ANTHROPIC_CACHE_WRITE_1H_NUMERATOR: i64 = 2; -const ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR: i64 = 1; -const USD_MICROS_PER_USD_F64: f64 = 1_000_000.0; - -fn saturating_i128_to_i64(value: i128) -> i64 { - i64::try_from(value).unwrap_or_else(|_| { - if value.is_negative() { - i64::MIN - } else { - i64::MAX - } - }) -} - -#[allow( - clippy::cast_possible_truncation, - clippy::cast_precision_loss, - reason = "Billing rounds bounded finite floats into i64 counters by design." -)] -fn saturating_rounded_f64_to_i64(value: f64) -> i64 { - if !value.is_finite() { - return if value.is_sign_negative() { - i64::MIN - } else { - i64::MAX - }; - } - - if value <= i64::MIN as f64 { - i64::MIN - } else if value >= i64::MAX as f64 { - i64::MAX - } else { - value as i64 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] -pub struct UsdMicros(pub i64); - -impl UsdMicros { - #[must_use] - pub fn from_usd(usd: f64) -> Self { - Self(saturating_rounded_f64_to_i64( - (usd * USD_MICROS_PER_USD_F64).round(), - )) - } - - /// Folds a cost into a running total that stays `None` until a cost is - /// observed (`None` means "no provider data", not $0). - pub fn accumulate(total: &mut Option, cost: Option) { - if let Some(cost) = cost { - *total.get_or_insert_default() += cost; - } - } -} - -impl std::ops::Add for UsdMicros { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self(self.0.saturating_add(rhs.0)) - } -} - -impl std::ops::AddAssign for UsdMicros { - fn add_assign(&mut self, rhs: Self) { - *self = *self + rhs; - } -} - -impl std::iter::Sum for UsdMicros { - fn sum>(iter: I) -> Self { - iter.fold(Self::default(), |acc, value| acc + value) - } -} - -fn accumulate_optional_usd_micros(total: &mut Option, cost: Option) { - let mut typed_total = (*total).map(UsdMicros); - UsdMicros::accumulate(&mut typed_total, cost.map(UsdMicros)); - *total = typed_total.map(|value| value.0); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct PricePerMTok { - pub usd_micros: i64, -} - -impl PricePerMTok { - #[must_use] - pub fn from_usd(usd: f64) -> Self { - Self { - usd_micros: UsdMicros::from_usd(usd).0, - } - } - - #[must_use] - pub fn multiply_ratio(self, numerator: i64, denominator: i64) -> Self { - Self { - usd_micros: self.usd_micros.saturating_mul(numerator) / denominator, - } - } - - #[must_use] - pub fn bill(self, tokens: i64) -> UsdMicros { - let total = i128::from(tokens) * i128::from(self.usd_micros); - UsdMicros(saturating_i128_to_i64(total / TOKENS_PER_MTOK)) - } -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - strum::VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum Speed { - Standard, - Fast, -} - -impl Speed { - #[must_use] - pub fn variants() -> &'static [Self] { - ::VARIANTS - } -} - -/// Source of a USD cost value attached to a completion response. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum CostSource { - /// The provider returned billing data in-band with the response. - Authoritative, - /// Computed from catalog prices and token usage. - Estimated, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ModelRef { - pub provider: ProviderId, - pub model_id: ModelId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option, -} - -/// Token counts for one LLM call. -/// -/// All five fields are disjoint: each token is counted in exactly one bucket, -/// and `total_tokens()` is their sum. Provider mappings normalize their wire -/// formats into this shape. For example, OpenAI's nested cached tokens are -/// subtracted out of `input_tokens`, while Anthropic thinking tokens remain in -/// `output_tokens` because Anthropic does not expose a separate billed count. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct TokenCounts { - pub input_tokens: i64, - pub output_tokens: i64, - #[serde(default)] - pub reasoning_tokens: i64, - #[serde(default)] - pub cache_read_tokens: i64, - #[serde(default)] - pub cache_write_tokens: i64, -} - -impl TokenCounts { - #[must_use] - pub fn billable_output_tokens(&self) -> i64 { - self.output_tokens + self.reasoning_tokens - } - - #[must_use] - pub fn total_tokens(&self) -> i64 { - self.input_tokens - + self.billable_output_tokens() - + self.cache_read_tokens - + self.cache_write_tokens - } -} - -impl std::ops::Add for TokenCounts { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self { - input_tokens: self.input_tokens + rhs.input_tokens, - output_tokens: self.output_tokens + rhs.output_tokens, - reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens, - cache_read_tokens: self.cache_read_tokens + rhs.cache_read_tokens, - cache_write_tokens: self.cache_write_tokens + rhs.cache_write_tokens, - } - } -} - -impl std::ops::AddAssign for TokenCounts { - fn add_assign(&mut self, rhs: Self) { - self.input_tokens += rhs.input_tokens; - self.output_tokens += rhs.output_tokens; - self.reasoning_tokens += rhs.reasoning_tokens; - self.cache_read_tokens += rhs.cache_read_tokens; - self.cache_write_tokens += rhs.cache_write_tokens; - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelUsage { - pub model: ModelRef, - pub tokens: TokenCounts, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OpenAiModelPricing { - pub input: PricePerMTok, - pub cached_input: Option, - pub output: PricePerMTok, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AnthropicModelPricing { - pub input: PricePerMTok, - pub cache_read: Option, - pub cache_write_5m: Option, - pub cache_write_1h: Option, - pub output: PricePerMTok, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeminiStorageSegment { - pub cached_tokens: i64, - pub ttl_seconds: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeminiStoragePricing { - pub usd_micros_per_mtok_second: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GeminiModelPricing { - pub input: PricePerMTok, - pub output: PricePerMTok, - pub cached_input: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "algorithm", rename_all = "snake_case")] -pub enum ModelPricingPolicy { - #[serde(rename = "openai")] - OpenAi(OpenAiModelPricing), - Anthropic(AnthropicModelPricing), - Gemini(GeminiModelPricing), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelPricing { - pub model: ModelRef, - pub policy: ModelPricingPolicy, -} - -#[allow( - clippy::empty_structs_with_brackets, - reason = "This type must serialize as {} rather than null." -)] -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct OpenAiBillingFacts {} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct AnthropicBillingFacts { - #[serde(default)] - pub cache_write_5m_tokens: i64, - #[serde(default)] - pub cache_write_1h_tokens: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct GeminiBillingFacts { - #[serde(default)] - pub storage_segments: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "algorithm", rename_all = "snake_case")] -pub enum ModelBillingFacts { - #[serde(rename = "openai")] - OpenAi(OpenAiBillingFacts), - Anthropic(AnthropicBillingFacts), - Gemini(GeminiBillingFacts), -} - -impl ModelBillingFacts { - #[must_use] - pub fn for_policy(policy: BillingPolicy, tokens: &TokenCounts) -> Option { - match policy { - BillingPolicy::OpenAi => Some(Self::OpenAi(OpenAiBillingFacts::default())), - BillingPolicy::Anthropic => Some(Self::Anthropic(anthropic_billing_facts(tokens))), - BillingPolicy::Gemini => Some(Self::Gemini(GeminiBillingFacts::default())), - BillingPolicy::None => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModelBillingInput { - pub usage: ModelUsage, - pub facts: ModelBillingFacts, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct BilledModelUsage { - pub input: ModelBillingInput, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_usd_micros: Option, -} - -impl BilledModelUsage { - #[must_use] - pub fn model(&self) -> &ModelRef { - &self.input.usage.model - } - - #[must_use] - pub fn model_id(&self) -> &str { - self.input.usage.model.model_id.as_str() - } - - #[must_use] - pub fn tokens(&self) -> &TokenCounts { - &self.input.usage.tokens - } - - /// Overrides the billed total with a provider-reported cost; `None` leaves - /// the catalog estimate in place. - #[must_use] - pub fn with_reported_cost(mut self, cost: Option) -> Self { - if let Some(cost) = cost { - self.total_usd_micros = Some(cost.0); - } - self - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct BilledTokenCounts { - pub input_tokens: i64, - pub output_tokens: i64, - pub total_tokens: i64, - #[serde(default)] - pub reasoning_tokens: i64, - #[serde(default)] - pub cache_read_tokens: i64, - #[serde(default)] - pub cache_write_tokens: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_usd_micros: Option, -} - -impl BilledTokenCounts { - #[must_use] - pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self { - let mut tokens = TokenCounts::default(); - let mut total_usd_micros = None; - - for entry in billed { - tokens += entry.input.usage.tokens.clone(); - accumulate_optional_usd_micros(&mut total_usd_micros, entry.total_usd_micros); - } - - Self { - input_tokens: tokens.input_tokens, - output_tokens: tokens.output_tokens, - total_tokens: tokens.total_tokens(), - reasoning_tokens: tokens.reasoning_tokens, - cache_read_tokens: tokens.cache_read_tokens, - cache_write_tokens: tokens.cache_write_tokens, - total_usd_micros, - } - } - - /// Returns the five disjoint per-call token buckets, dropping the derived - /// `total_tokens` sum and the optional `total_usd_micros` cost. - #[must_use] - pub fn token_counts(&self) -> TokenCounts { - TokenCounts { - input_tokens: self.input_tokens, - output_tokens: self.output_tokens, - reasoning_tokens: self.reasoning_tokens, - cache_read_tokens: self.cache_read_tokens, - cache_write_tokens: self.cache_write_tokens, - } - } - - pub fn add_counts(&mut self, source: &Self) { - self.input_tokens += source.input_tokens; - self.output_tokens += source.output_tokens; - self.total_tokens += source.total_tokens; - self.reasoning_tokens += source.reasoning_tokens; - self.cache_read_tokens += source.cache_read_tokens; - self.cache_write_tokens += source.cache_write_tokens; - accumulate_optional_usd_micros(&mut self.total_usd_micros, source.total_usd_micros); - } - - pub fn add_billed_usage(&mut self, usage: &BilledModelUsage) { - let tokens = usage.tokens(); - self.input_tokens += tokens.input_tokens; - self.output_tokens += tokens.output_tokens; - self.reasoning_tokens += tokens.reasoning_tokens; - self.cache_read_tokens += tokens.cache_read_tokens; - self.cache_write_tokens += tokens.cache_write_tokens; - self.total_tokens += tokens.total_tokens(); - accumulate_optional_usd_micros(&mut self.total_usd_micros, usage.total_usd_micros); - } - - pub fn replace_with_billed_usage(&mut self, usage: &BilledModelUsage) { - *self = Self::from_billed_usage(std::slice::from_ref(usage)); - } - - /// Overrides the billed total with a provider-reported cost; `None` leaves - /// any existing estimate in place. - #[must_use] - pub fn with_reported_cost(mut self, cost: Option) -> Self { - if let Some(cost) = cost { - self.total_usd_micros = Some(cost.0); - } - self - } - - #[must_use] - pub fn is_zero(&self) -> bool { - self.input_tokens == 0 - && self.output_tokens == 0 - && self.total_tokens == 0 - && self.reasoning_tokens == 0 - && self.cache_read_tokens == 0 - && self.cache_write_tokens == 0 - && self.total_usd_micros.unwrap_or(0) == 0 - } -} - -fn anthropic_billing_facts(tokens: &TokenCounts) -> AnthropicBillingFacts { - AnthropicBillingFacts { - cache_write_5m_tokens: tokens.cache_write_tokens, - cache_write_1h_tokens: 0, - } -} - -impl Catalog { - #[must_use] - pub fn pricing_for(&self, model_ref: &ModelRef) -> Option { - let model = self.offering(&model_ref.provider, &model_ref.model_id)?; - let provider = self.provider(&model_ref.provider)?; - let settings = self.settings_for(model)?; - let costs = costs_for_speed(model, settings, model_ref.speed)?; - pricing_for_model_costs( - model, - provider.id.clone(), - settings.billing_policy, - model_ref.speed, - &costs, - ) - } - - #[must_use] - pub fn billing_facts_for( - &self, - model_ref: &ModelRef, - tokens: &TokenCounts, - ) -> Option { - let policy = - self.effective_billing_policy(&model_ref.provider, Some(model_ref.model_id.as_str()))?; - ModelBillingFacts::for_policy(policy, tokens) - } - - /// Price a partial token sample for `model` using catalog pricing. - /// - /// Returns `None` when the provider has no billing policy, the model is - /// unknown, or the pricing algorithm cannot produce a result for the given - /// tokens. Used by read-side rollups so in-flight stages can show an - /// exact cost for the tokens consumed so far. - #[must_use] - pub fn price_tokens(&self, model: &ModelRef, tokens: &TokenCounts) -> Option { - let facts = self.billing_facts_for(model, tokens)?; - let input = ModelBillingInput { - usage: ModelUsage { - model: model.clone(), - tokens: tokens.clone(), - }, - facts, - }; - self.pricing_for(model) - .and_then(|pricing| pricing.bill(&input)) - .map(|amount| amount.0) - } -} - -fn costs_for_speed( - model: &Model, - settings: &CatalogModelSettings, - speed: Option, -) -> Option { - match speed { - None | Some(Speed::Standard) => Some(model.costs.clone()), - Some(speed) => { - if !settings.controls.speed.contains(&speed) { - return None; - } - let Some(speed_costs) = settings.speed_costs.get(&speed) else { - return Some(model.costs.clone()); - }; - Some(merge_cost_override(&model.costs, speed_costs)) - } - } -} - -fn merge_cost_override(base: &ModelCosts, override_costs: &ModelCosts) -> ModelCosts { - ModelCosts { - input_cost_per_mtok: override_costs - .input_cost_per_mtok - .or(base.input_cost_per_mtok), - output_cost_per_mtok: override_costs - .output_cost_per_mtok - .or(base.output_cost_per_mtok), - cache_input_cost_per_mtok: override_costs - .cache_input_cost_per_mtok - .or(base.cache_input_cost_per_mtok), - } -} - -impl Model { - #[must_use] - pub fn billing_model_ref(&self, speed: Option) -> ModelRef { - ModelRef { - provider: self.provider.clone(), - model_id: self.id.clone(), - speed, - } - } -} - -fn pricing_for_model_costs( - model: &Model, - provider_id: ProviderId, - billing_policy: BillingPolicy, - speed: Option, - costs: &ModelCosts, -) -> Option { - let input = costs.input_cost_per_mtok.map(PricePerMTok::from_usd)?; - let output = costs.output_cost_per_mtok.map(PricePerMTok::from_usd)?; - let cached_input = costs.cache_input_cost_per_mtok.map(PricePerMTok::from_usd); - - let policy = pricing_policy_for_billing_policy(billing_policy, input, output, cached_input)?; - Some(ModelPricing { - model: ModelRef { - provider: provider_id, - model_id: model.id.clone(), - speed, - }, - policy, - }) -} - -fn pricing_policy_for_billing_policy( - billing_policy: BillingPolicy, - input: PricePerMTok, - output: PricePerMTok, - cached_input: Option, -) -> Option { - match billing_policy { - BillingPolicy::Anthropic => Some(anthropic_pricing_policy(input, output, cached_input)), - BillingPolicy::Gemini => Some(ModelPricingPolicy::Gemini(GeminiModelPricing { - input, - output, - cached_input, - storage: None, - })), - BillingPolicy::OpenAi => Some(ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input, - cached_input, - output, - })), - BillingPolicy::None => None, - } -} - -fn anthropic_pricing_policy( - input: PricePerMTok, - output: PricePerMTok, - cached_input: Option, -) -> ModelPricingPolicy { - ModelPricingPolicy::Anthropic(AnthropicModelPricing { - input, - cache_read: cached_input, - cache_write_5m: Some(input.multiply_ratio( - ANTHROPIC_CACHE_WRITE_5M_NUMERATOR, - ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR, - )), - cache_write_1h: Some(input.multiply_ratio( - ANTHROPIC_CACHE_WRITE_1H_NUMERATOR, - ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR, - )), - output, - }) -} - -impl ModelPricing { - #[must_use] - pub fn bill(&self, input: &ModelBillingInput) -> Option { - if input.usage.model != self.model { - return None; - } - - let bill = match (&self.policy, &input.facts) { - (ModelPricingPolicy::OpenAi(pricing), ModelBillingFacts::OpenAi(_)) => { - Some(bill_openai_like(pricing, &input.usage.tokens)) - } - (ModelPricingPolicy::Anthropic(pricing), ModelBillingFacts::Anthropic(facts)) => { - Some(bill_anthropic(pricing, &input.usage.tokens, facts)) - } - (ModelPricingPolicy::Gemini(pricing), ModelBillingFacts::Gemini(facts)) => { - bill_gemini(pricing, &input.usage.tokens, facts) - } - _ => None, - }?; - - Some(bill) - } - - #[must_use] - pub fn bill_usage(&self, input: ModelBillingInput) -> BilledModelUsage { - let total_usd_micros = self.bill(&input).map(|amount| amount.0); - BilledModelUsage { - input, - total_usd_micros, - } - } -} - -fn bill_openai_like(pricing: &OpenAiModelPricing, tokens: &TokenCounts) -> UsdMicros { - let mut total = pricing.input.bill(tokens.input_tokens); - total += pricing.output.bill(tokens.billable_output_tokens()); - if let Some(cached_input) = pricing.cached_input { - total += cached_input.bill(tokens.cache_read_tokens); - } - total -} - -fn bill_anthropic( - pricing: &AnthropicModelPricing, - tokens: &TokenCounts, - facts: &AnthropicBillingFacts, -) -> UsdMicros { - let mut total = pricing.input.bill(tokens.input_tokens); - total += pricing.output.bill(tokens.billable_output_tokens()); - if let Some(cache_read) = pricing.cache_read { - total += cache_read.bill(tokens.cache_read_tokens); - } - if let Some(cache_write_5m) = pricing.cache_write_5m { - total += cache_write_5m.bill(facts.cache_write_5m_tokens); - } - if let Some(cache_write_1h) = pricing.cache_write_1h { - total += cache_write_1h.bill(facts.cache_write_1h_tokens); - } - total -} - -fn bill_gemini( - pricing: &GeminiModelPricing, - tokens: &TokenCounts, - facts: &GeminiBillingFacts, -) -> Option { - if tokens.cache_read_tokens > 0 && pricing.cached_input.is_none() { - return None; - } - if !facts.storage_segments.is_empty() && pricing.storage.is_none() { - return None; - } - - let mut total = pricing.input.bill(tokens.input_tokens); - total += pricing.output.bill(tokens.billable_output_tokens()); - if let Some(cached_input) = pricing.cached_input { - total += cached_input.bill(tokens.cache_read_tokens); - } - if let Some(storage) = pricing.storage.as_ref() { - let storage_cost = facts - .storage_segments - .iter() - .map(|segment| { - let token_seconds = - i128::from(segment.cached_tokens) * i128::from(segment.ttl_seconds); - UsdMicros(saturating_i128_to_i64( - token_seconds * i128::from(storage.usd_micros_per_mtok_second) - / TOKENS_PER_MTOK, - )) - }) - .sum::(); - total += storage_cost; - } - - Some(total) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::catalog::LlmCatalogSettings; - use crate::{Catalog, ProviderId}; - - fn catalog_from_toml(source: &str) -> Catalog { - let settings: LlmCatalogSettings = - toml::from_str(source).expect("catalog fixture should parse"); - Catalog::from_settings(&settings).expect("catalog fixture should build") - } - - fn billed_usage( - input_tokens: i64, - output_tokens: i64, - total_usd_micros: Option, - ) -> BilledModelUsage { - BilledModelUsage { - input: ModelBillingInput { - usage: ModelUsage { - model: ModelRef { - provider: ProviderId::openai(), - model_id: ModelId::new("gpt-5.4"), - speed: None, - }, - tokens: TokenCounts { - input_tokens, - output_tokens, - reasoning_tokens: 3, - cache_read_tokens: 5, - cache_write_tokens: 7, - }, - }, - facts: ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()), - }, - total_usd_micros, - } - } - - #[test] - fn usd_micros_accumulate_keeps_none_until_a_cost_is_observed() { - let mut total = None; - UsdMicros::accumulate(&mut total, None); - assert_eq!(total, None); - - UsdMicros::accumulate(&mut total, Some(UsdMicros(40_000))); - UsdMicros::accumulate(&mut total, None); - UsdMicros::accumulate(&mut total, Some(UsdMicros(60_000))); - assert_eq!(total, Some(UsdMicros(100_000))); - } - - #[test] - fn usd_micros_arithmetic_saturates_at_i64_bounds() { - assert_eq!(UsdMicros(i64::MAX) + UsdMicros(1), UsdMicros(i64::MAX)); - - let mut minimum = UsdMicros(i64::MIN); - minimum += UsdMicros(-1); - assert_eq!(minimum, UsdMicros(i64::MIN)); - - assert_eq!( - [UsdMicros(i64::MAX), UsdMicros(1)] - .into_iter() - .sum::(), - UsdMicros(i64::MAX) - ); - } - - #[test] - fn usd_micros_accumulate_saturates_at_i64_bounds() { - let mut maximum = Some(UsdMicros(i64::MAX)); - UsdMicros::accumulate(&mut maximum, Some(UsdMicros(1))); - assert_eq!(maximum, Some(UsdMicros(i64::MAX))); - - let mut minimum = Some(UsdMicros(i64::MIN)); - UsdMicros::accumulate(&mut minimum, Some(UsdMicros(-1))); - assert_eq!(minimum, Some(UsdMicros(i64::MIN))); - } - - #[test] - fn model_billing_policy_override_changes_the_billing_algorithm() { - let catalog = catalog_from_toml( - r#" -[providers.aggregator] -display_name = "Aggregator" -adapter = "openai_compatible" -base_url = "https://aggregator.test/v1" - -[models."claude-via-aggregator"] -provider = "aggregator" -billing_policy = "anthropic" -display_name = "Claude (via Aggregator)" -family = "claude" -default = true - -[models."claude-via-aggregator".limits] -context_window = 200000 - -[models."claude-via-aggregator".features] -tools = true -vision = false -reasoning = false - -[models."claude-via-aggregator".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -[models."plain-model"] -provider = "aggregator" -display_name = "Plain" -family = "plain" - -[models."plain-model".limits] -context_window = 100000 - -[models."plain-model".features] -tools = false -vision = false -reasoning = false - -[models."plain-model".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 -"#, - ); - - let tokens = TokenCounts { - cache_write_tokens: 1_000_000, - ..TokenCounts::default() - }; - let claude = ModelRef { - provider: ProviderId::new("aggregator"), - model_id: ModelId::new("claude-via-aggregator"), - speed: None, - }; - let plain = ModelRef { - provider: ProviderId::new("aggregator"), - model_id: ModelId::new("plain-model"), - speed: None, - }; - - // The override bills Anthropic-style: cache writes at 1.25x input - // ($3/MTok -> $3.75/MTok -> $3.75 for 1M write tokens). - assert_eq!(catalog.price_tokens(&claude, &tokens), Some(3_750_000)); - // The provider's default OpenAI policy has no cache-write charge. - assert_eq!(catalog.price_tokens(&plain, &tokens), Some(0)); - } - - #[test] - fn billed_token_counts_add_counts_accumulates_cost_when_known() { - let mut counts = BilledTokenCounts { - input_tokens: 1, - output_tokens: 2, - total_tokens: 3, - reasoning_tokens: 4, - cache_read_tokens: 5, - cache_write_tokens: 6, - total_usd_micros: None, - }; - counts.add_counts(&BilledTokenCounts { - input_tokens: 10, - output_tokens: 20, - total_tokens: 30, - reasoning_tokens: 40, - cache_read_tokens: 50, - cache_write_tokens: 60, - total_usd_micros: Some(70), - }); - - assert_eq!(counts, BilledTokenCounts { - input_tokens: 11, - output_tokens: 22, - total_tokens: 33, - reasoning_tokens: 44, - cache_read_tokens: 55, - cache_write_tokens: 66, - total_usd_micros: Some(70), - }); - } - - #[test] - fn billed_token_counts_add_billed_usage_preserves_unknown_cost() { - let mut counts = BilledTokenCounts::default(); - - counts.add_billed_usage(&billed_usage(10, 20, None)); - - assert_eq!(counts, BilledTokenCounts { - input_tokens: 10, - output_tokens: 20, - total_tokens: 45, - reasoning_tokens: 3, - cache_read_tokens: 5, - cache_write_tokens: 7, - total_usd_micros: None, - }); - } - - #[test] - fn billed_token_counts_add_billed_usage_accumulates_known_cost() { - let mut counts = BilledTokenCounts::default(); - - counts.add_billed_usage(&billed_usage(10, 20, Some(100))); - counts.add_billed_usage(&billed_usage(1, 2, Some(50))); - - assert_eq!(counts.input_tokens, 11); - assert_eq!(counts.output_tokens, 22); - assert_eq!(counts.total_tokens, 63); - assert_eq!(counts.total_usd_micros, Some(150)); - } - - #[test] - fn billed_token_counts_cost_rollups_saturate() { - let billed = [ - billed_usage(0, 0, Some(i64::MAX)), - billed_usage(0, 0, Some(1)), - ]; - assert_eq!( - BilledTokenCounts::from_billed_usage(&billed).total_usd_micros, - Some(i64::MAX) - ); - - let mut counts = BilledTokenCounts { - total_usd_micros: Some(i64::MAX), - ..BilledTokenCounts::default() - }; - counts.add_counts(&BilledTokenCounts { - total_usd_micros: Some(1), - ..BilledTokenCounts::default() - }); - assert_eq!(counts.total_usd_micros, Some(i64::MAX)); - - counts.add_billed_usage(&billed_usage(0, 0, Some(1))); - assert_eq!(counts.total_usd_micros, Some(i64::MAX)); - } - - #[test] - fn billed_token_counts_replace_with_billed_usage_discards_previous_values() { - let mut counts = BilledTokenCounts { - input_tokens: 100, - output_tokens: 200, - total_tokens: 300, - reasoning_tokens: 400, - cache_read_tokens: 500, - cache_write_tokens: 600, - total_usd_micros: Some(700), - }; - - counts.replace_with_billed_usage(&billed_usage(1, 2, None)); - - assert_eq!(counts, BilledTokenCounts { - input_tokens: 1, - output_tokens: 2, - total_tokens: 18, - reasoning_tokens: 3, - cache_read_tokens: 5, - cache_write_tokens: 7, - total_usd_micros: None, - }); - } - - #[test] - fn billed_token_counts_is_zero_treats_missing_and_zero_cost_as_zero() { - assert!(BilledTokenCounts::default().is_zero()); - assert!( - BilledTokenCounts { - total_usd_micros: Some(0), - ..BilledTokenCounts::default() - } - .is_zero() - ); - assert!( - !BilledTokenCounts { - input_tokens: 1, - ..BilledTokenCounts::default() - } - .is_zero() - ); - assert!( - !BilledTokenCounts { - total_usd_micros: Some(1), - ..BilledTokenCounts::default() - } - .is_zero() - ); - } - - #[test] - fn openai_pricing_bills_cached_input_and_reasoning_output() { - let pricing = ModelPricing { - model: ModelRef { - provider: ProviderId::openai(), - model_id: ModelId::new("gpt-5.4"), - speed: None, - }, - policy: ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input: PricePerMTok { - usd_micros: 1_250_000, - }, - cached_input: Some(PricePerMTok { - usd_micros: 125_000, - }), - output: PricePerMTok { - usd_micros: 10_000_000, - }, - }), - }; - let input = ModelBillingInput { - usage: ModelUsage { - model: pricing.model.clone(), - tokens: TokenCounts { - input_tokens: 500_000, - output_tokens: 125_000, - reasoning_tokens: 25_000, - cache_read_tokens: 250_000, - cache_write_tokens: 0, - }, - }, - facts: ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()), - }; - - assert_eq!(pricing.bill(&input), Some(UsdMicros(2_156_250))); - } - - #[test] - fn catalog_pricing_uses_speed_cost_overrides() { - let pricing = Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("claude-opus-4-6"), - speed: Some(Speed::Fast), - }) - .unwrap(); - - let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { - panic!("expected anthropic pricing"); - }; - - assert_eq!(pricing.model.provider, ProviderId::anthropic()); - assert_eq!(pricing.model.model_id, "claude-opus-4-6"); - assert_eq!(pricing.model.speed, Some(Speed::Fast)); - assert_eq!(anthropic.input.usd_micros, 30_000_000); - assert_eq!(anthropic.output.usd_micros, 150_000_000); - assert_eq!(anthropic.cache_read.unwrap().usd_micros, 3_000_000); - assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 37_500_000); - assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 60_000_000); - } - - #[test] - fn catalog_pricing_standard_speed_uses_base_costs() { - let pricing = Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("claude-opus-4-6"), - speed: Some(Speed::Standard), - }) - .unwrap(); - - let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { - panic!("expected anthropic pricing"); - }; - - assert_eq!(anthropic.input.usd_micros, 5_000_000); - assert_eq!(anthropic.output.usd_micros, 25_000_000); - assert_eq!(anthropic.cache_read.unwrap().usd_micros, 500_000); - assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 6_250_000); - assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 10_000_000); - } - - #[test] - fn catalog_pricing_supported_fast_without_override_uses_base_costs() { - let catalog = catalog_from_toml( - r#" -[providers.test_anthropic] -display_name = "Test Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" -billing_policy = "anthropic" - -[models.test-opus] -provider = "test_anthropic" -display_name = "Test Opus" -family = "test" -default = true - -[models.test-opus.limits] -context_window = 1000 - -[models.test-opus.features] -tools = true -vision = false -reasoning = false - -[models.test-opus.controls] -speed = ["fast"] - -[models.test-opus.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.25 -"#, - ); - - let pricing = catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("test_anthropic"), - model_id: ModelId::new("test-opus"), - speed: Some(Speed::Fast), - }) - .unwrap(); - - let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else { - panic!("expected anthropic adapter pricing"); - }; - assert_eq!(anthropic.input.usd_micros, 1_000_000); - assert_eq!(anthropic.output.usd_micros, 4_000_000); - assert_eq!(anthropic.cache_read.unwrap().usd_micros, 250_000); - } - - #[test] - fn catalog_pricing_supports_custom_openai_compatible_provider_costs() { - let catalog = catalog_from_toml( - r#" -[providers.proxy] -display_name = "Proxy" -adapter = "openai_compatible" -agent_profile = "openai" -billing_policy = "openai" -base_url = "https://proxy.example/v1" - -[models.proxy-model] -provider = "proxy" -display_name = "Proxy Model" -family = "proxy" -default = true - -[models.proxy-model.limits] -context_window = 1000 - -[models.proxy-model.features] -tools = true -vision = false -reasoning = false - -[models.proxy-model.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -cache_input_cost_per_mtok = 0.1 -"#, - ); - - let pricing = catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("proxy"), - model_id: ModelId::new("proxy-model"), - speed: None, - }) - .unwrap(); - - let ModelPricingPolicy::OpenAi(openai_like) = pricing.policy else { - panic!("expected OpenAI billing algorithm for OpenAI-compatible adapter"); - }; - assert_eq!(pricing.model.provider, ProviderId::new("proxy")); - assert_eq!(openai_like.input.usd_micros, 1_000_000); - assert_eq!(openai_like.output.usd_micros, 2_000_000); - assert_eq!(openai_like.cached_input.unwrap().usd_micros, 100_000); - } - - #[test] - fn catalog_pricing_uses_canonical_model_id_not_api_id() { - let catalog = catalog_from_toml( - r#" -[providers.proxy] -display_name = "Proxy" -adapter = "openai_compatible" -agent_profile = "openai" -billing_policy = "openai" -base_url = "https://proxy.example/v1" - -[models.canonical-model] -provider = "proxy" -api_id = "wire-model" -display_name = "Canonical Model" -family = "proxy" -default = true - -[models.canonical-model.limits] -context_window = 1000 - -[models.canonical-model.features] -tools = true -vision = false -reasoning = false - -[models.canonical-model.costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -"#, - ); - - assert!( - catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("proxy"), - model_id: ModelId::new("canonical-model"), - speed: None, - }) - .is_some() - ); - assert!( - catalog - .pricing_for(&ModelRef { - provider: ProviderId::new("proxy"), - model_id: ModelId::new("wire-model"), - speed: None, - }) - .is_none() - ); - } - - #[test] - fn catalog_pricing_unknown_provider_model_or_speed_has_no_estimate() { - assert!( - Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::new("unknown"), - model_id: ModelId::new("claude-opus-4-6"), - speed: None, - }) - .is_none() - ); - assert!( - Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("unknown"), - speed: None, - }) - .is_none() - ); - assert!( - Catalog::builtin() - .pricing_for(&ModelRef { - provider: ProviderId::openai(), - model_id: ModelId::new("gpt-5.4"), - speed: Some(Speed::Fast), - }) - .is_none() - ); - } - - #[test] - fn anthropic_billing_supports_distinct_cache_write_buckets() { - let pricing = ModelPricing { - model: ModelRef { - provider: ProviderId::anthropic(), - model_id: ModelId::new("claude-opus-4-6"), - speed: Some(Speed::Fast), - }, - policy: ModelPricingPolicy::Anthropic(AnthropicModelPricing { - input: PricePerMTok { - usd_micros: 30_000_000, - }, - cache_read: Some(PricePerMTok { - usd_micros: 3_000_000, - }), - cache_write_5m: Some(PricePerMTok { - usd_micros: 37_500_000, - }), - cache_write_1h: Some(PricePerMTok { - usd_micros: 60_000_000, - }), - output: PricePerMTok { - usd_micros: 150_000_000, - }, - }), - }; - let input = ModelBillingInput { - usage: ModelUsage { - model: pricing.model.clone(), - tokens: TokenCounts { - input_tokens: 100_000, - output_tokens: 10_000, - reasoning_tokens: 5_000, - cache_read_tokens: 20_000, - cache_write_tokens: 0, - }, - }, - facts: ModelBillingFacts::Anthropic(AnthropicBillingFacts { - cache_write_5m_tokens: 30_000, - cache_write_1h_tokens: 40_000, - }), - }; - - assert_eq!(pricing.bill(&input), Some(UsdMicros(8_835_000))); - } - - #[test] - fn gemini_billing_requires_storage_pricing_when_storage_facts_exist() { - let pricing = ModelPricing { - model: ModelRef { - provider: ProviderId::gemini(), - model_id: ModelId::new("gemini-3.1-pro-preview"), - speed: None, - }, - policy: ModelPricingPolicy::Gemini(GeminiModelPricing { - input: PricePerMTok { - usd_micros: 1_250_000, - }, - output: PricePerMTok { - usd_micros: 10_000_000, - }, - cached_input: None, - storage: None, - }), - }; - let input = ModelBillingInput { - usage: ModelUsage { - model: pricing.model.clone(), - tokens: TokenCounts { - input_tokens: 100_000, - output_tokens: 10_000, - reasoning_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - }, - }, - facts: ModelBillingFacts::Gemini(GeminiBillingFacts { - storage_segments: vec![GeminiStorageSegment { - cached_tokens: 100_000, - ttl_seconds: 60, - }], - }), - }; - - assert_eq!(pricing.bill(&input), None); - } - - #[test] - fn price_per_mtok_bill_saturates_large_totals() { - let price = PricePerMTok { - usd_micros: i64::MAX, - }; - - assert_eq!(price.bill(i64::MAX), UsdMicros(i64::MAX)); - } - - #[test] - fn price_per_mtok_from_usd_saturates_large_inputs() { - let price = PricePerMTok::from_usd(f64::MAX); - - assert_eq!(price.usd_micros, i64::MAX); - } - - #[test] - fn openai_billing_facts_serialize_as_empty_object() { - assert_eq!( - serde_json::to_value(OpenAiBillingFacts::default()).unwrap(), - serde_json::json!({}) - ); - } - - #[test] - fn pricing_policy_serializes_with_algorithm_tag() { - let policy = ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input: PricePerMTok { usd_micros: 1 }, - cached_input: None, - output: PricePerMTok { usd_micros: 2 }, - }); - - assert_eq!( - serde_json::to_value(policy).unwrap(), - serde_json::json!({ - "algorithm": "openai", - "input": { "usd_micros": 1 }, - "cached_input": null, - "output": { "usd_micros": 2 } - }) - ); - } - - #[test] - fn old_provider_tagged_billing_facts_are_rejected() { - let error = serde_json::from_value::(serde_json::json!({ - "provider": "openai" - })) - .unwrap_err(); - assert!(error.to_string().contains("algorithm")); - } - - #[test] - fn old_provider_tagged_pricing_policy_is_rejected() { - let error = serde_json::from_value::(openai_pricing_json( - "provider", "moonshot", - )) - .unwrap_err(); - assert!(error.to_string().contains("algorithm")); - } - - #[test] - fn openai_billing_policy_uses_openai_billing_algorithm() { - let facts = - ModelBillingFacts::for_policy(BillingPolicy::OpenAi, &TokenCounts::default()).unwrap(); - assert_eq!( - facts, - ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()) - ); - } - - fn openai_pricing_json(tag: &str, tag_value: &str) -> serde_json::Value { - let mut value = serde_json::json!({ - "input": { "usd_micros": 1 }, - "cached_input": null, - "output": { "usd_micros": 2 } - }); - value - .as_object_mut() - .unwrap() - .insert(tag.to_string(), tag_value.into()); - value - } -} diff --git a/lib/foundation/fabro-model/src/bootstrap_catalog.rs b/lib/foundation/fabro-model/src/bootstrap_catalog.rs deleted file mode 100644 index 4b150e0eb..000000000 --- a/lib/foundation/fabro-model/src/bootstrap_catalog.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Install/API-key validation access to the built-in catalog. -//! -//! Runtime request-serving paths should use a resolved catalog threaded -//! through their state. This module is the explicit hatch for setup flows that -//! need built-in provider/model metadata before project settings are loaded. - -use crate::Catalog; - -#[must_use] -pub fn catalog() -> &'static Catalog { - Catalog::builtin() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bootstrap_catalog_is_the_builtin_catalog() { - assert!(std::ptr::eq(catalog(), Catalog::builtin())); - } -} diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs deleted file mode 100644 index 56c0f5b0c..000000000 --- a/lib/foundation/fabro-model/src/catalog.rs +++ /dev/null @@ -1,7529 +0,0 @@ -use std::borrow::Cow; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::str::FromStr; -use std::sync::LazyLock; - -use rust_embed::RustEmbed; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use strum::VariantArray; -use toml::de::Error as TomlDeError; -use tracing::warn; - -use crate::Speed; -use crate::adapter::{AdapterKind, AgentProfileKind}; -use crate::codec::CodecKind; -use crate::ids::{ModelId, ProviderId}; -use crate::provider::Provider; -use crate::reasoning::ReasoningEffort; -use crate::types::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, -}; - -#[derive(RustEmbed)] -#[folder = "src/catalog/providers"] -struct BuiltinCatalogToml; - -/// TOML shape used by the model catalog builder. -/// -/// This deliberately lives in `fabro-model` instead of reusing -/// `fabro-config::LlmLayer`: `fabro-config` depends on `fabro-types`, and -/// `fabro-types` depends on `fabro-model`, so the catalog cannot depend on -/// `fabro-config` without creating a crate cycle. -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LlmCatalogSettings { - #[serde(default)] - pub providers: HashMap, - /// Legacy `[models.""]` input. Canonical settings place model rows - /// under their provider; this map is normalized before layers merge. - #[serde(default)] - pub models: HashMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProviderCatalogSettings { - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub adapter: Option, - /// Wire dialect for this provider's routes. Defaults to the adapter's - /// codec; only the default pairing is accepted today. - #[serde(default)] - pub codec: Option, - #[serde(default)] - pub agent_profile: Option, - #[serde(default)] - pub auth: Option, - #[serde(default)] - pub billing_policy: Option, - #[serde(default)] - pub api_key_url: Option, - #[serde(default)] - pub base_url: Option, - /// Unresolved interpolation source strings (literal text or - /// `{{ secrets.NAME }}` tokens), resolved at the credential boundary in - /// `fabro-auth`. - #[serde(default)] - pub extra_headers: Option>, - #[serde(default)] - pub priority: Option, - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub aliases: Option>, - /// Model declarations keyed by Fabro's canonical model slug. - #[serde(default)] - pub models: HashMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ModelCatalogSettings { - /// Provider used only by the temporary legacy top-level `[models]` - /// compatibility shape. Canonical provider-scoped rows leave this unset. - #[serde(default)] - pub provider: Option, - #[serde(default)] - pub api_id: Option, - /// Wire dialect for this model's route, overriding the provider's codec - /// (the multiplexer case). Only the adapter's default pairing is - /// accepted today. - #[serde(default)] - pub codec: Option, - /// Billing family for this model, overriding the provider's policy - /// (e.g. Anthropic cache billing for a Claude model served through an - /// aggregator whose other models bill OpenAI-style). - #[serde(default)] - pub billing_policy: Option, - #[serde(default)] - pub agent_profile: Option, - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub family: Option, - #[serde(default)] - pub training: Option, - #[serde(default, deserialize_with = "deserialize_knowledge_cutoff")] - pub knowledge_cutoff: Option, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub small_default: Option, - #[serde(default)] - pub probe: Option, - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub aliases: Option>, - #[serde(default)] - pub estimated_output_tps: Option, - #[serde(default)] - pub limits: Option, - #[serde(default)] - pub features: Option, - #[serde(default)] - pub controls: Option, - #[serde(default)] - pub costs: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelLimits { - #[serde(default)] - pub context_window: Option, - #[serde(default)] - pub max_output: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelFeatures { - #[serde(default)] - pub tools: Option, - #[serde(default)] - pub vision: Option, - #[serde(default)] - pub reasoning: Option, - /// Whether requests reason when no effort control is supplied. When - /// omitted, effort-capable models default to `true` and other models to - /// `false`. - #[serde(default)] - pub reasoning_by_default: Option, - #[serde(default)] - pub reasoning_effort: Option, - #[serde(default)] - pub prompt_cache: Option, - #[serde(default)] - pub cache_control_breakpoints: Option, - #[serde(default)] - pub sampling_params: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelControls { - #[serde(default)] - pub reasoning_effort: Option>, - #[serde(default)] - pub speed: Option>, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SettingsModelCostTable { - #[serde(flatten)] - pub base: CostRates, - #[serde(default)] - pub speed: Option>, -} - -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CostRates { - #[serde(default)] - pub input_cost_per_mtok: Option, - #[serde(default)] - pub output_cost_per_mtok: Option, - #[serde(default)] - pub cache_input_cost_per_mtok: Option, -} - -/// Where a provider's credential comes from. -/// -/// `Vault`/`Env` reference a stored secret resolved to an auth header. -/// `AwsSigv4` is an opaque source: the credential comes from the AWS default -/// credential chain and the request is SigV4-signed rather than carrying a -/// static secret. It is only valid on Bedrock providers, which catalog -/// validation enforces before adapter construction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(into = "String", try_from = "String")] -pub enum CredentialRef { - Vault(String), - Env(String), - AwsSigv4, -} - -impl std::fmt::Display for CredentialRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Vault(name) => write!(f, "vault:{name}"), - Self::Env(name) => write!(f, "env:{name}"), - Self::AwsSigv4 => write!(f, "aws_sigv4"), - } - } -} - -impl From for String { - fn from(value: CredentialRef) -> Self { - value.to_string() - } -} - -impl FromStr for CredentialRef { - type Err = CredentialRefParseError; - - fn from_str(value: &str) -> Result { - if let Some(name) = value.strip_prefix("vault:") { - if name.is_empty() { - return Err(CredentialRefParseError::EmptyVault); - } - return Ok(Self::Vault(name.to_string())); - } - if let Some(name) = value.strip_prefix("env:") { - if name.is_empty() { - return Err(CredentialRefParseError::EmptyEnv); - } - return Ok(Self::Env(name.to_string())); - } - if value == "aws_sigv4" { - return Ok(Self::AwsSigv4); - } - Err(CredentialRefParseError::Invalid) - } -} - -impl TryFrom for CredentialRef { - type Error = CredentialRefParseError; - - fn try_from(value: String) -> Result { - value.parse() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub enum CredentialRefParseError { - #[error("credential reference must be `vault:`, `env:`, or `aws_sigv4`")] - Invalid, - #[error("credential reference is missing a name after `vault:`")] - EmptyVault, - #[error("credential reference is missing a name after `env:`")] - EmptyEnv, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProviderAuthConfig { - /// Ordered credential sources; the first that resolves wins. Static secrets - /// use `env:` / `vault:`; AWS SigV4 (Bedrock) uses `aws_sigv4`, - /// which resolves opaquely from the AWS credential chain. - pub credentials: Vec, - #[serde(default)] - pub header: ApiKeyHeaderPolicy, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum ApiKeyHeaderPolicy { - #[default] - Bearer, - Custom { - name: String, - }, -} - -impl Serialize for ApiKeyHeaderPolicy { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - Self::Bearer => serializer.serialize_str("bearer"), - Self::Custom { name } => { - use serde::ser::SerializeMap; - - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("custom", name)?; - map.end() - } - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum ApiKeyHeaderPolicyInput { - String(String), - Table(ApiKeyHeaderPolicyTable), -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ApiKeyHeaderPolicyTable { - custom: String, -} - -impl<'de> Deserialize<'de> for ApiKeyHeaderPolicy { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - use serde::de::Error as _; - - match ApiKeyHeaderPolicyInput::deserialize(deserializer)? { - ApiKeyHeaderPolicyInput::String(value) if value == "bearer" => Ok(Self::Bearer), - ApiKeyHeaderPolicyInput::String(value) => Err(D::Error::custom(format!( - "API key header must be `bearer`, got `{value}`" - ))), - ApiKeyHeaderPolicyInput::Table(table) => { - validate_header_name(&table.custom).map_err(D::Error::custom)?; - Ok(Self::Custom { name: table.custom }) - } - } - } -} - -fn validate_header_name(name: &str) -> Result<(), &'static str> { - http::HeaderName::from_bytes(name.as_bytes()) - .map(|_| ()) - .map_err(|_| "custom header name must be a valid HTTP header name") -} - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum BillingPolicy { - #[serde(rename = "openai")] - #[strum(to_string = "openai")] - OpenAi, - Anthropic, - Gemini, - None, -} - -pub fn deserialize_knowledge_cutoff<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error as _; - use toml::value::Datetime; - - #[derive(Deserialize)] - #[serde(untagged)] - enum Either { - Toml(Datetime), - Str(String), - } - - let value = Option::::deserialize(deserializer)?; - match value { - None => Ok(None), - Some(Either::Str(value)) => Ok(Some(value)), - Some(Either::Toml(value)) => { - let date = value - .date - .ok_or_else(|| D::Error::custom("knowledge_cutoff requires a date component"))?; - Ok(Some(format!( - "{:04}-{:02}-{:02}", - date.year, date.month, date.day - ))) - } - } -} - -/// Global singleton catalog parsed from embedded provider TOML files. -static GLOBAL_CATALOG: LazyLock = LazyLock::new(|| { - Catalog::from_builtin_toml().expect("embedded provider TOML files must build a valid catalog") -}); - -/// A resolved fallback target: provider name + model ID. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FallbackTarget { - pub provider: ProviderId, - pub model: ModelId, -} - -impl FallbackTarget { - /// Build a target from anything that renders as a provider name and model - /// ID, so callers holding [`ProviderId`]/[`ModelId`] or bare passthrough - /// selectors all use one constructor. - pub fn new(provider: impl std::fmt::Display, model: impl std::fmt::Display) -> Self { - Self { - provider: ProviderId::new(provider.to_string()), - model: ModelId::new(model.to_string()), - } - } -} - -impl std::fmt::Display for FallbackTarget { - /// Renders as `provider:model`, matching the qualified form accepted by - /// model references. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", self.provider, self.model) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CatalogProvider { - pub id: ProviderId, - pub display_name: String, - pub adapter: AdapterKind, - /// Wire dialect driven by this provider's routes; models may override it - /// via [`CatalogModelSettings::codec`]. - pub codec: CodecKind, - pub agent_profile: AgentProfileKind, - pub auth: Option, - pub billing_policy: BillingPolicy, - pub api_key_url: Option, - pub base_url: Option, - /// Unresolved interpolation source strings (literal text or - /// `{{ secrets.NAME }}` tokens), resolved at the credential boundary in - /// `fabro-auth`. - pub extra_headers: HashMap, - pub priority: i32, - pub aliases: Vec, -} - -impl CatalogProvider { - #[must_use] - pub fn vault_secret_name(&self) -> Option<&str> { - self.auth - .as_ref()? - .credentials - .iter() - .find_map(|credential_ref| match credential_ref { - CredentialRef::Vault(name) => Some(name.as_str()), - CredentialRef::Env(_) | CredentialRef::AwsSigv4 => None, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CatalogModelControls { - pub reasoning_effort: Vec, - pub speed: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CatalogModelSettings { - pub api_id: String, - /// Wire dialect for this model's route (the provider codec unless the - /// model row overrides it). - pub codec: CodecKind, - /// Billing family for this model (the provider policy unless the model - /// row overrides it). - pub billing_policy: BillingPolicy, - pub agent_profile: AgentProfileKind, - /// Whether the provider route reasons when a request omits an effort - /// control. - pub reasoning_by_default: bool, - pub controls: CatalogModelControls, - pub speed_costs: HashMap, - probe: bool, -} - -#[derive(Debug, thiserror::Error)] -pub enum CatalogBuildError { - #[error("embedded built-in catalog contains no provider TOML files")] - NoBuiltinProviderFiles, - #[error("failed to read embedded provider TOML path '{path}' as UTF-8")] - InvalidBuiltinUtf8 { - path: String, - #[source] - source: std::str::Utf8Error, - }, - #[error("failed to parse embedded provider TOML '{path}'")] - InvalidBuiltinToml { - path: String, - #[source] - source: TomlDeError, - }, - #[error("embedded provider TOML '{path}' must define exactly one provider row")] - InvalidBuiltinProviderCount { path: String }, - #[error("embedded provider TOML '{path}' must define provider '{expected}', found '{actual}'")] - BuiltinProviderIdMismatch { - path: String, - expected: String, - actual: String, - }, - #[error( - "embedded provider TOML '{path}' contains model '{model}' for provider '{actual}', expected '{expected}'" - )] - BuiltinModelProviderMismatch { - path: String, - model: String, - expected: String, - actual: String, - }, - #[error("provider '{provider}' is missing required field '{field}'")] - MissingProviderField { - provider: ProviderId, - field: &'static str, - }, - #[error("provider '{provider}' uses unknown adapter '{adapter}'")] - UnknownAdapter { - provider: ProviderId, - adapter: String, - }, - #[error( - "provider '{provider}' configures codec '{codec}', but adapter '{adapter}' only supports '{expected}'" - )] - UnsupportedProviderCodec { - provider: ProviderId, - adapter: AdapterKind, - codec: CodecKind, - expected: CodecKind, - }, - #[error( - "model '{model}' configures codec '{codec}', but adapter '{adapter}' only supports '{expected}'" - )] - UnsupportedModelCodec { - model: String, - adapter: AdapterKind, - codec: CodecKind, - expected: CodecKind, - }, - #[error("provider '{provider}' API-key auth must declare at least one credential")] - EmptyApiKeyCredentials { provider: ProviderId }, - #[error( - "provider '{provider}' uses aws_sigv4 credentials, but adapter '{adapter}' does not support SigV4" - )] - UnsupportedAwsSigv4Credential { - provider: ProviderId, - adapter: AdapterKind, - }, - #[error("provider identifier '{identifier}' is declared by both '{first}' and '{second}'")] - DuplicateProviderIdentifier { - identifier: String, - first: ProviderId, - second: ProviderId, - }, - #[error("model '{model}' is missing required field '{field}'")] - MissingModelField { model: String, field: &'static str }, - #[error("model '{model}' references unknown provider '{provider}'")] - UnknownModelProvider { - model: String, - provider: ProviderId, - }, - #[error( - "provider '{provider}' model selector '{selector}' is declared by both '{first}' and '{second}'" - )] - DuplicateProviderModelSelector { - provider: ProviderId, - selector: String, - first: ModelId, - second: ModelId, - }, - #[error(transparent)] - LegacyModel(#[from] LegacyModelError), - #[error("provider '{provider}' model '{model}' has an empty api_id")] - EmptyModelApiId { - provider: ProviderId, - model: ModelId, - }, - #[error("provider '{provider}' has multiple default models: {models:?}")] - MultipleProviderDefaults { - provider: ProviderId, - models: Vec, - }, - #[error("provider '{provider}' has multiple small default models: {models:?}")] - MultipleProviderSmallDefaults { - provider: ProviderId, - models: Vec, - }, - #[error("catalog must contain at least one enabled default model")] - NoDefaultModel, - #[error("model '{model}' has invalid reasoning_effort '{value}'")] - InvalidReasoningEffort { - model: String, - value: String, - #[source] - source: strum::ParseError, - }, - #[error("model '{model}' declares reasoning_effort controls but features.reasoning is false")] - ReasoningEffortControlsWithoutReasoning { model: String }, - #[error("model '{model}' declares reasoning_effort feature but features.reasoning is false")] - ReasoningEffortWithoutReasoning { model: String }, - #[error("model '{model}' sets reasoning_by_default but features.reasoning is false")] - DefaultReasoningWithoutReasoning { model: String }, - #[error( - "model '{model}' declares cache_control_breakpoints but features.prompt_cache is false" - )] - CacheControlBreakpointsWithoutPromptCache { model: String }, - #[error( - "model '{model}' must declare at least one reasoning_effort when features.reasoning_effort is levels or always_adaptive" - )] - EmptyReasoningEffortControls { model: String }, - #[error("model '{model}' has invalid speed '{value}'")] - InvalidSpeed { - model: String, - value: String, - #[source] - source: strum::ParseError, - }, - #[error("model '{model}' must not declare standard in controls.speed")] - StandardSpeedControl { model: String }, - #[error("model '{model}' has costs.speed.{speed} without declaring controls.speed")] - UndeclaredSpeedCost { model: String, speed: Speed }, -} - -/// Failure to select one concrete provider/model offering. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum ModelSelectionError { - #[error("unknown model provider '{provider}'")] - UnknownProvider { provider: ProviderId }, - #[error("model provider '{provider}' is unavailable")] - ProviderUnavailable { provider: ProviderId }, - #[error("unknown model selector '{selector}'")] - UnknownSelector { selector: String }, - #[error("model selector '{selector}' is unknown on provider '{provider}'")] - UnknownSelectorOnProvider { - selector: String, - provider: ProviderId, - }, - #[error( - "model selector '{selector}' is known but has no offering on an eligible provider; available providers: {providers:?}" - )] - NoEligibleOffering { - selector: String, - providers: Vec, - }, - #[error( - "no default model is available on an eligible provider; providers with defaults: {providers:?}" - )] - NoDefaultModel { providers: Vec }, -} - -/// One provider/model pair chosen by [`Catalog::resolve_selection`]. The -/// model is the canonical catalog ID when the selector matched an offering, -/// or the caller's selector passed through verbatim when it did not. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SelectedModel { - pub provider: ProviderId, - pub model: 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<(ProviderId, ModelId), CatalogModelSettings>, - offering_index: HashMap<(ProviderId, ModelId), usize>, - provider_selector_index: HashMap<(ProviderId, String), usize>, - canonical_candidates: HashMap>, - alias_candidates: HashMap>, - provider_aliases: HashMap, - provider_index: HashMap, -} - -impl Catalog { - /// Returns a reference to the global built-in catalog (loaded once from - /// embedded provider TOML files). - #[must_use] - pub fn builtin() -> &'static Self { - &GLOBAL_CATALOG - } - - pub fn from_settings(settings: &LlmCatalogSettings) -> Result { - let settings = normalize_catalog_settings(settings.clone(), None)?; - let mut providers = build_providers(&settings)?; - providers.sort_by(provider_order); - - let mut provider_index = HashMap::new(); - for (idx, provider) in providers.iter().enumerate() { - provider_index.insert(provider.id.clone(), idx); - } - - let provider_aliases = build_provider_aliases(&providers)?; - let known_providers: HashSet<&str> = - settings.providers.keys().map(String::as_str).collect(); - let enabled_providers: HashSet<&str> = providers - .iter() - .map(|provider| provider.id.as_str()) - .collect(); - let provider_by_id: HashMap<&str, &CatalogProvider> = providers - .iter() - .map(|provider| (provider.id.as_str(), provider)) - .collect(); - - let mut models_with_settings = Vec::new(); - let mut model_identifiers = HashMap::>::new(); - let mut defaults_by_provider = HashMap::>::new(); - let mut small_defaults_by_provider = HashMap::>::new(); - - let mut provider_ids = settings.providers.keys().cloned().collect::>(); - provider_ids.sort_unstable(); - for provider_id in provider_ids { - if !known_providers.contains(provider_id.as_str()) - || !enabled_providers.contains(provider_id.as_str()) - { - continue; - } - let provider = provider_by_id - .get(provider_id.as_str()) - .expect("enabled provider ID should have provider metadata"); - let provider_settings = settings - .providers - .get(&provider_id) - .expect("provider ID came from settings map keys"); - let identifiers = model_identifiers.entry(provider.id.clone()).or_default(); - let mut model_ids = provider_settings.models.keys().cloned().collect::>(); - model_ids.sort_unstable(); - for model_id in model_ids { - let model_settings = provider_settings - .models - .get(&model_id) - .expect("model ID came from provider model map keys"); - if model_settings.enabled == Some(false) { - continue; - } - - if let Some((_, canonical_model)) = legacy_builtin_model(&model_id) { - return Err(LegacyModelError::LegacyIdentifierAsModelId { - identifier: model_id, - provider: provider.id.clone(), - model: canonical_model, - } - .into()); - } - - let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; - register_model_identifier( - identifiers, - model.id.as_str().to_string(), - model.id.clone(), - &model.provider, - )?; - for alias in &model.aliases { - register_model_identifier( - identifiers, - alias.clone(), - model.id.clone(), - &model.provider, - )?; - } - register_model_identifier( - identifiers, - resolved_settings.api_id.clone(), - model.id.clone(), - &model.provider, - )?; - - if model.default { - defaults_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.clone()); - } - if model.small_default { - small_defaults_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.clone()); - } - models_with_settings.push((model, resolved_settings)); - } - } - - for (provider, defaults) in defaults_by_provider { - if defaults.len() > 1 { - return Err(CatalogBuildError::MultipleProviderDefaults { - provider, - models: defaults.into_iter().map(ModelId::into_inner).collect(), - }); - } - } - for (provider, small_defaults) in small_defaults_by_provider { - if small_defaults.len() > 1 { - return Err(CatalogBuildError::MultipleProviderSmallDefaults { - provider, - models: small_defaults - .into_iter() - .map(ModelId::into_inner) - .collect(), - }); - } - } - if !models_with_settings.iter().any(|(model, _)| model.default) { - return Err(CatalogBuildError::NoDefaultModel); - } - - models_with_settings.sort_by(|(left, _), (right, _)| { - provider_index[&left.provider] - .cmp(&provider_index[&right.provider]) - .then_with(|| left.id.cmp(&right.id)) - }); - warn_multiple_probe_models(&models_with_settings); - let (offering_index, provider_selector_index, canonical_candidates, alias_candidates) = - build_model_indexes(&models_with_settings); - let mut model_settings_by_offering = HashMap::new(); - let mut models = Vec::new(); - for (model, settings) in models_with_settings { - model_settings_by_offering.insert((model.provider.clone(), model.id.clone()), settings); - models.push(model); - } - - Ok(Self { - models, - providers, - model_settings: model_settings_by_offering, - offering_index, - provider_selector_index, - canonical_candidates, - alias_candidates, - provider_aliases, - provider_index, - }) - } - - pub fn from_builtin_with_overrides( - overrides: &LlmCatalogSettings, - ) -> Result { - let builtins = normalize_catalog_settings(Self::builtin_settings()?, None)?; - let overrides = normalize_catalog_settings(overrides.clone(), Some(&builtins))?; - let settings = merge_catalog_settings(overrides, builtins); - Self::from_settings(&settings) - } - - /// Builds a fresh catalog from embedded provider TOML without user - /// overrides. - pub fn from_builtin() -> Result { - Self::from_builtin_toml() - } - - fn builtin_settings() -> Result { - let mut layer = LlmCatalogSettings::default(); - let mut paths = BuiltinCatalogToml::iter() - .filter(|path| path.ends_with(".toml")) - .map(Cow::into_owned) - .collect::>(); - paths.sort_unstable(); - if paths.is_empty() { - return Err(CatalogBuildError::NoBuiltinProviderFiles); - } - - for path in paths { - let file = BuiltinCatalogToml::get(&path) - .expect("path came from embedded built-in catalog iterator"); - let source = std::str::from_utf8(file.data.as_ref()).map_err(|source| { - CatalogBuildError::InvalidBuiltinUtf8 { - path: path.clone(), - source, - } - })?; - let fragment: LlmCatalogSettings = - toml::from_str(source).map_err(|source| CatalogBuildError::InvalidBuiltinToml { - path: path.clone(), - source, - })?; - validate_builtin_fragment(&path, &fragment)?; - layer.providers.extend(fragment.providers); - layer.models.extend(fragment.models); - } - - normalize_catalog_settings(layer, None) - } - - fn from_builtin_toml() -> Result { - Self::from_settings(&Self::builtin_settings()?) - } - - /// Test-only shorthand for selecting from every enabled catalog provider. - /// - /// Production callers must supply an explicit ready-provider snapshot to - /// [`Catalog::select`] or use a provider-scoped lookup. - #[cfg(test)] - #[must_use] - pub(crate) fn get(&self, selector: &str) -> Option<&Model> { - self.candidate_indices(selector) - .and_then(|indices| indices.first()) - .and_then(|idx| self.models.get(*idx)) - } - - /// Look up a canonical ID, alias, or API ID on exactly one provider, - /// without considering provider availability. Exact provider-scoped - /// identifiers win before historical built-in API identifiers normalize - /// to their canonical model slug. - #[must_use] - pub fn get_on_provider(&self, provider: &ProviderId, selector: &str) -> Option<&Model> { - let provider = self.provider(provider)?; - let lookup = |selector: &str| { - self.provider_selector_index - .get(&(provider.id.clone(), selector.to_string())) - }; - let index = - lookup(selector).or_else(|| lookup(&normalize_legacy_builtin_selector(selector)))?; - self.models.get(*index) - } - - /// Look up a canonical offering by its composite identity. - #[must_use] - pub fn offering(&self, provider: &ProviderId, model: &ModelId) -> Option<&Model> { - let provider = self.provider(provider)?; - self.offering_index - .get(&(provider.id.clone(), model.clone())) - .and_then(|idx| self.models.get(*idx)) - } - - /// Look up a provider by ID or alias, failing when the catalog has no such - /// provider. - pub fn require_provider( - &self, - provider: &ProviderId, - ) -> Result<&CatalogProvider, ModelSelectionError> { - self.provider(provider) - .ok_or_else(|| ModelSelectionError::UnknownProvider { - provider: provider.clone(), - }) - } - - /// Canonicalize a provider name or alias to its catalog ID. - pub fn provider_id(&self, name: &str) -> Result { - Ok(self.require_provider(&ProviderId::from(name))?.id.clone()) - } - - /// Resolve a canonical ID, alias, or API ID on exactly one provider. - pub fn resolve_on_provider( - &self, - provider: &ProviderId, - selector: &str, - ) -> Result<&Model, ModelSelectionError> { - let provider = self.require_provider(provider)?; - if let Some(model) = self.get_on_provider(&provider.id, selector) { - return Ok(model); - } - Err(ModelSelectionError::UnknownSelectorOnProvider { - selector: selector.to_string(), - provider: provider.id.clone(), - }) - } - - /// Select one concrete offering for a selector and ready-provider - /// snapshot. - /// - /// Historical built-in API identifiers normalize to their canonical model - /// slug before selection. - /// - /// An explicit provider is a pin and also permits that provider's API IDs. - /// Unqualified selection checks canonical IDs before aliases and uses the - /// catalog's provider priority ordering. - pub fn select<'a>( - &'a self, - selector: &str, - explicit_provider: Option<&ProviderId>, - eligible_providers: &HashSet, - ) -> Result<&'a Model, ModelSelectionError> { - let eligible = eligible_providers - .iter() - .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) - .collect::>(); - - if let Some(explicit_provider) = explicit_provider { - let provider = self.require_provider(explicit_provider)?; - if !eligible.contains(&provider.id) { - return Err(ModelSelectionError::ProviderUnavailable { - provider: provider.id.clone(), - }); - } - return self.resolve_on_provider(&provider.id, selector); - } - - let normalized_selector = normalize_legacy_builtin_selector(selector); - let canonical = self - .canonical_candidates - .get(&ModelId::new(normalized_selector.as_ref())); - if let Some(indices) = canonical { - if let Some(model) = indices - .iter() - .filter_map(|idx| self.models.get(*idx)) - .find(|model| eligible.contains(&model.provider)) - { - return Ok(model); - } - } - - let aliases = self.alias_candidates.get(normalized_selector.as_ref()); - if let Some(indices) = aliases { - if let Some(model) = indices - .iter() - .filter_map(|idx| self.models.get(*idx)) - .find(|model| eligible.contains(&model.provider)) - { - return Ok(model); - } - } - - let mut providers = Vec::new(); - for index in canonical - .into_iter() - .flatten() - .chain(aliases.into_iter().flatten()) - { - let Some(model) = self.models.get(*index) else { - continue; - }; - if !providers.contains(&model.provider) { - providers.push(model.provider.clone()); - } - } - if !providers.is_empty() { - return Err(ModelSelectionError::NoEligibleOffering { - selector: selector.to_string(), - providers, - }); - } - - Err(ModelSelectionError::UnknownSelector { - selector: selector.to_string(), - }) - } - - #[must_use] - pub fn all_provider_ids(&self) -> HashSet { - self.providers - .iter() - .map(|provider| provider.id.clone()) - .collect() - } - - /// Canonicalize a model selector to its catalog model ID, preferring the - /// given provider's offering. Unknown selectors pass through verbatim. - /// - /// Model-keyed fallback chains are written and read through this one - /// function so a configured chain key and a dispatch-time lookup cannot - /// silently disagree. - #[must_use] - pub fn canonical_model_id(&self, provider: &ProviderId, selector: &str) -> String { - self.get_on_provider(provider, selector).map_or_else( - || { - self.select(selector, None, &self.all_provider_ids()) - .map_or_else(|_| selector.to_string(), |offering| offering.id.to_string()) - }, - |offering| offering.id.to_string(), - ) - } - - /// Select the highest-priority default model on an eligible provider. - pub fn select_default( - &self, - eligible_providers: &HashSet, - ) -> Result<&Model, ModelSelectionError> { - let eligible = eligible_providers - .iter() - .filter_map(|provider| self.provider(provider).map(|provider| provider.id.clone())) - .collect::>(); - if let Some(model) = self - .models - .iter() - .find(|model| model.default && eligible.contains(&model.provider)) - { - return Ok(model); - } - let mut providers = self - .models - .iter() - .filter(|model| model.default) - .map(|model| model.provider.clone()) - .collect::>(); - providers.sort(); - providers.dedup(); - Err(ModelSelectionError::NoDefaultModel { providers }) - } - - /// Canonicalize a provider ID or alias and require it to be in the - /// eligible snapshot. - pub fn ready_provider( - &self, - provider: &ProviderId, - eligible_providers: &HashSet, - ) -> Result { - let provider = self.require_provider(provider)?; - let ready = eligible_providers.iter().any(|eligible| { - self.provider(eligible) - .is_some_and(|eligible| eligible.id == provider.id) - }); - if !ready { - return Err(ModelSelectionError::ProviderUnavailable { - provider: provider.id.clone(), - }); - } - Ok(provider.id.clone()) - } - - /// Resolve an optional selector to one provider/model pair, applying the - /// passthrough policy shared by every dispatch boundary: - /// - /// - A selector known to the catalog resolves to its canonical offering. - /// - An unknown selector pinned to a provider passes through verbatim on - /// that provider. - /// - An unqualified unknown selector passes through on the default - /// provider. - /// - No selector picks the default offering (of the pinned provider, when - /// one is given). - pub fn resolve_selection( - &self, - selector: Option<&str>, - explicit_provider: Option<&ProviderId>, - eligible_providers: &HashSet, - ) -> Result { - let Some(selector) = selector else { - let eligible = match explicit_provider { - Some(provider) => { - HashSet::from([self.ready_provider(provider, eligible_providers)?]) - } - None => eligible_providers.clone(), - }; - let offering = self.select_default(&eligible)?; - return Ok(SelectedModel { - provider: offering.provider.clone(), - model: offering.id.to_string(), - }); - }; - match self.select(selector, explicit_provider, eligible_providers) { - Ok(offering) => Ok(SelectedModel { - provider: offering.provider.clone(), - model: offering.id.to_string(), - }), - Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => { - Ok(SelectedModel { - provider, - model: selector.to_string(), - }) - } - Err(ModelSelectionError::UnknownSelector { .. }) => { - let default = self.select_default(eligible_providers)?; - Ok(SelectedModel { - provider: default.provider.clone(), - model: selector.to_string(), - }) - } - Err(error) => Err(error), - } - } - - /// Resolve a selection against a preferred provider snapshot, falling back - /// to every provider in the catalog only when the preferred set cannot - /// supply the requested provider or model. - /// - /// This is useful for readiness checks: ready providers remain preferred, - /// while a catalog-only offering can still be selected so the caller can - /// report why its provider is unavailable. Semantic failures such as an - /// unknown provider do not fall back. - pub fn resolve_selection_with_catalog_fallback( - &self, - selector: Option<&str>, - explicit_provider: Option<&ProviderId>, - preferred_providers: &HashSet, - ) -> Result { - match self.resolve_selection(selector, explicit_provider, preferred_providers) { - Ok(selected) => Ok(selected), - Err( - ModelSelectionError::ProviderUnavailable { .. } - | ModelSelectionError::NoEligibleOffering { .. } - | ModelSelectionError::NoDefaultModel { .. }, - ) => self.resolve_selection(selector, explicit_provider, &self.all_provider_ids()), - Err(error) => Err(error), - } - } - - #[must_use] - pub fn is_model_selector(&self, selector: &str) -> bool { - self.candidate_indices(selector).is_some() - } - - fn candidate_indices(&self, selector: &str) -> Option<&Vec> { - let selector = normalize_legacy_builtin_selector(selector); - self.canonical_candidates - .get(&ModelId::new(selector.as_ref())) - .or_else(|| self.alias_candidates.get(selector.as_ref())) - } - - #[must_use] - pub fn providers(&self) -> &[CatalogProvider] { - &self.providers - } - - #[must_use] - pub fn provider_summaries(&self, configured: &HashSet) -> Vec { - #[derive(Default)] - struct Stats { - model_count: u32, - default_model: Option, - } - - let mut stats_by_provider = HashMap::::new(); - for model in &self.models { - 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.to_string()); - } - } - - self.providers - .iter() - .map(|provider| { - let stats = stats_by_provider.remove(&provider.id).unwrap_or_default(); - Provider::from_catalog( - provider, - stats.model_count, - stats.default_model, - configured.contains(&provider.id), - ) - }) - .collect() - } - - #[must_use] - pub fn provider(&self, id: &ProviderId) -> Option<&CatalogProvider> { - let canonical = self.provider_aliases.get(id.as_str()).unwrap_or(id); - self.provider_index - .get(canonical) - .and_then(|idx| self.providers.get(*idx)) - } - - #[must_use] - pub fn provider_vault_secret_name(&self, id: &ProviderId) -> Option<&str> { - self.provider(id)?.vault_secret_name() - } - - #[must_use] - pub fn settings_for(&self, model: &Model) -> Option<&CatalogModelSettings> { - self.model_settings - .get(&(model.provider.clone(), model.id.clone())) - } - - /// Test-only shorthand for settings on the highest-priority enabled - /// offering. Production callers must retain the resolved offering and use - /// [`Catalog::settings_for`]. - #[cfg(test)] - #[must_use] - pub(crate) fn model_settings( - &self, - selector: impl AsRef, - ) -> Option<&CatalogModelSettings> { - self.get(selector.as_ref()) - .and_then(|model| self.settings_for(model)) - } - - #[must_use] - pub fn model_settings_on_provider( - &self, - provider: &ProviderId, - selector: &str, - ) -> Option<&CatalogModelSettings> { - let model = self.get_on_provider(provider, selector)?; - self.settings_for(model) - } - - #[must_use] - pub fn effective_agent_profile( - &self, - provider_id: &ProviderId, - model_id_or_alias: Option<&str>, - ) -> Option { - let provider = self.provider(provider_id)?; - let model_profile = model_id_or_alias - .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) - .and_then(|model| self.settings_for(model)) - .map(|settings| settings.agent_profile); - Some(model_profile.unwrap_or(provider.agent_profile)) - } - - /// The codec a request for `model_id_or_alias` on `provider_id` speaks: - /// the model row's codec when one is configured, otherwise the - /// provider's. - #[must_use] - pub fn effective_codec( - &self, - provider_id: &ProviderId, - model_id_or_alias: Option<&str>, - ) -> Option { - let provider = self.provider(provider_id)?; - let model_codec = model_id_or_alias - .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) - .and_then(|model| self.settings_for(model)) - .map(|settings| settings.codec); - Some(model_codec.unwrap_or(provider.codec)) - } - - /// The billing family for `model_id_or_alias` on `provider_id`: the model - /// row's policy when one is configured, otherwise the provider's (unknown - /// passthrough model ids keep the provider policy). - #[must_use] - pub fn effective_billing_policy( - &self, - provider_id: &ProviderId, - model_id_or_alias: Option<&str>, - ) -> Option { - let provider = self.provider(provider_id)?; - let model_policy = model_id_or_alias - .and_then(|model_id| self.get_on_provider(&provider.id, model_id)) - .and_then(|model| self.settings_for(model)) - .map(|settings| settings.billing_policy); - Some(model_policy.unwrap_or(provider.billing_policy)) - } - - /// List all models, optionally filtered by provider. - #[must_use] - pub fn list(&self, provider: Option<&ProviderId>) -> Vec<&Model> { - match provider { - None => self.models.iter().collect(), - Some(p) => { - let provider_id = self.provider(p).map_or(p, |provider| &provider.id); - self.models - .iter() - .filter(|m| &m.provider == provider_id) - .collect() - } - } - } - - /// The overall default model (first model marked `default` in catalog). - /// - /// # Panics - /// Panics if the catalog contains no default model. - #[must_use] - pub fn default_model(&self) -> &Model { - self.providers - .iter() - .find_map(|provider| self.default_for_provider(&provider.id)) - .or_else(|| self.models.iter().find(|m| m.default)) - .expect("catalog must contain at least one default model") - } - - /// The default model for a specific provider. - #[must_use] - pub fn default_for_provider(&self, p: &ProviderId) -> Option<&Model> { - let provider_id = self - .provider(p) - .map_or_else(|| p.clone(), |provider| provider.id.clone()); - self.models - .iter() - .find(|m| m.provider == provider_id && m.default) - } - - /// Small default model for a provider — the small/cheap utility model used - /// for metadata enrichment. `None` when the provider marks no small - /// default. Deliberately does not substitute the provider's normal - /// default, which is typically a large reasoning model: callers asking for - /// a small model give it a small token budget and a short timeout, and a - /// flagship model silently exceeds both. - #[must_use] - pub fn small_default_for_provider(&self, p: &ProviderId) -> Option<&Model> { - let provider_id = self.provider(p).map_or(p, |provider| &provider.id); - self.models - .iter() - .find(|m| &m.provider == provider_id && m.small_default) - } - - /// Default model for the best-available provider (based on API keys), - /// falling back to the global catalog default. - #[must_use] - #[expect( - clippy::disallowed_methods, - reason = "Catalog default selection intentionally checks provider API-key env refs." - )] - pub fn default_from_env(&self) -> &Model { - let configured = self - .providers - .iter() - .filter(|provider| { - provider.auth.as_ref().is_some_and(|auth| { - auth.credentials.iter().any(|credential| { - matches!(credential, CredentialRef::Env(name) if std::env::var(name).is_ok()) - }) - }) - }) - .map(|provider| provider.id.clone()) - .collect::>(); - self.default_for_configured_ids(&configured) - } - - /// Default model for the best-available built-in provider IDs, falling - /// back to the global catalog default. - #[must_use] - pub fn default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model { - if configured.is_empty() { - return self.default_model(); - } - let configured = self.canonical_provider_ids(configured); - self.providers - .iter() - .filter(|provider| configured.contains(&provider.id)) - .find_map(|provider| self.default_for_provider(&provider.id)) - .unwrap_or_else(|| self.default_model()) - } - - /// Small default model for the best-available built-in provider IDs. - /// - /// Configured providers that mark no small default are skipped in favour - /// of a lower-priority provider that has one. Only when none of them does - /// is the ordinary default used. - #[must_use] - pub fn small_default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model { - if configured.is_empty() { - return self.default_model(); - } - let configured = self.canonical_provider_ids(configured); - let mut fallback = None; - for model in self - .models - .iter() - .filter(|model| configured.contains(&model.provider)) - { - if model.small_default { - return model; - } - if model.default && fallback.is_none() { - fallback = Some(model); - } - } - fallback.unwrap_or_else(|| self.default_model()) - } - - fn canonical_provider_ids(&self, provider_ids: &[ProviderId]) -> HashSet { - provider_ids - .iter() - .filter_map(|id| self.provider(id).map(|provider| provider.id.clone())) - .collect() - } - - /// Probe model for a provider — the cheapest model suitable for - /// connectivity checks. Falls back to the provider's default when no - /// explicit override is configured. - #[must_use] - pub fn probe_for_provider(&self, p: &ProviderId) -> Option<&Model> { - let provider_id = self.provider(p).map_or(p, |provider| &provider.id); - if let Some(model) = self.models.iter().find(|model| { - &model.provider == provider_id - && self - .settings_for(model) - .is_some_and(|settings| settings.probe) - }) { - return Some(model); - } - self.default_for_provider(provider_id) - } - - /// Find the closest model on a target provider matching the reference's - /// capabilities. - /// - /// Hard-filters on `features.tools`, `features.vision`, and - /// `features.reasoning`. Among matches, picks the closest by - /// `costs.input_cost_per_mtok` (absolute diff). - #[must_use] - pub fn closest(&self, target: &ProviderId, reference: &Model) -> Option<&Model> { - let target = self - .provider(target) - .map_or(target, |provider| &provider.id); - self.models - .iter() - .filter(|m| { - &m.provider == target - && m.features.tools == reference.features.tools - && m.features.vision == reference.features.vision - && m.features.reasoning == reference.features.reasoning - }) - .min_by(|a, b| { - let ref_cost = reference.costs.input_cost_per_mtok.unwrap_or(0.0); - let cost_a = (a.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); - let cost_b = (b.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); - cost_a - .partial_cmp(&cost_b) - .unwrap_or(std::cmp::Ordering::Equal) - }) - } -} - -type ModelIndexes = ( - HashMap<(ProviderId, ModelId), usize>, - HashMap<(ProviderId, String), usize>, - HashMap>, - HashMap>, -); - -fn build_model_indexes(models: &[(Model, CatalogModelSettings)]) -> ModelIndexes { - let mut offering_index = HashMap::new(); - let mut provider_selector_index = HashMap::new(); - let mut canonical_candidates = HashMap::>::new(); - let mut alias_candidates = HashMap::>::new(); - for (idx, (model, settings)) in models.iter().enumerate() { - offering_index.insert((model.provider.clone(), model.id.clone()), idx); - provider_selector_index - .insert((model.provider.clone(), model.id.as_str().to_string()), idx); - canonical_candidates - .entry(model.id.clone()) - .or_default() - .push(idx); - for alias in &model.aliases { - provider_selector_index.insert((model.provider.clone(), alias.clone()), idx); - alias_candidates.entry(alias.clone()).or_default().push(idx); - } - provider_selector_index.insert((model.provider.clone(), settings.api_id.clone()), idx); - } - ( - offering_index, - provider_selector_index, - canonical_candidates, - alias_candidates, - ) -} - -fn normalize_catalog_settings( - mut settings: LlmCatalogSettings, - known: Option<&LlmCatalogSettings>, -) -> Result { - reject_scoped_provider_fields(&settings)?; - - let legacy_models = std::mem::take(&mut settings.models); - if legacy_models.is_empty() { - return Ok(settings); - } - let mut legacy_models = legacy_models.into_iter().collect::>(); - legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right)); - - let mut index = LegacyModelIndex::default(); - index.add_settings(&settings); - if let Some(known) = known { - index.add_settings(known); - } - - for (legacy_id, mut model_settings) in legacy_models { - let explicit_provider = model_settings.provider.take(); - let (provider, model_id) = index.resolve(&legacy_id, explicit_provider.as_deref())?; - - if !settings.providers.contains_key(provider.as_str()) - && !known.is_some_and(|known| known.providers.contains_key(provider.as_str())) - { - return Err(CatalogBuildError::UnknownModelProvider { - model: legacy_id, - provider, - }); - } - - let provider_settings = settings.providers.entry(provider.to_string()).or_default(); - if provider_settings.models.contains_key(model_id.as_str()) { - return Err(LegacyModelError::DuplicateModel { - provider, - model: model_id, - } - .into()); - } - provider_settings - .models - .insert(model_id.into_inner(), model_settings); - } - Ok(settings) -} - -fn reject_scoped_provider_fields(settings: &LlmCatalogSettings) -> Result<(), LegacyModelError> { - for (provider, settings) in &settings.providers { - for (model, settings) in &settings.models { - if settings.provider.is_some() { - return Err(LegacyModelError::ScopedModelDeclaresProvider { - provider: ProviderId::new(provider.clone()), - model: ModelId::new(model.clone()), - }); - } - } - } - Ok(()) -} - -/// Failure to resolve a legacy top-level `[models.]` row onto its -/// provider. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum LegacyModelError { - #[error("failed to inspect the built-in model catalog: {message}")] - BuiltinCatalog { message: String }, - #[error( - "legacy built-in model identifier '{identifier}' cannot be used as a canonical model ID under provider '{provider}'; use '{model}'" - )] - LegacyIdentifierAsModelId { - identifier: String, - provider: ProviderId, - model: ModelId, - }, - #[error("legacy model row '{model}' omits provider and does not match a unique known offering")] - UnknownModel { model: String }, - #[error( - "legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}" - )] - AmbiguousModel { - model: String, - candidates: Vec<(ProviderId, ModelId)>, - }, - #[error("legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}")] - AmbiguousAlias { - provider: ProviderId, - selector: String, - models: Vec, - }, - #[error("provider-scoped model '{provider}/{model}' must not declare a provider field")] - ScopedModelDeclaresProvider { - provider: ProviderId, - model: ModelId, - }, - #[error( - "provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax" - )] - DuplicateModel { - provider: ProviderId, - model: ModelId, - }, -} - -/// Identifier/alias view used to resolve legacy top-level `[models.]` -/// rows onto their provider before provider-scoped settings merge. -/// -/// Both the settings-layer normalization in `fabro-config` and catalog-build -/// normalization here feed this index: local entries first, lower-precedence -/// known entries (e.g. the built-in catalog) after. Canonical IDs always win -/// over aliases; alias ties resolve to the first entry added. -#[derive(Debug, Default)] -pub struct LegacyModelIndex { - providers: Vec, -} - -#[derive(Debug)] -struct LegacyProviderEntry { - id: ProviderId, - aliases: Vec, - models: Vec, -} - -#[derive(Debug)] -struct LegacyModelEntry { - id: ModelId, - aliases: Vec, -} - -impl LegacyModelIndex { - pub fn add_provider( - &mut self, - id: ProviderId, - aliases: Vec, - models: impl IntoIterator)>, - ) { - self.providers.push(LegacyProviderEntry { - id, - aliases, - models: models - .into_iter() - .map(|(id, aliases)| LegacyModelEntry { id, aliases }) - .collect(), - }); - } - - fn add_settings(&mut self, settings: &LlmCatalogSettings) { - let mut provider_ids = settings.providers.keys().collect::>(); - provider_ids.sort_unstable(); - for provider_id in provider_ids { - let provider = &settings.providers[provider_id]; - let mut model_ids = provider.models.keys().collect::>(); - model_ids.sort_unstable(); - self.add_provider( - ProviderId::new(provider_id.clone()), - provider.aliases.clone().unwrap_or_default(), - model_ids.into_iter().map(|model_id| { - let model = &provider.models[model_id]; - ( - ModelId::new(model_id.clone()), - model.aliases.clone().unwrap_or_default(), - ) - }), - ); - } - } - - /// Append the built-in catalog as the lowest-precedence tier. Includes - /// disabled providers because config compatibility normalization happens - /// before runtime availability is known. - pub fn with_builtin(mut self) -> Result { - let builtin = - Catalog::builtin_settings().map_err(|error| LegacyModelError::BuiltinCatalog { - message: error.to_string(), - })?; - self.add_settings(&builtin); - Ok(self) - } - - /// Resolve one legacy row to its provider-scoped address. Historical - /// built-in identifiers normalize to their canonical slug and use their - /// historical provider when no explicit provider is present. Other - /// unknown explicit providers or model selectors pass through verbatim; - /// rows without an explicit provider must match exactly one known - /// offering. - pub fn resolve( - &self, - legacy_id: &str, - explicit_provider: Option<&str>, - ) -> Result<(ProviderId, ModelId), LegacyModelError> { - if let Some((historical_provider, model)) = legacy_builtin_model(legacy_id) { - let provider = explicit_provider.map_or(historical_provider, |explicit| { - self.canonical_provider(explicit) - .unwrap_or_else(|| ProviderId::new(explicit)) - }); - return Ok((provider, model)); - } - if let Some(explicit) = explicit_provider { - let provider = self - .canonical_provider(explicit) - .unwrap_or_else(|| ProviderId::new(explicit)); - let model = self - .canonical_model_on(&provider, legacy_id)? - .unwrap_or_else(|| ModelId::new(legacy_id)); - return Ok((provider, model)); - } - let candidates = self.candidates(legacy_id); - match candidates.as_slice() { - [(provider, model)] => Ok((provider.clone(), model.clone())), - [] => Err(LegacyModelError::UnknownModel { - model: legacy_id.to_string(), - }), - _ => Err(LegacyModelError::AmbiguousModel { - model: legacy_id.to_string(), - candidates, - }), - } - } - - fn canonical_provider(&self, selector: &str) -> Option { - self.providers - .iter() - .find(|provider| provider.id.as_str() == selector) - .or_else(|| { - self.providers - .iter() - .find(|provider| provider.aliases.iter().any(|alias| alias == selector)) - }) - .map(|provider| provider.id.clone()) - } - - fn canonical_model_on( - &self, - provider: &ProviderId, - selector: &str, - ) -> Result, LegacyModelError> { - let models = || { - self.providers - .iter() - .filter(|entry| entry.id == *provider) - .flat_map(|entry| entry.models.iter()) - }; - if models().any(|model| model.id.as_str() == selector) { - return Ok(Some(ModelId::new(selector))); - } - let matches = models() - .filter(|model| model.aliases.iter().any(|alias| alias == selector)) - .map(|model| model.id.clone()) - .collect::>(); - match matches.len() { - 0 => Ok(None), - 1 => Ok(matches.into_iter().next()), - _ => Err(LegacyModelError::AmbiguousAlias { - provider: provider.clone(), - selector: selector.to_string(), - models: matches.into_iter().collect(), - }), - } - } - - fn candidates(&self, selector: &str) -> Vec<(ProviderId, ModelId)> { - let canonical = self - .providers - .iter() - .filter(|entry| { - entry - .models - .iter() - .any(|model| model.id.as_str() == selector) - }) - .map(|entry| (entry.id.clone(), ModelId::new(selector))) - .collect::>(); - if !canonical.is_empty() { - return canonical.into_iter().collect(); - } - self.providers - .iter() - .flat_map(|entry| { - entry - .models - .iter() - .filter(|model| model.aliases.iter().any(|alias| alias == selector)) - .map(|model| (entry.id.clone(), model.id.clone())) - }) - .collect::>() - .into_iter() - .collect() - } -} - -/// Historical built-in catalog keys from before Fabro separated canonical -/// model slugs from provider API identifiers. The provider records the key's -/// original offering for legacy catalog-row normalization; runtime selectors -/// normalize to the model slug and use normal provider-aware selection. -const LEGACY_BUILTIN_MODEL_IDENTIFIERS: &[(&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-fable-5", "openrouter", "claude-fable-5"), - ("anthropic/claude-opus-4-8", "openrouter", "claude-opus-4-8"), - ("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.6-sol", "openrouter", "gpt-5.6-sol"), - ("openai/gpt-5.6-terra", "openrouter", "gpt-5.6-terra"), - ("openai/gpt-5.6-luna", "openrouter", "gpt-5.6-luna"), - ("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-120b-a12b", - ), - ("mistralai/devstral-2512", "openrouter", "devstral-2512"), -]; - -/// Return the historical provider and canonical model slug for a legacy -/// built-in catalog key. -#[must_use] -pub fn legacy_builtin_model(identifier: &str) -> Option<(ProviderId, ModelId)> { - LEGACY_BUILTIN_MODEL_IDENTIFIERS - .iter() - .find(|(legacy, _, _)| *legacy == identifier) - .map(|(_, provider, model)| (ProviderId::new(*provider), ModelId::new(*model))) -} - -fn normalize_legacy_builtin_selector(selector: &str) -> Cow<'_, str> { - legacy_builtin_model(selector).map_or_else( - || Cow::Borrowed(selector), - |(_, model)| Cow::Owned(model.into_inner()), - ) -} - -fn merge_catalog_settings( - higher: LlmCatalogSettings, - mut fallback: LlmCatalogSettings, -) -> LlmCatalogSettings { - for (id, provider) in higher.providers { - let provider = match fallback.providers.remove(&id) { - Some(fallback_provider) => merge_provider_settings(provider, fallback_provider), - None => provider, - }; - fallback.providers.insert(id, provider); - } - - fallback -} - -fn merge_provider_settings( - mut higher: ProviderCatalogSettings, - mut fallback: ProviderCatalogSettings, -) -> ProviderCatalogSettings { - for (id, model) in higher.models.drain() { - let model = match fallback.models.remove(&id) { - Some(fallback_model) => merge_model_settings(model, fallback_model), - None => model, - }; - fallback.models.insert(id, model); - } - ProviderCatalogSettings { - display_name: higher.display_name.or(fallback.display_name), - adapter: higher.adapter.or(fallback.adapter), - codec: higher.codec.or(fallback.codec), - agent_profile: higher.agent_profile.or(fallback.agent_profile), - auth: higher.auth.or(fallback.auth), - billing_policy: higher.billing_policy.or(fallback.billing_policy), - api_key_url: higher.api_key_url.or(fallback.api_key_url), - base_url: higher.base_url.or(fallback.base_url), - extra_headers: higher.extra_headers.or(fallback.extra_headers), - priority: higher.priority.or(fallback.priority), - enabled: higher.enabled.or(fallback.enabled), - aliases: higher.aliases.or(fallback.aliases), - models: fallback.models, - } -} - -fn merge_model_settings( - higher: ModelCatalogSettings, - fallback: ModelCatalogSettings, -) -> ModelCatalogSettings { - ModelCatalogSettings { - provider: higher.provider.or(fallback.provider), - api_id: higher.api_id.or(fallback.api_id), - codec: higher.codec.or(fallback.codec), - billing_policy: higher.billing_policy.or(fallback.billing_policy), - agent_profile: higher.agent_profile.or(fallback.agent_profile), - display_name: higher.display_name.or(fallback.display_name), - family: higher.family.or(fallback.family), - training: higher.training.or(fallback.training), - knowledge_cutoff: higher.knowledge_cutoff.or(fallback.knowledge_cutoff), - default: higher.default.or(fallback.default), - small_default: higher.small_default.or(fallback.small_default), - probe: higher.probe.or(fallback.probe), - enabled: higher.enabled.or(fallback.enabled), - aliases: higher.aliases.or(fallback.aliases), - estimated_output_tps: higher - .estimated_output_tps - .or(fallback.estimated_output_tps), - limits: merge_optional( - higher.limits, - fallback.limits, - merge_model_limits_settings, - ), - features: merge_optional( - higher.features, - fallback.features, - merge_model_features_settings, - ), - controls: merge_optional( - higher.controls, - fallback.controls, - merge_model_controls_settings, - ), - costs: merge_optional(higher.costs, fallback.costs, merge_model_cost_table), - } -} - -fn merge_optional(higher: Option, fallback: Option, merge: fn(&T, &T) -> T) -> Option { - match (higher, fallback) { - (Some(higher), Some(fallback)) => Some(merge(&higher, &fallback)), - (Some(higher), None) => Some(higher), - (None, fallback) => fallback, - } -} - -fn merge_model_limits_settings( - higher: &SettingsModelLimits, - fallback: &SettingsModelLimits, -) -> SettingsModelLimits { - SettingsModelLimits { - context_window: higher.context_window.or(fallback.context_window), - max_output: higher.max_output.or(fallback.max_output), - } -} - -fn merge_model_features_settings( - higher: &SettingsModelFeatures, - fallback: &SettingsModelFeatures, -) -> SettingsModelFeatures { - SettingsModelFeatures { - tools: higher.tools.or(fallback.tools), - vision: higher.vision.or(fallback.vision), - reasoning: higher.reasoning.or(fallback.reasoning), - reasoning_by_default: higher - .reasoning_by_default - .or(fallback.reasoning_by_default), - reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort), - prompt_cache: higher.prompt_cache.or(fallback.prompt_cache), - cache_control_breakpoints: higher - .cache_control_breakpoints - .or(fallback.cache_control_breakpoints), - sampling_params: higher.sampling_params.or(fallback.sampling_params), - } -} - -fn merge_model_controls_settings( - higher: &SettingsModelControls, - fallback: &SettingsModelControls, -) -> SettingsModelControls { - SettingsModelControls { - reasoning_effort: higher - .reasoning_effort - .clone() - .or_else(|| fallback.reasoning_effort.clone()), - speed: higher.speed.clone().or_else(|| fallback.speed.clone()), - } -} - -fn merge_model_cost_table( - higher: &SettingsModelCostTable, - fallback: &SettingsModelCostTable, -) -> SettingsModelCostTable { - SettingsModelCostTable { - base: merge_cost_rates(&higher.base, &fallback.base), - speed: higher.speed.clone().or_else(|| fallback.speed.clone()), - } -} - -fn merge_cost_rates(higher: &CostRates, fallback: &CostRates) -> CostRates { - CostRates { - input_cost_per_mtok: higher.input_cost_per_mtok.or(fallback.input_cost_per_mtok), - output_cost_per_mtok: higher - .output_cost_per_mtok - .or(fallback.output_cost_per_mtok), - cache_input_cost_per_mtok: higher - .cache_input_cost_per_mtok - .or(fallback.cache_input_cost_per_mtok), - } -} - -fn build_providers( - settings: &LlmCatalogSettings, -) -> Result, CatalogBuildError> { - let mut providers = Vec::new(); - let mut ids = settings.providers.keys().cloned().collect::>(); - ids.sort_unstable(); - for id in ids { - let provider_id = ProviderId::from(id.clone()); - let settings = settings - .providers - .get(&id) - .expect("provider ID came from settings map keys"); - if settings.enabled == Some(false) { - continue; - } - - let adapter_name = - required_provider_string(&provider_id, settings.adapter.as_ref(), "adapter")?; - let adapter = AdapterKind::from_str(&adapter_name).map_err(|_| { - CatalogBuildError::UnknownAdapter { - provider: provider_id.clone(), - adapter: adapter_name, - } - })?; - let defaults = adapter_defaults(adapter); - let codec = resolve_provider_codec(&provider_id, adapter, settings.codec)?; - let agent_profile = settings.agent_profile.unwrap_or(defaults.agent_profile); - let auth = settings.auth.clone(); - validate_provider_auth(&provider_id, adapter, auth.as_ref())?; - - providers.push(CatalogProvider { - id: provider_id, - display_name: settings.display_name.clone().unwrap_or_else(|| id.clone()), - adapter, - codec, - agent_profile, - auth, - billing_policy: settings.billing_policy.unwrap_or(defaults.billing_policy), - api_key_url: settings.api_key_url.clone(), - base_url: settings.base_url.clone(), - extra_headers: settings.extra_headers.clone().unwrap_or_default(), - priority: settings.priority.unwrap_or_default(), - aliases: settings.aliases.clone().unwrap_or_default(), - }); - } - Ok(providers) -} - -#[derive(Debug, Clone, Copy)] -struct AdapterDefaults { - agent_profile: AgentProfileKind, - billing_policy: BillingPolicy, -} - -fn adapter_defaults(adapter: AdapterKind) -> AdapterDefaults { - match adapter { - // Bedrock hosts Anthropic-family models, so it shares the Anthropic - // agent profile and billing policy by default. - AdapterKind::Anthropic | AdapterKind::Bedrock => AdapterDefaults { - agent_profile: AgentProfileKind::Anthropic, - billing_policy: BillingPolicy::Anthropic, - }, - AdapterKind::OpenAi | AdapterKind::OpenAiCompatible => AdapterDefaults { - agent_profile: AgentProfileKind::OpenAi, - billing_policy: BillingPolicy::OpenAi, - }, - AdapterKind::Gemini => AdapterDefaults { - agent_profile: AgentProfileKind::Gemini, - billing_policy: BillingPolicy::Gemini, - }, - } -} - -/// Resolve a provider row's codec, rejecting pairings outside the adapter's -/// default so no new route combination is silently enabled by configuration. -fn resolve_provider_codec( - provider: &ProviderId, - adapter: AdapterKind, - configured: Option, -) -> Result { - let expected = CodecKind::default_for(adapter); - match configured { - Some(codec) if codec != expected => Err(CatalogBuildError::UnsupportedProviderCodec { - provider: provider.clone(), - adapter, - codec, - expected, - }), - _ => Ok(expected), - } -} - -/// Resolve a model row's codec against its provider, with the same -/// only-the-default-pairing rule as [`resolve_provider_codec`]. -fn resolve_model_codec( - model_id: &str, - provider: &CatalogProvider, - configured: Option, -) -> Result { - let expected = CodecKind::default_for(provider.adapter); - match configured { - Some(codec) if codec != expected => Err(CatalogBuildError::UnsupportedModelCodec { - model: model_id.to_string(), - adapter: provider.adapter, - codec, - expected, - }), - Some(codec) => Ok(codec), - None => Ok(provider.codec), - } -} - -fn validate_provider_auth( - provider: &ProviderId, - adapter: AdapterKind, - auth: Option<&ProviderAuthConfig>, -) -> Result<(), CatalogBuildError> { - match auth { - Some(auth) if auth.credentials.is_empty() => { - Err(CatalogBuildError::EmptyApiKeyCredentials { - provider: provider.clone(), - }) - } - Some(auth) - if adapter != AdapterKind::Bedrock - && auth - .credentials - .iter() - .any(|credential| matches!(credential, CredentialRef::AwsSigv4)) => - { - Err(CatalogBuildError::UnsupportedAwsSigv4Credential { - provider: provider.clone(), - adapter, - }) - } - _ => Ok(()), - } -} - -fn build_provider_aliases( - providers: &[CatalogProvider], -) -> Result, CatalogBuildError> { - let mut identifiers = BTreeMap::::new(); - for provider in providers { - register_provider_identifier( - &mut identifiers, - provider.id.as_str().to_string(), - provider.id.clone(), - )?; - for alias in &provider.aliases { - register_provider_identifier(&mut identifiers, alias.clone(), provider.id.clone())?; - } - } - Ok(identifiers.into_iter().collect()) -} - -fn build_model( - model_id: &str, - settings: &ModelCatalogSettings, - provider: &CatalogProvider, -) -> Result<(Model, CatalogModelSettings), CatalogBuildError> { - let family = required_model_string(model_id, settings.family.as_ref(), "family")?; - let display_name = - required_model_string(model_id, settings.display_name.as_ref(), "display_name")?; - let limits = settings - .limits - .as_ref() - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "limits", - })?; - let context_window = - limits - .context_window - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "limits.context_window", - })?; - let features = - settings - .features - .as_ref() - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features", - })?; - let model_features = build_model_features(model_id, features)?; - let reasoning_by_default = features - .reasoning_by_default - .unwrap_or_else(|| model_features.supports_reasoning_effort()); - if reasoning_by_default && !model_features.reasoning { - return Err(CatalogBuildError::DefaultReasoningWithoutReasoning { - model: model_id.to_string(), - }); - } - let controls = build_model_controls(model_id, &model_features, settings)?; - let costs = build_model_costs(settings.costs.as_ref()); - let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; - - let model = Model { - id: ModelId::new(model_id), - provider: provider.id.clone(), - family, - display_name, - limits: ModelLimits { - context_window, - max_output: limits.max_output, - }, - training: settings.training.clone(), - knowledge_cutoff: settings.knowledge_cutoff.clone(), - features: model_features, - controls: ModelControls { - reasoning_effort: controls.reasoning_effort.clone(), - }, - costs, - estimated_output_tps: settings.estimated_output_tps, - aliases: settings.aliases.clone().unwrap_or_default(), - default: settings.default.unwrap_or_default(), - small_default: settings.small_default.unwrap_or_default(), - configured: false, - }; - let api_id = match settings.api_id.as_ref() { - Some(api_id) if api_id.is_empty() => { - return Err(CatalogBuildError::EmptyModelApiId { - provider: provider.id.clone(), - model: ModelId::new(model_id), - }); - } - Some(api_id) => api_id.clone(), - None => model_id.to_string(), - }; - let catalog_settings = CatalogModelSettings { - api_id, - codec: resolve_model_codec(model_id, provider, settings.codec)?, - billing_policy: settings.billing_policy.unwrap_or(provider.billing_policy), - agent_profile: settings.agent_profile.unwrap_or(provider.agent_profile), - reasoning_by_default, - controls, - speed_costs, - probe: settings.probe.unwrap_or_default(), - }; - Ok((model, catalog_settings)) -} - -fn warn_multiple_probe_models(models_with_settings: &[(Model, CatalogModelSettings)]) { - let mut probes_by_provider = BTreeMap::>::new(); - for (model, settings) in models_with_settings { - if settings.probe { - probes_by_provider - .entry(model.provider.clone()) - .or_default() - .push(model.id.to_string()); - } - } - - for (provider, models) in probes_by_provider { - if models.len() > 1 { - warn!( - provider = %provider, - models = ?models, - "Multiple probe models configured for provider" - ); - } - } -} - -fn build_model_features( - model_id: &str, - features: &SettingsModelFeatures, -) -> Result { - let reasoning = features - .reasoning - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features.reasoning", - })?; - let reasoning_effort = features.reasoning_effort.unwrap_or_default(); - if !reasoning && reasoning_effort != ReasoningEffortFeature::None { - return Err(CatalogBuildError::ReasoningEffortWithoutReasoning { - model: model_id.to_string(), - }); - } - let prompt_cache = features.prompt_cache.unwrap_or_default(); - let cache_control_breakpoints = features.cache_control_breakpoints.unwrap_or_default(); - if cache_control_breakpoints && !prompt_cache { - return Err( - CatalogBuildError::CacheControlBreakpointsWithoutPromptCache { - model: model_id.to_string(), - }, - ); - } - - Ok(ModelFeatures { - tools: features - .tools - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features.tools", - })?, - vision: features - .vision - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model_id.to_string(), - field: "features.vision", - })?, - reasoning, - reasoning_effort, - prompt_cache, - cache_control_breakpoints, - sampling_params: features.sampling_params.unwrap_or(true), - }) -} - -fn build_model_costs(costs: Option<&SettingsModelCostTable>) -> ModelCosts { - let base = costs.map(|costs| &costs.base); - ModelCosts { - input_cost_per_mtok: base.and_then(|base| base.input_cost_per_mtok), - output_cost_per_mtok: base.and_then(|base| base.output_cost_per_mtok), - cache_input_cost_per_mtok: base.and_then(|base| base.cache_input_cost_per_mtok), - } -} - -fn build_speed_costs( - model_id: &str, - costs: Option<&SettingsModelCostTable>, - controls: &CatalogModelControls, -) -> Result, CatalogBuildError> { - let mut speed_costs = HashMap::new(); - let Some(costs) = costs.and_then(|costs| costs.speed.as_ref()) else { - return Ok(speed_costs); - }; - for (speed, rates) in costs { - let speed = parse_speed(model_id, speed)?; - if !controls.speed.contains(&speed) { - return Err(CatalogBuildError::UndeclaredSpeedCost { - model: model_id.to_string(), - speed, - }); - } - speed_costs.insert(speed, cost_rates_to_model_costs(rates)); - } - Ok(speed_costs) -} - -fn cost_rates_to_model_costs(rates: &CostRates) -> ModelCosts { - ModelCosts { - input_cost_per_mtok: rates.input_cost_per_mtok, - output_cost_per_mtok: rates.output_cost_per_mtok, - cache_input_cost_per_mtok: rates.cache_input_cost_per_mtok, - } -} - -fn build_model_controls( - model_id: &str, - features: &ModelFeatures, - settings: &ModelCatalogSettings, -) -> Result { - let supports_native_reasoning_effort = features.supports_reasoning_effort(); - let reasoning_effort = match settings - .controls - .as_ref() - .and_then(|controls| controls.reasoning_effort.as_ref()) - { - Some(values) if !features.reasoning && !values.is_empty() => { - return Err(CatalogBuildError::ReasoningEffortControlsWithoutReasoning { - model: model_id.to_string(), - }); - } - Some(values) if values.is_empty() && supports_native_reasoning_effort => { - return Err(CatalogBuildError::EmptyReasoningEffortControls { - model: model_id.to_string(), - }); - } - Some(values) => values - .iter() - .map(|value| parse_reasoning_effort(model_id, value)) - .collect::, _>>()?, - None if supports_native_reasoning_effort => ReasoningEffort::VARIANTS.to_vec(), - None => Vec::new(), - }; - - let speed = settings - .controls - .as_ref() - .and_then(|controls| controls.speed.as_ref()) - .map(|values| { - values - .iter() - .map(|value| parse_speed_control(model_id, value)) - .collect::, _>>() - }) - .transpose()? - .unwrap_or_default(); - - Ok(CatalogModelControls { - reasoning_effort, - speed, - }) -} - -fn parse_reasoning_effort( - model_id: &str, - value: &str, -) -> Result { - ReasoningEffort::from_str(value).map_err(|source| CatalogBuildError::InvalidReasoningEffort { - model: model_id.to_string(), - value: value.to_string(), - source, - }) -} - -fn parse_speed(model_id: &str, value: &str) -> Result { - Speed::from_str(value).map_err(|source| CatalogBuildError::InvalidSpeed { - model: model_id.to_string(), - value: value.to_string(), - source, - }) -} - -fn parse_speed_control(model_id: &str, value: &str) -> Result { - let speed = parse_speed(model_id, value)?; - if speed == Speed::Standard { - return Err(CatalogBuildError::StandardSpeedControl { - model: model_id.to_string(), - }); - } - Ok(speed) -} - -fn required_provider_string( - provider: &ProviderId, - value: Option<&String>, - field: &'static str, -) -> Result { - value - .filter(|value| !value.is_empty()) - .cloned() - .ok_or_else(|| CatalogBuildError::MissingProviderField { - provider: provider.clone(), - field, - }) -} - -fn required_model_string( - model: &str, - value: Option<&String>, - field: &'static str, -) -> Result { - value - .filter(|value| !value.is_empty()) - .cloned() - .ok_or_else(|| CatalogBuildError::MissingModelField { - model: model.to_string(), - field, - }) -} - -fn register_provider_identifier( - identifiers: &mut BTreeMap, - identifier: String, - owner: ProviderId, -) -> Result<(), CatalogBuildError> { - match identifiers.get(&identifier) { - Some(existing) if existing != &owner => { - Err(CatalogBuildError::DuplicateProviderIdentifier { - identifier, - first: existing.clone(), - second: owner, - }) - } - _ => { - identifiers.insert(identifier, owner); - Ok(()) - } - } -} - -fn register_model_identifier( - identifiers: &mut BTreeMap, - identifier: String, - owner: ModelId, - provider: &ProviderId, -) -> Result<(), CatalogBuildError> { - match identifiers.get(&identifier) { - Some(existing) if existing != &owner => { - Err(CatalogBuildError::DuplicateProviderModelSelector { - provider: provider.clone(), - selector: identifier, - first: existing.clone(), - second: owner, - }) - } - _ => { - identifiers.insert(identifier, owner); - Ok(()) - } - } -} - -fn validate_builtin_fragment( - path: &str, - fragment: &LlmCatalogSettings, -) -> Result<(), CatalogBuildError> { - if fragment.providers.len() != 1 { - return Err(CatalogBuildError::InvalidBuiltinProviderCount { - path: path.to_string(), - }); - } - let expected = path - .strip_suffix(".toml") - .unwrap_or(path) - .rsplit('/') - .next() - .unwrap_or(path); - let actual = fragment - .providers - .keys() - .next() - .expect("provider count was checked"); - if actual != expected { - return Err(CatalogBuildError::BuiltinProviderIdMismatch { - path: path.to_string(), - expected: expected.to_string(), - actual: actual.clone(), - }); - } - - for (model, settings) in &fragment.models { - let Some(provider) = settings.provider.as_ref() else { - continue; - }; - if provider != expected { - return Err(CatalogBuildError::BuiltinModelProviderMismatch { - path: path.to_string(), - model: model.clone(), - expected: expected.to_string(), - actual: provider.clone(), - }); - } - } - Ok(()) -} - -fn provider_order(left: &CatalogProvider, right: &CatalogProvider) -> std::cmp::Ordering { - right - .priority - .cmp(&left.priority) - .then_with(|| left.id.cmp(&right.id)) -} - -#[cfg(test)] -mod tests { - use strum::VariantArray; - - use super::*; - use crate::adapter::AdapterKind; - use crate::reasoning::ReasoningEffort; - use crate::{AgentProfileKind, ProviderId, Speed}; - - fn minimal_settings(source: &str) -> LlmCatalogSettings { - toml::from_str(source).expect("fixture should parse as an LLM settings layer") - } - - fn portable_model_catalog() -> Catalog { - Catalog::from_settings(&minimal_settings( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -agent_profile = "openai" -priority = 90 - -[providers.openai.models."gpt-5.6-sol"] -display_name = "GPT-5.6 Sol" -family = "gpt-5" -aliases = ["gpt-56-sol", "portable"] -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" -agent_profile = "openai" -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", "portable"] -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 -"#, - )) - .expect("portable model fixture should build") - } - - const BEDROCK_SIGV4_LAYER: &str = r#" -[providers.bedrock] -adapter = "bedrock" -base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com" - -[providers.bedrock.auth] -credentials = ["aws_sigv4"] - -[models."bedrock-sonnet"] -provider = "bedrock" -api_id = "anthropic.claude-sonnet-4-6" -display_name = "Bedrock Sonnet" -family = "claude-4" -default = true - -[models."bedrock-sonnet".limits] -context_window = 200000 -max_output = 64000 - -[models."bedrock-sonnet".features] -tools = true -vision = true -reasoning = true -"#; - - #[test] - fn provider_parses_bedrock_base_url_and_sigv4_credential() { - let catalog = Catalog::from_settings(&minimal_settings(BEDROCK_SIGV4_LAYER)).unwrap(); - let provider = catalog.provider(&ProviderId::from("bedrock")).unwrap(); - assert_eq!( - provider.base_url.as_deref(), - Some("https://bedrock-runtime.eu-west-1.amazonaws.com") - ); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::AwsSigv4 - ]); - // Bedrock inherits the Anthropic agent profile and billing by default. - assert_eq!(provider.agent_profile, AgentProfileKind::Anthropic); - assert_eq!(provider.billing_policy, BillingPolicy::Anthropic); - } - - #[test] - fn aws_sigv4_credential_round_trips() { - assert_eq!( - "aws_sigv4".parse::().unwrap(), - CredentialRef::AwsSigv4 - ); - assert_eq!(CredentialRef::AwsSigv4.to_string(), "aws_sigv4"); - } - - // ---- Catalog struct tests ---- - - #[test] - fn from_builtin_matches_builtin_catalog() { - let catalog = Catalog::from_builtin().expect("built-in catalog should build"); - - assert_eq!( - catalog.get("sonnet").map(|model| model.id.as_str()), - Catalog::builtin() - .get("sonnet") - .map(|model| model.id.as_str()) - ); - assert_eq!( - catalog.default_model().id, - Catalog::builtin().default_model().id - ); - } - - #[test] - fn builtin_overrides_sparse_provider_fields() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.anthropic] -enabled = false -", - )) - .expect("sparse built-in provider override should build"); - - assert!(catalog.provider(&ProviderId::anthropic()).is_none()); - assert!(catalog.get("claude-sonnet-4-5").is_none()); - assert!( - catalog - .providers() - .iter() - .any(|provider| provider.id == ProviderId::openai()) - ); - } - - #[test] - fn builtin_overrides_add_custom_openai_compatible_provider_and_model() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -priority = 120 -aliases = ["acme-ai"] - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true -aliases = ["al"] - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - )) - .expect("custom provider overlay should build"); - - let provider = catalog - .provider(&ProviderId::new("acme-ai")) - .expect("provider alias should resolve"); - assert_eq!(provider.id, ProviderId::new("acme")); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - - let model = catalog.get("al").expect("model alias should resolve"); - assert_eq!(model.id, "acme-large"); - assert_eq!(model.provider, ProviderId::new("acme")); - } - - #[test] - fn builtin_bedrock_provider_is_opt_in() { - let bedrock = ProviderId::new("bedrock"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&bedrock).is_none()); - assert!(builtin.list(Some(&bedrock)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.bedrock] -enabled = true -", - )) - .expect("enabled Bedrock override should build from the built-in provider settings"); - - let provider = catalog - .provider(&bedrock) - .expect("enabled Bedrock provider should be present"); - assert_eq!(provider.adapter, AdapterKind::Bedrock); - assert_eq!(provider.codec, CodecKind::BedrockConverse); - assert_eq!( - provider.base_url.as_deref(), - Some("https://bedrock-runtime.us-east-1.amazonaws.com") - ); - // Bearer key first (env then vault, like every other provider), under - // either the AWS-canonical name or Fabro's `_API_KEY` - // convention; SigV4 chain as the fallback. - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("AWS_BEARER_TOKEN_BEDROCK".to_string()), - CredentialRef::Env("BEDROCK_API_KEY".to_string()), - CredentialRef::Vault("AWS_BEARER_TOKEN_BEDROCK".to_string()), - CredentialRef::Vault("BEDROCK_API_KEY".to_string()), - CredentialRef::AwsSigv4, - ]); - - // Claude rows bill Anthropic-style; open-weights rows override the - // provider's Anthropic defaults the other way. - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "claude-sonnet-4-6") - .unwrap() - .billing_policy, - BillingPolicy::Anthropic - ); - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "glm-5") - .unwrap() - .billing_policy, - BillingPolicy::OpenAi - ); - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "claude-haiku-4-5") - .unwrap() - .api_id, - "us.anthropic.claude-haiku-4-5-20251001-v1:0" - ); - assert_eq!( - catalog - .default_for_provider(&bedrock) - .map(|model| model.id.as_str()), - Some("claude-sonnet-5") - ); - // Fable 5 ships with sampling params pinned off (the Converse - // encoder drops temperature/top_p for it). - let fable = catalog - .get_on_provider(&bedrock, "claude-fable-5") - .expect("fable row should be present"); - assert!(!fable.features.sampling_params); - let fable_settings = catalog - .settings_for(fable) - .expect("fable settings should be present"); - assert!(fable_settings.reasoning_by_default); - assert_eq!(fable_settings.agent_profile, AgentProfileKind::Claude5); - assert_eq!( - catalog - .model_settings_on_provider(&bedrock, "claude-fable-5") - .unwrap() - .billing_policy, - BillingPolicy::Anthropic - ); - let sonnet = catalog - .get_on_provider(&bedrock, "claude-sonnet-5") - .expect("Sonnet 5 row should be present"); - assert_eq!(sonnet.limits.context_window, 1_000_000); - assert_eq!(sonnet.limits.max_output, Some(128_000)); - assert!(!sonnet.features.sampling_params); - assert_eq!( - catalog.settings_for(sonnet).unwrap().agent_profile, - AgentProfileKind::Claude5 - ); - } - - #[test] - fn builtin_bedrock_openai_provider_is_opt_in() { - let provider_id = ProviderId::new("bedrock-openai"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&provider_id).is_none()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.bedrock-openai] -enabled = true -", - )) - .expect("enabled bedrock-openai override should build"); - - let provider = catalog - .provider(&provider_id) - .expect("enabled bedrock-openai provider should be present"); - // OpenAI frontier on Bedrock rides the existing openai_responses - // dialect against the bedrock-mantle endpoint — pure configuration. - assert_eq!(provider.adapter, AdapterKind::OpenAi); - assert_eq!(provider.codec, CodecKind::OpenAiResponses); - assert_eq!( - provider.base_url.as_deref(), - Some("https://bedrock-mantle.us-east-1.api.aws/openai/v1") - ); - assert_eq!( - catalog - .default_for_provider(&provider_id) - .map(|model| model.id.as_str()), - Some("gpt-5.5") - ); - } - - #[test] - fn builtin_poolside_provider_routes_current_laguna_models() { - let poolside = ProviderId::new("poolside"); - let catalog = Catalog::builtin(); - let provider = catalog - .provider(&poolside) - .expect("Poolside provider should be active"); - - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!( - provider.base_url.as_deref(), - Some("https://inference.poolside.ai/v1") - ); - assert_eq!(provider.priority, 65); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("POOLSIDE_API_KEY".to_string()), - CredentialRef::Vault("POOLSIDE_API_KEY".to_string()), - ]); - - assert_eq!( - catalog - .default_for_provider(&poolside) - .map(|model| model.id.as_str()), - Some("laguna-s-2.1") - ); - assert_eq!( - catalog - .small_default_for_provider(&poolside) - .map(|model| model.id.as_str()), - Some("laguna-xs-2.1") - ); - assert_eq!( - catalog - .probe_for_provider(&poolside) - .map(|model| model.id.as_str()), - Some("laguna-xs-2.1") - ); - - let s = catalog.get("laguna").expect("Laguna alias should resolve"); - assert_eq!(s.id, "laguna-s-2.1"); - assert_eq!(s.limits.context_window, 1_048_576); - assert_eq!(s.limits.max_output, Some(131_072)); - assert!(s.features.tools); - assert!(s.features.reasoning); - assert!(s.features.prompt_cache); - assert!(s.features.sampling_params); - assert!(!s.features.vision); - assert!(!s.supports_reasoning_effort()); - assert_eq!(s.costs.input_cost_per_mtok, Some(0.10)); - assert_eq!(s.costs.output_cost_per_mtok, Some(0.20)); - assert_eq!(s.costs.cache_input_cost_per_mtok, Some(0.01)); - assert_eq!( - catalog.model_settings(&s.id).unwrap().api_id, - "poolside/laguna-s-2.1" - ); - - let xs = catalog - .get("laguna-xs") - .expect("Laguna XS alias should resolve"); - assert_eq!(xs.id, "laguna-xs-2.1"); - assert_eq!(xs.limits.context_window, 262_144); - assert_eq!(xs.limits.max_output, Some(32_768)); - assert!(xs.features.tools); - assert!(xs.features.reasoning); - assert!(xs.features.prompt_cache); - assert!(xs.features.sampling_params); - assert!(!xs.features.vision); - assert!(!xs.supports_reasoning_effort()); - assert_eq!(xs.costs.input_cost_per_mtok, Some(0.10)); - assert_eq!(xs.costs.output_cost_per_mtok, Some(0.20)); - assert_eq!(xs.costs.cache_input_cost_per_mtok, Some(0.05)); - assert_eq!( - catalog.model_settings(&xs.id).unwrap().api_id, - "poolside/laguna-xs-2.1" - ); - } - - #[test] - fn builtin_deepseek_provider_routes_v4_models() { - let deepseek = ProviderId::new("deepseek"); - let catalog = Catalog::builtin(); - let provider = catalog - .provider(&deepseek) - .expect("DeepSeek provider should be active"); - - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!( - provider.base_url.as_deref(), - Some("https://api.deepseek.com") - ); - assert_eq!(provider.priority, 75); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("DEEPSEEK_API_KEY".to_string()), - CredentialRef::Vault("DEEPSEEK_API_KEY".to_string()), - ]); - assert_eq!( - catalog - .default_for_provider(&deepseek) - .map(|model| model.id.as_str()), - Some("deepseek-v4-flash") - ); - assert_eq!( - catalog - .small_default_for_provider(&deepseek) - .map(|model| model.id.as_str()), - Some("deepseek-v4-flash") - ); - assert_eq!( - catalog - .probe_for_provider(&deepseek) - .map(|model| model.id.as_str()), - Some("deepseek-v4-flash") - ); - - let expected = [ - ("deepseek-v4-flash", 0.14, 0.28, 0.0028), - ("deepseek-v4-pro", 0.435, 0.87, 0.003_625), - ]; - for (id, input, output, cache_read) in expected { - let model = catalog - .get_on_provider(&deepseek, id) - .unwrap_or_else(|| panic!("DeepSeek model '{id}' should be present")); - assert_eq!(model.family, "deepseek-v4", "{id}"); - assert_eq!(model.limits.context_window, 1_048_576, "{id}"); - assert_eq!(model.limits.max_output, Some(384_000), "{id}"); - assert!(model.features.tools, "{id}"); - assert!(!model.features.vision, "{id}"); - assert!(model.features.reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert!(!model.features.sampling_params, "{id}"); - assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_read), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&deepseek, id) - .unwrap_or_else(|| panic!("DeepSeek settings for '{id}' should be present")); - assert_eq!(settings.api_id, id, "{id}"); - assert!(settings.reasoning_by_default, "{id}"); - } - } - - #[test] - fn builtin_deepseek_reasoning_controls_match_provider_dialects() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("DeepSeek gateway providers should build when enabled"); - - let expected = [ - (ProviderId::new("deepseek"), "deepseek-v4-flash", vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("deepseek"), "deepseek-v4-pro", vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("fireworks"), "deepseek-v4-flash", vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("fireworks"), "deepseek-v4-pro", vec![ - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("openrouter"), "deepseek-v4-flash", vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - (ProviderId::new("openrouter"), "deepseek-v4-pro", vec![ - ReasoningEffort::High, - ReasoningEffort::XHigh, - ]), - ]; - - for (provider, id, efforts) in expected { - let model = catalog - .get_on_provider(&provider, id) - .unwrap_or_else(|| panic!("{provider}/{id} should be present")); - assert!(model.features.reasoning, "{provider}/{id}"); - assert_eq!( - model.features.reasoning_effort, - ReasoningEffortFeature::Levels, - "{provider}/{id}" - ); - assert_eq!(model.controls.reasoning_effort, efforts, "{provider}/{id}"); - assert!(!model.features.sampling_params, "{provider}/{id}"); - - let settings = catalog - .model_settings_on_provider(&provider, id) - .unwrap_or_else(|| panic!("{provider}/{id} settings should be present")); - assert_eq!( - settings.agent_profile, - AgentProfileKind::OpenAi, - "{provider}/{id}" - ); - assert!(settings.reasoning_by_default, "{provider}/{id}"); - assert_eq!( - settings.controls.reasoning_effort, efforts, - "{provider}/{id}" - ); - } - } - - #[test] - fn builtin_openrouter_provider_is_opt_in() { - let openrouter = ProviderId::new("openrouter"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&openrouter).is_none()); - assert!(builtin.list(Some(&openrouter)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let provider = catalog - .provider(&openrouter) - .expect("enabled OpenRouter provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!( - provider.base_url.as_deref(), - Some("https://openrouter.ai/api/v1") - ); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - - // Claude rows override the provider's OpenAI billing default; - // open-weights rows inherit it. - assert_eq!( - catalog - .model_settings_on_provider(&openrouter, "claude-sonnet-5") - .unwrap() - .billing_policy, - BillingPolicy::Anthropic - ); - assert_eq!( - catalog - .model_settings_on_provider(&openrouter, "deepseek-v4-flash") - .unwrap() - .billing_policy, - BillingPolicy::OpenAi - ); - let deepseek = catalog - .get_on_provider(&openrouter, "deepseek-v4-flash") - .expect("DeepSeek V4 Flash should be present on OpenRouter"); - assert_eq!(deepseek.limits.max_output, Some(384_000)); - assert!(deepseek.features.prompt_cache); - assert_eq!(deepseek.costs.input_cost_per_mtok, Some(0.14)); - assert_eq!(deepseek.costs.output_cost_per_mtok, Some(0.28)); - assert_eq!(deepseek.costs.cache_input_cost_per_mtok, Some(0.0028)); - assert_eq!( - catalog.settings_for(deepseek).unwrap().api_id, - "deepseek/deepseek-v4-flash-0731" - ); - let deepseek_pro = catalog - .get_on_provider(&openrouter, "deepseek-v4-pro") - .expect("DeepSeek V4 Pro should be present on OpenRouter"); - assert_eq!(deepseek_pro.limits.max_output, Some(384_000)); - assert!(deepseek_pro.features.prompt_cache); - assert_eq!( - deepseek_pro.costs.cache_input_cost_per_mtok, - Some(0.003_625) - ); - assert_eq!( - catalog - .default_for_provider(&openrouter) - .map(|model| model.id.as_str()), - Some("claude-sonnet-5") - ); - } - - #[test] - fn builtin_openrouter_includes_gpt_5_6_and_current_claude_models_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let expected = [ - ( - "gpt-5.6-sol", - "openai/gpt-5.6-sol", - "gpt-5", - 1_050_000, - 5.0, - 30.0, - 0.5, - ReasoningEffortFeature::Levels, - false, - false, - BillingPolicy::OpenAi, - ), - ( - "gpt-5.6-terra", - "openai/gpt-5.6-terra", - "gpt-5", - 1_050_000, - 2.5, - 15.0, - 0.25, - ReasoningEffortFeature::Levels, - false, - false, - BillingPolicy::OpenAi, - ), - ( - "gpt-5.6-luna", - "openai/gpt-5.6-luna", - "gpt-5", - 1_050_000, - 1.0, - 6.0, - 0.1, - ReasoningEffortFeature::Levels, - false, - false, - BillingPolicy::OpenAi, - ), - ( - "claude-opus-5", - "anthropic/claude-opus-5", - "claude-5", - 1_000_000, - 5.0, - 25.0, - 0.5, - ReasoningEffortFeature::Levels, - false, - true, - BillingPolicy::Anthropic, - ), - ( - "claude-opus-4-8", - "anthropic/claude-opus-4.8", - "claude-4", - 1_000_000, - 5.0, - 25.0, - 0.5, - ReasoningEffortFeature::Levels, - false, - true, - BillingPolicy::Anthropic, - ), - ( - "claude-fable-5", - "anthropic/claude-fable-5", - "claude-5", - 1_000_000, - 10.0, - 50.0, - 1.0, - ReasoningEffortFeature::AlwaysAdaptive, - false, - true, - BillingPolicy::Anthropic, - ), - ( - "claude-sonnet-5", - "anthropic/claude-sonnet-5", - "claude-5", - 1_000_000, - 2.0, - 10.0, - 0.2, - ReasoningEffortFeature::Levels, - false, - true, - BillingPolicy::Anthropic, - ), - ]; - - for ( - id, - api_id, - family, - context_window, - input_cost, - output_cost, - cache_input_cost, - reasoning_effort, - sampling_params, - cache_control_breakpoints, - billing_policy, - ) in expected - { - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter model '{id}' should be present")); - assert_eq!(model.provider, ProviderId::new("openrouter"), "{id}"); - assert_eq!(model.family, family, "{id}"); - assert_eq!(model.limits.context_window, context_window, "{id}"); - assert_eq!(model.limits.max_output, Some(128_000), "{id}"); - assert!(model.features.tools, "{id}"); - assert!(model.features.vision, "{id}"); - assert!(model.features.reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert_eq!(model.features.reasoning_effort, reasoning_effort, "{id}"); - assert_eq!(model.features.sampling_params, sampling_params, "{id}"); - assert_eq!( - model.features.cache_control_breakpoints, cache_control_breakpoints, - "{id}" - ); - assert_eq!(model.costs.input_cost_per_mtok, Some(input_cost), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output_cost), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_input_cost), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter settings for '{id}' should be present")); - assert_eq!(settings.api_id, api_id, "{id}"); - assert_eq!(settings.billing_policy, billing_policy, "{id}"); - assert_eq!( - settings.controls.reasoning_effort, - ReasoningEffort::VARIANTS, - "{id}" - ); - if family == "claude-5" { - assert_eq!(settings.agent_profile, AgentProfileKind::Claude5, "{id}"); - } - } - - for (alias, expected) in [ - ("opus", "claude-opus-5"), - ("claude-opus", "claude-opus-5"), - ("sonnet", "claude-sonnet-5"), - ("claude-sonnet", "claude-sonnet-5"), - ] { - let model = catalog - .resolve_on_provider(&ProviderId::new("openrouter"), alias) - .unwrap_or_else(|error| panic!("{alias} should resolve on OpenRouter: {error}")); - assert_eq!(model.id, expected, "{alias}"); - } - } - - #[test] - fn builtin_gpt_5_6_short_aliases_are_portable() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - for provider in [ProviderId::openai(), ProviderId::new("openrouter")] { - for (alias, canonical_id) in [ - ("sol", "gpt-5.6-sol"), - ("gpt-sol", "gpt-5.6-sol"), - ("terra", "gpt-5.6-terra"), - ("gpt-terra", "gpt-5.6-terra"), - ("luna", "gpt-5.6-luna"), - ("gpt-luna", "gpt-5.6-luna"), - ] { - let model = catalog - .resolve_on_provider(&provider, alias) - .unwrap_or_else(|error| { - panic!("{alias} should resolve on {provider}: {error}") - }); - assert_eq!(model.provider, provider, "{alias}"); - assert_eq!(model.id, canonical_id, "{alias}"); - } - } - } - - #[test] - fn builtin_glm_5_2_aliases_are_portable() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - for provider in [ProviderId::new("zai"), ProviderId::new("openrouter")] { - for alias in ["glm", "glm5", "glm52", "glm5.2"] { - let model = catalog - .resolve_on_provider(&provider, alias) - .unwrap_or_else(|error| { - panic!("{alias} should resolve on {provider}: {error}") - }); - assert_eq!(model.provider, provider, "{alias}"); - assert_eq!(model.id, "glm-5.2", "{alias}"); - } - } - } - - #[test] - fn builtin_deepseek_v4_selectors_resolve_on_openrouter() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - let openrouter = ProviderId::new("openrouter"); - - for (selector, canonical_id) in [ - ("deepseek-v4-pro", "deepseek-v4-pro"), - ("deepseek-v4", "deepseek-v4-flash"), - ("deepseek", "deepseek-v4-flash"), - ("deepseek-v4-flash", "deepseek-v4-flash"), - ("deepseek-flash", "deepseek-v4-flash"), - ] { - let model = catalog - .resolve_on_provider(&openrouter, selector) - .unwrap_or_else(|error| { - panic!("{selector} should resolve on {openrouter}: {error}") - }); - assert_eq!(model.provider, openrouter, "{selector}"); - assert_eq!(model.id, canonical_id, "{selector}"); - } - } - - #[test] - fn builtin_legacy_vendor_ids_normalize_for_pinned_and_unpinned_selection() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - let openrouter = ProviderId::new("openrouter"); - - for (selector, canonical_id) in [ - ("anthropic/claude-fable-5", "claude-fable-5"), - ("openai/gpt-5.6-sol", "gpt-5.6-sol"), - ] { - let model = catalog - .resolve_on_provider(&openrouter, selector) - .unwrap_or_else(|error| panic!("{selector} should resolve on OpenRouter: {error}")); - assert_eq!(model.provider, openrouter, "{selector}"); - assert_eq!(model.id, canonical_id, "{selector}"); - } - - let anthropic = ProviderId::anthropic(); - let selector = "anthropic/claude-fable-5"; - let selected = catalog - .resolve_selection( - Some(selector), - None, - &HashSet::from([anthropic.clone(), openrouter.clone()]), - ) - .unwrap(); - assert_eq!(selected.provider, anthropic); - assert_eq!(selected.model, "claude-fable-5"); - - let selected = catalog - .resolve_selection(Some(selector), None, &HashSet::from([openrouter.clone()])) - .unwrap(); - assert_eq!(selected.provider, openrouter); - assert_eq!(selected.model, "claude-fable-5"); - } - - #[test] - fn every_legacy_builtin_identifier_targets_an_existing_offering() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.bedrock] -enabled = true - -[providers.bedrock-openai] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("all providers referenced by the legacy table should build"); - - for (legacy_id, provider_id, canonical_id) in LEGACY_BUILTIN_MODEL_IDENTIFIERS { - let provider = ProviderId::new(*provider_id); - let model = catalog - .resolve_on_provider(&provider, legacy_id) - .unwrap_or_else(|error| { - panic!( - "legacy identifier '{legacy_id}' should resolve on '{provider}': {error}" - ) - }); - - assert_eq!(model.provider, provider, "{legacy_id}"); - assert_eq!(model.id, *canonical_id, "{legacy_id}"); - assert_eq!( - legacy_builtin_model(legacy_id), - Some((provider, ModelId::new(*canonical_id))), - "{legacy_id}" - ); - } - } - - #[test] - fn builtin_openrouter_includes_glm_5_2_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "glm-5.2") - .expect("OpenRouter GLM 5.2 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "glm-5.2", - provider: openrouter, - family: "glm-5", - display_name: "GLM 5.2 (via OpenRouter)", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - High, - XHigh, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 0.784, - ), - output_cost_per_mtok: Some( - 2.464, - ), - cache_input_cost_per_mtok: Some( - 0.1456, - ), - }, - estimated_output_tps: None, - aliases: [ - "glm", - "glm5", - "glm52", - "glm5.2", - ], - default: false, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), "glm-5.2") - .expect("OpenRouter GLM 5.2 settings should be present"); - assert_eq!(settings.api_id, "z-ai/glm-5.2"); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::High, - ReasoningEffort::XHigh - ]); - } - - #[test] - fn builtin_openrouter_includes_kimi_k3_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "kimi-k3") - .expect("OpenRouter Kimi K3 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "kimi-k3", - provider: openrouter, - family: "kimi-k3", - display_name: "Kimi K3 (via OpenRouter)", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: AlwaysAdaptive, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 3.0, - ), - output_cost_per_mtok: Some( - 15.0, - ), - cache_input_cost_per_mtok: Some( - 0.3, - ), - }, - estimated_output_tps: None, - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), "kimi-k3") - .expect("OpenRouter Kimi K3 settings should be present"); - assert_eq!(settings.api_id, "moonshotai/kimi-k3"); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]); - } - - #[test] - fn builtin_openrouter_includes_qwen3_8_max_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "qwen3.8-max") - .expect("OpenRouter Qwen3.8 Max should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "qwen3.8-max", - provider: openrouter, - family: "qwen3", - display_name: "Qwen3.8 Max", - limits: ModelLimits { - context_window: 1000000, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 2.0, - ), - output_cost_per_mtok: Some( - 6.0, - ), - cache_input_cost_per_mtok: Some( - 0.25, - ), - }, - estimated_output_tps: None, - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), "qwen3.8-max") - .expect("OpenRouter Qwen3.8 Max settings should be present"); - assert_eq!(settings.api_id, "qwen/qwen3.8-max"); - assert!(settings.reasoning_by_default); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ]); - } - - #[test] - fn builtin_modal_provider_is_opt_in() { - let modal = ProviderId::new("modal"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&modal).is_none()); - assert!(builtin.list(Some(&modal)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.modal] -enabled = true -", - )) - .expect("enabled Modal override should build from the built-in provider settings"); - - let provider = catalog - .provider(&modal) - .expect("enabled Modal provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!(provider.agent_profile, AgentProfileKind::Kimi); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider.priority, 75); - assert!(provider.auth.is_none()); - assert_eq!( - provider.extra_headers, - HashMap::from([ - ( - "Modal-Key".to_string(), - "{{ secrets.MODAL_TOKEN_ID }}".to_string(), - ), - ( - "Modal-Secret".to_string(), - "{{ secrets.MODAL_TOKEN_SECRET }}".to_string(), - ), - ]) - ); - - // Modal assigns the endpoint URL per deployment, so the built-in entry - // ships without one and the operator supplies it through settings. - assert!(provider.base_url.is_none()); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.modal] -enabled = true -base_url = "https://example--kimi-k3.modal.run/v1" -"#, - )) - .expect("Modal base URL override should build"); - assert_eq!( - catalog - .provider(&modal) - .and_then(|provider| provider.base_url.as_deref()), - Some("https://example--kimi-k3.modal.run/v1") - ); - } - - #[test] - fn builtin_modal_includes_kimi_k3_when_enabled() { - let modal = ProviderId::new("modal"); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.modal] -enabled = true -", - )) - .expect("enabled Modal override should build from the built-in provider settings"); - - assert_eq!(catalog.list(Some(&modal)).len(), 1); - let model = catalog - .get_on_provider(&modal, "kimi-k3") - .expect("Modal Kimi K3 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "kimi-k3", - provider: modal, - family: "kimi-k3", - display_name: "Kimi K3 (via Modal)", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: AlwaysAdaptive, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 3.0, - ), - output_cost_per_mtok: Some( - 15.0, - ), - cache_input_cost_per_mtok: Some( - 0.3, - ), - }, - estimated_output_tps: Some( - 460.0, - ), - aliases: [], - default: true, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings_on_provider(&modal, "kimi-k3") - .expect("Modal Kimi K3 settings should be present"); - assert_eq!(settings.api_id, "moonshotai/Kimi-K3"); - assert_eq!(settings.agent_profile, AgentProfileKind::Kimi); - assert_eq!(settings.billing_policy, BillingPolicy::OpenAi); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]); - } - - #[test] - fn builtin_moonshot_provider_prefers_moonshot_api_key() { - let moonshot = ProviderId::new("moonshot"); - let catalog = Catalog::builtin(); - let provider = catalog - .provider(&moonshot) - .expect("Moonshot provider should be present"); - - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("MOONSHOT_API_KEY".to_string()), - CredentialRef::Env("KIMI_API_KEY".to_string()), - CredentialRef::Vault("MOONSHOT_API_KEY".to_string()), - CredentialRef::Vault("KIMI_API_KEY".to_string()), - ]); - assert_eq!( - catalog.provider_vault_secret_name(&moonshot), - Some("MOONSHOT_API_KEY") - ); - } - - #[test] - fn builtin_kimi_k3_selection_follows_provider_priority() { - let moonshot = ProviderId::new("moonshot"); - let modal = ProviderId::new("modal"); - let fireworks = ProviderId::new("fireworks"); - let openrouter = ProviderId::new("openrouter"); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.modal] -enabled = true - -[providers.fireworks] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("enabled Kimi K3 provider overrides should build"); - - let selected = catalog - .select( - "kimi-k3", - None, - &HashSet::from([ - moonshot.clone(), - modal.clone(), - fireworks.clone(), - openrouter.clone(), - ]), - ) - .expect("Modal should win portable Kimi K3 selection"); - assert_eq!(selected.provider, modal); - - let selected = catalog - .select( - "kimi-k3", - None, - &HashSet::from([moonshot.clone(), fireworks.clone(), openrouter.clone()]), - ) - .expect("Moonshot should win when Modal is unavailable"); - assert_eq!(selected.provider, moonshot); - - let selected = catalog - .select( - "kimi-k3", - None, - &HashSet::from([fireworks.clone(), openrouter]), - ) - .expect("Fireworks should win when only gateway routes are available"); - assert_eq!(selected.provider, fireworks); - } - - #[test] - fn builtin_openrouter_includes_poolside_laguna_when_enabled() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.openrouter] -enabled = true -", - )) - .expect("enabled OpenRouter override should build from the built-in provider settings"); - - let expected = [ - ("laguna-s-2.1", 1_048_576, 131_072, 0.10, 0.20, 0.01), - ("laguna-xs-2.1", 262_144, 32_768, 0.06, 0.12, 0.03), - ]; - - for (id, context, max_output, input, output, cache_read) in expected { - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter model '{id}' should be present")); - assert_eq!(model.provider, ProviderId::new("openrouter"), "{id}"); - assert_eq!(model.family, "laguna-2", "{id}"); - assert_eq!(model.limits.context_window, context, "{id}"); - assert_eq!(model.limits.max_output, Some(max_output), "{id}"); - assert!(model.features.tools, "{id}"); - assert!(model.features.reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert!(model.features.sampling_params, "{id}"); - assert!(!model.features.vision, "{id}"); - assert!(!model.supports_reasoning_effort(), "{id}"); - assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_read), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&ProviderId::new("openrouter"), id) - .unwrap_or_else(|| panic!("OpenRouter settings for '{id}' should be present")); - assert_eq!(settings.api_id, format!("poolside/{id}"), "{id}"); - assert!(settings.controls.reasoning_effort.is_empty(), "{id}"); - } - } - - #[test] - fn builtin_fireworks_provider_is_opt_in() { - let fireworks = ProviderId::new("fireworks"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&fireworks).is_none()); - assert!(builtin.list(Some(&fireworks)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true -", - )) - .expect("enabled Fireworks override should build from the built-in provider settings"); - - let provider = catalog - .provider(&fireworks) - .expect("enabled Fireworks provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!(provider.codec, CodecKind::OpenAiCompatible); - assert_eq!( - provider.base_url.as_deref(), - Some("https://api.fireworks.ai/inference/v1") - ); - assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider.priority, 30); - assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ - CredentialRef::Env("FIREWORKS_API_KEY".to_string()), - CredentialRef::Vault("FIREWORKS_API_KEY".to_string()), - ]); - - assert_eq!( - catalog - .default_for_provider(&fireworks) - .map(|model| model.id.as_str()), - Some("kimi-k2.7-code") - ); - assert_eq!( - catalog - .small_default_for_provider(&fireworks) - .map(|model| model.id.as_str()), - Some("gpt-oss-20b") - ); - assert_eq!( - catalog - .probe_for_provider(&fireworks) - .map(|model| model.id.as_str()), - Some("gpt-oss-20b") - ); - } - - #[test] - fn builtin_fireworks_models_when_enabled() { - let fireworks = ProviderId::new("fireworks"); - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true -", - )) - .expect("enabled Fireworks override should build from the built-in provider settings"); - - // (id, api_id, family, context_window, max_output, vision, reasoning, - // input, output, cache_read) - let expected = [ - ( - "kimi-k3", - "accounts/fireworks/models/kimi-k3", - "kimi-k3", - 1_048_576, - 131_072, - true, - true, - 3.0, - 15.0, - 0.3, - ), - ( - "kimi-k3-fast", - "accounts/fireworks/routers/kimi-k3-fast", - "kimi-k3", - 1_048_576, - 131_072, - true, - true, - 4.5, - 22.5, - 0.45, - ), - ( - "kimi-k2.7-code", - "accounts/fireworks/models/kimi-k2p7-code", - "kimi-k2", - 262_144, - 32_768, - true, - true, - 0.95, - 4.0, - 0.19, - ), - ( - "kimi-k2.6", - "accounts/fireworks/models/kimi-k2p6", - "kimi-k2", - 262_144, - 16_384, - false, - false, - 0.95, - 4.0, - 0.16, - ), - ( - "deepseek-v4-pro", - "accounts/fireworks/models/deepseek-v4-pro", - "deepseek-v4", - 1_048_576, - 16_384, - false, - true, - 1.74, - 3.48, - 0.145, - ), - ( - "deepseek-v4-flash", - "accounts/fireworks/models/deepseek-v4-flash", - "deepseek-v4", - 1_048_576, - 384_000, - false, - true, - 0.14, - 0.28, - 0.028, - ), - ( - "glm-5.2", - "accounts/fireworks/models/glm-5p2", - "glm-5", - 1_048_576, - 131_072, - false, - true, - 1.4, - 4.4, - 0.14, - ), - ( - "minimax-m2.7", - "accounts/fireworks/models/minimax-m2p7", - "minimax-m2", - 196_608, - 16_384, - false, - false, - 0.3, - 1.2, - 0.059, - ), - ( - "qwen3.7-plus", - "accounts/fireworks/models/qwen3p7-plus", - "qwen3", - 262_144, - 16_384, - true, - false, - 0.4, - 1.6, - 0.08, - ), - ( - "gpt-oss-120b", - "accounts/fireworks/models/gpt-oss-120b", - "gpt-oss", - 131_072, - 32_768, - false, - true, - 0.15, - 0.6, - 0.015, - ), - ( - "gpt-oss-20b", - "accounts/fireworks/models/gpt-oss-20b", - "gpt-oss", - 131_072, - 32_768, - false, - true, - 0.07, - 0.3, - 0.035, - ), - ]; - - let mut model_ids: Vec<&str> = catalog - .list(Some(&fireworks)) - .iter() - .map(|model| model.id.as_str()) - .collect(); - model_ids.sort_unstable(); - let mut expected_ids: Vec<&str> = expected.iter().map(|row| row.0).collect(); - expected_ids.sort_unstable(); - assert_eq!( - model_ids, expected_ids, - "expected rows must cover every Fireworks model" - ); - - for ( - id, - api_id, - family, - context, - max_output, - vision, - reasoning, - input, - output, - cache_read, - ) in expected - { - let model = catalog - .get_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks model '{id}' should be present")); - assert_eq!(model.family, family, "{id}"); - assert_eq!(model.limits.context_window, context, "{id}"); - assert_eq!(model.limits.max_output, Some(max_output), "{id}"); - assert!(model.features.tools, "{id}"); - assert_eq!(model.features.vision, vision, "{id}"); - assert_eq!(model.features.reasoning, reasoning, "{id}"); - assert!(model.features.prompt_cache, "{id}"); - assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); - assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); - assert_eq!( - model.costs.cache_input_cost_per_mtok, - Some(cache_read), - "{id}" - ); - - let settings = catalog - .model_settings_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks settings for '{id}' should be present")); - assert_eq!(settings.api_id, api_id, "{id}"); - assert_eq!(settings.billing_policy, BillingPolicy::OpenAi, "{id}"); - } - - for id in ["kimi-k3", "kimi-k3-fast"] { - let model = catalog - .get_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks model '{id}' should be present")); - assert_eq!( - model.features.reasoning_effort, - ReasoningEffortFeature::AlwaysAdaptive, - "{id}" - ); - assert!(!model.features.sampling_params, "{id}"); - - let settings = catalog - .model_settings_on_provider(&fireworks, id) - .unwrap_or_else(|| panic!("Fireworks settings for '{id}' should be present")); - assert_eq!(settings.agent_profile, AgentProfileKind::Kimi, "{id}"); - assert_eq!( - settings.controls.reasoning_effort, - [ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ], - "{id}" - ); - } - } - - #[test] - fn builtin_deepseek_shared_slugs_are_portable_across_providers() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.fireworks] -enabled = true - -[providers.openrouter] -enabled = true -", - )) - .expect("enabled Fireworks and OpenRouter overrides should build"); - - for provider in [ProviderId::new("fireworks"), ProviderId::new("openrouter")] { - for id in [ - "kimi-k3", - "kimi-k2.6", - "deepseek-v4-pro", - "deepseek-v4-flash", - "glm-5.2", - "minimax-m2.7", - ] { - let model = catalog - .get_on_provider(&provider, id) - .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'")); - assert_eq!(model.id, id, "{provider}/{id}"); - assert_eq!(model.provider, provider, "{provider}/{id}"); - } - } - - for provider in [ - ProviderId::new("deepseek"), - ProviderId::new("fireworks"), - ProviderId::new("openrouter"), - ] { - for id in ["deepseek-v4-pro", "deepseek-v4-flash"] { - let model = catalog - .get_on_provider(&provider, id) - .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'")); - assert_eq!(model.id, id, "{provider}/{id}"); - assert_eq!(model.provider, provider, "{provider}/{id}"); - assert_eq!(model.limits.context_window, 1_048_576, "{provider}/{id}"); - } - for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] { - let model = catalog - .resolve_on_provider(&provider, alias) - .unwrap_or_else(|error| { - panic!("'{alias}' should resolve on provider '{provider}': {error}") - }); - assert_eq!(model.id, "deepseek-v4-flash", "{provider}/{alias}"); - assert_eq!(model.provider, provider, "{provider}/{alias}"); - } - } - - let selected = catalog - .select( - "deepseek", - None, - &HashSet::from([ - ProviderId::new("deepseek"), - ProviderId::new("fireworks"), - ProviderId::new("openrouter"), - ]), - ) - .expect("direct DeepSeek should win portable DeepSeek selection"); - assert_eq!(selected.provider, ProviderId::new("deepseek")); - } - - #[test] - fn builtin_ollama_provider_is_opt_in() { - let ollama = ProviderId::new("ollama"); - let builtin = Catalog::builtin(); - - assert!(builtin.provider(&ollama).is_none()); - assert!(builtin.list(Some(&ollama)).is_empty()); - - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r" -[providers.ollama] -enabled = true -", - )) - .expect("enabled Ollama override should build from the built-in provider settings"); - - let provider = catalog - .provider(&ollama) - .expect("enabled Ollama provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); - assert_eq!( - provider.base_url.as_deref(), - Some("http://localhost:11434/v1") - ); - assert_eq!(provider.billing_policy, BillingPolicy::None); - - assert!(catalog.list(Some(&ollama)).is_empty()); - assert!(catalog.default_for_provider(&ollama).is_none()); - } - - #[test] - fn builtin_get_by_id() { - let m = Catalog::builtin().get("claude-opus-4-6").unwrap(); - assert_eq!(m.id, "claude-opus-4-6"); - } - - #[test] - fn builtin_get_unknown() { - assert!(Catalog::builtin().get("nonexistent").is_none()); - } - - #[test] - fn builtin_list_all() { - let all = Catalog::builtin().list(None); - assert!(!all.is_empty()); - } - - #[test] - fn builtin_list_by_provider() { - let anthropic = Catalog::builtin().list(Some(&ProviderId::anthropic())); - assert!(!anthropic.is_empty()); - assert!( - anthropic - .iter() - .all(|m| m.provider == ProviderId::anthropic()) - ); - } - - #[test] - fn builtin_list_unknown_provider_empty() { - let models = Catalog::builtin().list(Some(&ProviderId::new("missing-provider"))); - assert!(models.is_empty()); - } - - #[test] - fn builtin_default_model() { - let m = Catalog::builtin().default_model(); - assert!(m.default); - } - - #[test] - fn builtin_default_for_provider() { - let m = Catalog::builtin() - .default_for_provider(&ProviderId::anthropic()) - .unwrap(); - assert_eq!(m.id, "claude-sonnet-5"); - assert!(m.default); - - let m = Catalog::builtin() - .default_for_provider(&ProviderId::openai()) - .unwrap(); - assert_eq!(m.provider, ProviderId::openai()); - assert!(m.default); - - let m = Catalog::builtin() - .default_for_provider(&ProviderId::gemini()) - .unwrap(); - assert_eq!(m.id, "gemini-3.5-flash"); - } - - #[test] - fn builtin_probe_openai_returns_override() { - let m = Catalog::builtin() - .probe_for_provider(&ProviderId::openai()) - .unwrap(); - assert_eq!(m.id, "gpt-5.4-mini"); - } - - #[test] - fn builtin_probe_anthropic_returns_override() { - let m = Catalog::builtin() - .probe_for_provider(&ProviderId::anthropic()) - .unwrap(); - assert_eq!(m.id, "claude-haiku-4-5"); - } - - #[test] - fn builtin_probe_gemini_returns_default() { - let m = Catalog::builtin() - .probe_for_provider(&ProviderId::gemini()) - .unwrap(); - assert_eq!(m.id, "gemini-3.5-flash"); - } - - #[test] - fn builtin_small_defaults_are_marked_per_provider() { - let catalog = Catalog::builtin(); - - let small_defaults = catalog - .list(None) - .into_iter() - .filter(|model| model.small_default) - .collect::>(); - - assert!( - !small_defaults.is_empty(), - "built-in catalog should mark at least one small default model" - ); - - for model in small_defaults { - assert_eq!( - catalog - .small_default_for_provider(&model.provider) - .unwrap() - .id, - model.id - ); - } - } - - #[test] - fn builtin_closest_opus_to_gemini() { - let opus = Catalog::builtin().get("claude-opus-4-6").unwrap(); - let result = Catalog::builtin() - .closest(&ProviderId::gemini(), opus) - .unwrap(); - assert_eq!(result.id, "gemini-3.1-pro-preview"); - } - - #[test] - fn builtin_closest_no_match() { - let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); - assert!( - Catalog::builtin() - .closest(&ProviderId::openai(), haiku) - .is_none() - ); - } - - #[test] - fn builtin_catalog_is_loaded_from_provider_toml_settings() { - let catalog = Catalog::builtin(); - - assert_eq!( - catalog.provider(&ProviderId::openai()).unwrap().adapter, - AdapterKind::OpenAi - ); - assert_eq!( - catalog - .provider(&ProviderId::openai()) - .unwrap() - .api_key_url - .as_deref(), - Some("https://platform.openai.com/api-keys") - ); - assert_eq!( - catalog - .provider(&ProviderId::new("moonshot")) - .unwrap() - .base_url - .as_deref(), - Some("https://api.moonshot.ai/v1") - ); - assert_eq!(catalog.model_settings("gpt-5.4").unwrap().api_id, "gpt-5.4"); - assert_eq!( - catalog.get("claude-opus-4-7").unwrap().knowledge_cutoff(), - Some("May 2025") - ); - assert_eq!( - catalog - .model_settings("gpt-5.4") - .unwrap() - .controls - .reasoning_effort, - ReasoningEffort::VARIANTS - ); - assert_eq!( - catalog - .model_settings("claude-sonnet-4-5") - .unwrap() - .controls - .reasoning_effort, - ReasoningEffort::VARIANTS - ); - } - - #[test] - fn catalog_from_settings_rejects_unknown_adapter() { - let layer = minimal_settings( - r#" -[providers.test-provider] -display_name = "Test Provider" -adapter = "not_real" -enabled = true -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::UnknownAdapter { provider, adapter } - if provider == ProviderId::new("test-provider") && adapter == "not_real" - )); - } - - // ---- Codec on the route ---- - - #[test] - fn provider_codec_defaults_from_adapter() { - let catalog = Catalog::builtin(); - - for (provider, expected) in [ - ("anthropic", CodecKind::AnthropicMessages), - ("openai", CodecKind::OpenAiResponses), - ("gemini", CodecKind::GeminiGenerate), - ("moonshot", CodecKind::OpenAiCompatible), - ] { - let provider_id = ProviderId::new(provider); - assert_eq!(catalog.provider(&provider_id).unwrap().codec, expected); - assert_eq!(catalog.effective_codec(&provider_id, None), Some(expected)); - } - } - - #[test] - fn model_codec_inherits_provider_codec() { - let catalog = Catalog::builtin(); - - assert_eq!( - catalog.model_settings("claude-sonnet-4-5").unwrap().codec, - CodecKind::AnthropicMessages - ); - assert_eq!( - catalog.model_settings("gpt-5.4").unwrap().codec, - CodecKind::OpenAiResponses - ); - assert_eq!( - catalog.effective_codec(&ProviderId::anthropic(), Some("claude-sonnet-4-5")), - Some(CodecKind::AnthropicMessages) - ); - } - - #[test] - fn explicit_codec_matching_the_adapter_default_is_accepted() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -codec = "openai_compatible" -base_url = "https://api.acme.test/v1" - -[models."acme-large"] -provider = "acme" -codec = "openai_compatible" -display_name = "Acme Large" -family = "acme" - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - )) - .expect("default codec pairing should build"); - - assert_eq!( - catalog.provider(&ProviderId::new("acme")).unwrap().codec, - CodecKind::OpenAiCompatible - ); - assert_eq!( - catalog.model_settings("acme-large").unwrap().codec, - CodecKind::OpenAiCompatible - ); - assert_eq!( - catalog.effective_codec(&ProviderId::new("acme"), Some("acme-large")), - Some(CodecKind::OpenAiCompatible) - ); - } - - #[test] - fn provider_codec_outside_the_adapter_default_is_rejected() { - let layer = minimal_settings( - r#" -[providers.test-provider] -display_name = "Test Provider" -adapter = "openai" -codec = "anthropic_messages" -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::UnsupportedProviderCodec { - provider, - adapter: AdapterKind::OpenAi, - codec: CodecKind::AnthropicMessages, - expected: CodecKind::OpenAiResponses, - } if provider == ProviderId::new("test-provider") - )); - } - - #[test] - fn model_codec_outside_the_adapter_default_is_rejected() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -enabled = true - -[models.one] -provider = "test" -codec = "gemini_generate" -display_name = "One" -family = "test" -default = true - -[models.one.limits] -context_window = 1000 - -[models.one.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::UnsupportedModelCodec { - model, - adapter: AdapterKind::OpenAi, - codec: CodecKind::GeminiGenerate, - expected: CodecKind::OpenAiResponses, - } if model == "one" - )); - } - - #[test] - fn builtin_override_can_pin_the_default_codec() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.anthropic] -codec = "anthropic_messages" -"#, - )) - .expect("override pinning the default codec should build"); - - assert_eq!( - catalog.provider(&ProviderId::anthropic()).unwrap().codec, - CodecKind::AnthropicMessages - ); - } - - #[test] - /// Canonical IDs, aliases, and API IDs share one identifier namespace per - /// provider, so a collision in any of them is rejected the same way. - fn catalog_from_settings_rejects_duplicate_provider_model_selectors() { - for (declaration, expected) in [ - (r#"aliases = ["shared"]"#, "shared"), - (r#"api_id = "vendor/shared""#, "vendor/shared"), - ] { - let layer = minimal_settings(&format!( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" -enabled = true - -[providers.test.models.one] -display_name = "One" -family = "test" -{declaration} - -[providers.test.models.one.limits] -context_window = 1000 - -[providers.test.models.one.features] -tools = false -vision = false -reasoning = false - -[providers.test.models.two] -display_name = "Two" -family = "test" -{declaration} - -[providers.test.models.two.limits] -context_window = 1000 - -[providers.test.models.two.features] -tools = false -vision = false -reasoning = false -"# - )); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!( - matches!( - &err, - CatalogBuildError::DuplicateProviderModelSelector { - provider, - selector, - first, - second, - } if provider == &ProviderId::new("test") - && selector == expected - && first == "one" - && second == "two" - ), - "{declaration}: {err:?}" - ); - } - } - - #[test] - fn provider_scoped_lookup_accepts_canonical_alias_and_api_id_selectors() { - let catalog = Catalog::from_settings(&minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" -aliases = ["test-alias"] - -[providers.test.models.one] -api_id = "vendor/models/one:latest" -display_name = "One" -family = "test" -aliases = ["one-alias"] -default = true - -[providers.test.models.one.limits] -context_window = 1000 - -[providers.test.models.one.features] -tools = false -vision = false -reasoning = false -"#, - )) - .expect("provider-scoped selector fixture should build"); - - for selector in ["one", "one-alias", "vendor/models/one:latest"] { - let model = catalog - .resolve_on_provider(&ProviderId::new("test-alias"), selector) - .unwrap_or_else(|error| { - panic!("selector '{selector}' should resolve on provider alias: {error}") - }); - assert_eq!(model.provider, ProviderId::new("test"), "{selector}"); - assert_eq!(model.id, "one", "{selector}"); - } - - assert!(matches!( - catalog.select( - "vendor/models/one:latest", - None, - &HashSet::from([ProviderId::new("test")]), - ), - Err(ModelSelectionError::UnknownSelector { selector }) - if selector == "vendor/models/one:latest" - )); - } - - #[test] - fn provider_scoped_model_rejects_redundant_provider_field() { - let error = Catalog::from_settings(&minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" - -[providers.test.models.one] -provider = "test" -"#, - )) - .unwrap_err(); - - assert!(matches!( - error, - CatalogBuildError::LegacyModel(LegacyModelError::ScopedModelDeclaresProvider { - provider, - model, - }) if provider == ProviderId::new("test") && model == "one" - )); - } - - #[test] - fn provider_scoped_model_rejects_legacy_builtin_id_as_canonical_id() { - let error = Catalog::from_settings(&minimal_settings( - r#" -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" - -[providers.openrouter.models."openai/gpt-5.6-sol"] -"#, - )) - .unwrap_err(); - - assert!(matches!( - error, - CatalogBuildError::LegacyModel( - LegacyModelError::LegacyIdentifierAsModelId { - identifier, - provider, - model, - } - ) if identifier == "openai/gpt-5.6-sol" - && provider == ProviderId::new("openrouter") - && model == "gpt-5.6-sol" - )); - } - - #[test] - fn provider_aware_selection_uses_readiness_priority_and_api_ids() { - let catalog = portable_model_catalog(); - let openai = ProviderId::openai(); - let openrouter = ProviderId::new("openrouter"); - - let offerings = catalog - .list(None) - .into_iter() - .filter(|model| model.id.as_str() == "gpt-5.6-sol") - .collect::>(); - assert_eq!(offerings.len(), 2); - - let direct = catalog - .select("gpt-56-sol", None, &HashSet::from([openai.clone()])) - .unwrap(); - assert_eq!(direct.provider, openai); - assert_eq!(direct.id, "gpt-5.6-sol"); - assert_eq!(catalog.settings_for(direct).unwrap().api_id, "gpt-5.6-sol"); - - let aggregator = catalog - .select("gpt-56-sol", None, &HashSet::from([openrouter.clone()])) - .unwrap(); - assert_eq!(aggregator.provider, openrouter); - assert_eq!(aggregator.id, "gpt-5.6-sol"); - assert_eq!( - catalog.settings_for(aggregator).unwrap().api_id, - "openai/gpt-5.6-sol" - ); - - let both = HashSet::from([ProviderId::openai(), ProviderId::new("openrouter")]); - assert_eq!( - catalog.select("portable", None, &both).unwrap().provider, - ProviderId::openai() - ); - assert_eq!( - catalog - .select("portable", Some(&ProviderId::new("openrouter")), &both,) - .unwrap() - .provider, - ProviderId::new("openrouter") - ); - assert!(matches!( - catalog.select( - "portable", - Some(&ProviderId::new("openrouter")), - &HashSet::from([ProviderId::openai()]), - ), - Err(ModelSelectionError::ProviderUnavailable { provider }) - if provider == ProviderId::new("openrouter") - )); - } - - #[test] - fn selection_fallback_preserves_ready_preference_per_request() { - let catalog = portable_model_catalog(); - let openai = ProviderId::openai(); - let openrouter = ProviderId::new("openrouter"); - let ready = HashSet::from([openrouter.clone()]); - - let shared = catalog - .resolve_selection_with_catalog_fallback(Some("portable"), None, &ready) - .unwrap(); - assert_eq!(shared.provider, openrouter); - - let pinned = catalog - .resolve_selection_with_catalog_fallback(Some("portable"), Some(&openai), &ready) - .unwrap(); - assert_eq!(pinned.provider, openai); - - let unknown = catalog - .resolve_selection_with_catalog_fallback(Some("provider-private-preview"), None, &ready) - .unwrap(); - assert_eq!(unknown.provider, ProviderId::new("openrouter")); - assert_eq!(unknown.model, "provider-private-preview"); - } - - #[test] - fn legacy_builtin_selector_uses_readiness_priority_and_explicit_pins() { - let catalog = portable_model_catalog(); - let openai = ProviderId::openai(); - let openrouter = ProviderId::new("openrouter"); - let selector = "openai/gpt-5.6-sol"; - - for (eligible, expected_provider) in [ - (HashSet::from([openai.clone()]), openai.clone()), - (HashSet::from([openrouter.clone()]), openrouter.clone()), - ( - HashSet::from([openai.clone(), openrouter.clone()]), - openai.clone(), - ), - ] { - let selected = catalog - .resolve_selection(Some(selector), None, &eligible) - .unwrap(); - assert_eq!(selected.provider, expected_provider); - assert_eq!(selected.model, "gpt-5.6-sol"); - } - - let both = HashSet::from([openai, openrouter.clone()]); - let selected = catalog - .resolve_selection(Some(selector), Some(&openrouter), &both) - .unwrap(); - assert_eq!(selected.provider, openrouter); - assert_eq!(selected.model, "gpt-5.6-sol"); - } - - #[test] - fn equal_provider_priorities_use_canonical_provider_id_as_tie_breaker() { - let catalog = Catalog::from_settings(&minimal_settings( - r#" -[providers.zeta] -display_name = "Zeta" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.zeta.models.zeta] -display_name = "Zeta" -family = "test" -aliases = ["shared"] -default = true - -[providers.zeta.models.zeta.limits] -context_window = 1000 - -[providers.zeta.models.zeta.features] -tools = false -vision = false -reasoning = false - -[providers.alpha] -display_name = "Alpha" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.alpha.models.alpha] -display_name = "Alpha" -family = "test" -aliases = ["shared"] -default = true - -[providers.alpha.models.alpha.limits] -context_window = 1000 - -[providers.alpha.models.alpha.features] -tools = false -vision = false -reasoning = false -"#, - )) - .unwrap(); - - let eligible = HashSet::from([ProviderId::new("zeta"), ProviderId::new("alpha")]); - assert_eq!( - catalog.select("shared", None, &eligible).unwrap().provider, - ProviderId::new("alpha") - ); - } - - #[test] - fn canonical_id_wins_over_cross_provider_alias() { - let catalog = Catalog::from_settings(&minimal_settings( - r#" -[providers.direct] -display_name = "Direct" -adapter = "openai" -agent_profile = "openai" -priority = 1 - -[providers.direct.models.shared] -display_name = "Canonical Shared" -family = "test" -default = true - -[providers.direct.models.shared.limits] -context_window = 1000 - -[providers.direct.models.shared.features] -tools = false -vision = false -reasoning = false - -[providers.aggregator] -display_name = "Aggregator" -adapter = "openai" -agent_profile = "openai" -priority = 100 - -[providers.aggregator.models.other] -display_name = "Alias Shared" -family = "test" -aliases = ["shared"] -default = true - -[providers.aggregator.models.other.limits] -context_window = 1000 - -[providers.aggregator.models.other.features] -tools = false -vision = false -reasoning = false -"#, - )) - .unwrap(); - let eligible = HashSet::from([ProviderId::new("direct"), ProviderId::new("aggregator")]); - - let unqualified = catalog.select("shared", None, &eligible).unwrap(); - assert_eq!(unqualified.provider, ProviderId::new("direct")); - assert_eq!(unqualified.id, "shared"); - - let qualified = catalog - .resolve_on_provider(&ProviderId::new("aggregator"), "shared") - .unwrap(); - assert_eq!(qualified.id, "other"); - - let aggregator_only = HashSet::from([ProviderId::new("aggregator")]); - let portable_alias = catalog.select("shared", None, &aggregator_only).unwrap(); - assert_eq!(portable_alias.provider, ProviderId::new("aggregator")); - assert_eq!(portable_alias.id, "other"); - } - - #[test] - fn empty_api_id_is_rejected() { - let error = Catalog::from_settings(&minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.models.model] -api_id = "" -display_name = "Model" -family = "test" -default = true - -[providers.test.models.model.limits] -context_window = 1000 - -[providers.test.models.model.features] -tools = false -vision = false -reasoning = false -"#, - )) - .unwrap_err(); - - assert!(matches!( - error, - CatalogBuildError::EmptyModelApiId { provider, model } - if provider == ProviderId::new("test") && model == "model" - )); - } - - #[test] - fn catalog_from_settings_filters_disabled_providers_and_models() { - let layer = minimal_settings( - r#" -[providers.enabled] -display_name = "Enabled" -adapter = "openai" -agent_profile = "openai" -enabled = true - -[providers.disabled] -enabled = false - -[models.enabled_model] -provider = "enabled" -display_name = "Enabled Model" -family = "test" -aliases = ["enabled-alias"] -default = true - -[models.enabled_model.limits] -context_window = 1000 - -[models.enabled_model.features] -tools = false -vision = false -reasoning = false - -[models.disabled_model] -provider = "enabled" -display_name = "Disabled Model" -family = "test" -aliases = ["disabled-alias"] -enabled = false - -[models.disabled_model.limits] -context_window = 1000 - -[models.disabled_model.features] -tools = false -vision = false -reasoning = false - -[models.model_on_disabled_provider] -provider = "disabled" -display_name = "Hidden" -family = "test" - -[models.model_on_disabled_provider.limits] -context_window = 1000 - -[models.model_on_disabled_provider.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert!(catalog.get("enabled_model").is_some()); - assert!(catalog.get("enabled-alias").is_some()); - assert!(catalog.get("disabled_model").is_none()); - assert!(catalog.get("disabled-alias").is_none()); - assert!(catalog.get("model_on_disabled_provider").is_none()); - assert!(catalog.provider(&ProviderId::new("disabled")).is_none()); - } - - #[test] - fn provider_priority_drives_configured_default_ordering() { - let layer = minimal_settings( - r#" -[providers.low] -display_name = "Low" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.high] -display_name = "High" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[models.low_default] -provider = "low" -display_name = "Low Default" -family = "test" -default = true - -[models.low_default.limits] -context_window = 1000 - -[models.low_default.features] -tools = false -vision = false -reasoning = false - -[models.high_default] -provider = "high" -display_name = "High Default" -family = "test" -default = true - -[models.high_default.limits] -context_window = 1000 - -[models.high_default.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!(catalog.default_model().id, "high_default"); - assert_eq!( - catalog - .default_for_configured_ids(&[ProviderId::new("low"), ProviderId::new("high")]) - .id, - "high_default" - ); - assert_eq!( - catalog - .default_for_configured_ids(&[ProviderId::new("low")]) - .id, - "low_default" - ); - } - - #[test] - fn catalog_lists_models_by_provider_priority_then_model_id() { - let layer = minimal_settings( - r#" -[providers.zeta] -display_name = "Zeta" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[providers.alpha] -display_name = "Alpha" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[models.zeta_two] -provider = "zeta" -display_name = "Zeta Two" -family = "test" -default = true - -[models.zeta_two.limits] -context_window = 1000 - -[models.zeta_two.features] -tools = false -vision = false -reasoning = false - -[models.alpha_one] -provider = "alpha" -display_name = "Alpha One" -family = "test" -default = true - -[models.alpha_one.limits] -context_window = 1000 - -[models.alpha_one.features] -tools = false -vision = false -reasoning = false - -[models.zeta_one] -provider = "zeta" -display_name = "Zeta One" -family = "test" - -[models.zeta_one.limits] -context_window = 1000 - -[models.zeta_one.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - let ids = catalog - .list(None) - .into_iter() - .map(|model| model.id.as_str()) - .collect::>(); - - assert_eq!(ids, ["zeta_one", "zeta_two", "alpha_one"]); - assert_eq!(catalog.default_model().id, "zeta_two"); - } - - #[test] - fn provider_aliases_resolve_provider_scoped_catalog_methods() { - let layer = minimal_settings( - r#" -[providers.canonical] -display_name = "Canonical" -adapter = "openai" -agent_profile = "openai" -aliases = ["alias"] - -[models.default_model] -provider = "canonical" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - let alias = ProviderId::new("alias"); - let reference = catalog.get("default_model").unwrap(); - - assert_eq!( - catalog.provider(&alias).unwrap().id, - ProviderId::new("canonical") - ); - assert_eq!( - catalog.default_for_provider(&alias).unwrap().id, - "default_model" - ); - assert_eq!( - catalog - .default_for_configured_ids(std::slice::from_ref(&alias)) - .id, - "default_model" - ); - assert_eq!(catalog.list(Some(&alias))[0].id, "default_model"); - assert_eq!( - catalog.closest(&alias, reference).unwrap().id, - "default_model" - ); - } - - #[test] - fn probe_for_provider_prefers_enabled_probe_model_over_provider_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.probe_model] -provider = "test" -display_name = "Probe Model" -family = "test" -probe = true - -[models.probe_model.limits] -context_window = 1000 - -[models.probe_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .probe_for_provider(&ProviderId::new("test")) - .unwrap() - .id, - "probe_model" - ); - } - - #[test] - fn probe_for_provider_falls_back_to_provider_default_when_no_probe_marked() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.other_model] -provider = "test" -display_name = "Other Model" -family = "test" - -[models.other_model.limits] -context_window = 1000 - -[models.other_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .probe_for_provider(&ProviderId::new("test")) - .unwrap() - .id, - "default_model" - ); - } - - #[test] - fn probe_false_override_clears_inherited_builtin_probe_marker() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[models."gpt-5.4-mini"] -probe = false -"#, - )) - .expect("sparse built-in model override should build"); - - let openai = ProviderId::openai(); - assert_eq!( - catalog.probe_for_provider(&openai).unwrap().id, - catalog.default_for_provider(&openai).unwrap().id - ); - } - - #[test] - fn probe_for_provider_resolves_provider_alias() { - let layer = minimal_settings( - r#" -[providers.canonical] -display_name = "Canonical" -adapter = "openai" -agent_profile = "openai" -aliases = ["alias"] - -[models.default_model] -provider = "canonical" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.probe_model] -provider = "canonical" -display_name = "Probe Model" -family = "test" -probe = true - -[models.probe_model.limits] -context_window = 1000 - -[models.probe_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .probe_for_provider(&ProviderId::new("alias")) - .unwrap() - .id, - "probe_model" - ); - } - - #[test] - fn small_default_for_provider_prefers_enabled_small_default_model_over_provider_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.small_model] -provider = "test" -display_name = "Small Model" -family = "test" -small_default = true - -[models.small_model.limits] -context_window = 1000 - -[models.small_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_provider(&ProviderId::new("test")) - .unwrap() - .id, - "small_model" - ); - } - - #[test] - fn small_default_for_provider_returns_none_when_no_small_default_marked() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.other_model] -provider = "test" -display_name = "Other Model" -family = "test" - -[models.other_model.limits] -context_window = 1000 - -[models.other_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert!( - catalog - .small_default_for_provider(&ProviderId::new("test")) - .is_none() - ); - } - - #[test] - fn small_default_for_provider_resolves_provider_alias() { - let layer = minimal_settings( - r#" -[providers.canonical] -display_name = "Canonical" -adapter = "openai" -agent_profile = "openai" -aliases = ["alias"] - -[models.default_model] -provider = "canonical" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.small_model] -provider = "canonical" -display_name = "Small Model" -family = "test" -small_default = true - -[models.small_model.limits] -context_window = 1000 - -[models.small_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_provider(&ProviderId::new("alias")) - .unwrap() - .id, - "small_model" - ); - } - - #[test] - fn small_default_for_configured_ids_uses_highest_priority_configured_provider() { - let layer = minimal_settings( - r#" -[providers.low] -display_name = "Low" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.high] -display_name = "High" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[models.low_default] -provider = "low" -display_name = "Low Default" -family = "test" -default = true - -[models.low_default.limits] -context_window = 1000 - -[models.low_default.features] -tools = false -vision = false -reasoning = false - -[models.low_small] -provider = "low" -display_name = "Low Small" -family = "test" -small_default = true - -[models.low_small.limits] -context_window = 1000 - -[models.low_small.features] -tools = false -vision = false -reasoning = false - -[models.high_default] -provider = "high" -display_name = "High Default" -family = "test" -default = true - -[models.high_default.limits] -context_window = 1000 - -[models.high_default.features] -tools = false -vision = false -reasoning = false - -[models.high_small] -provider = "high" -display_name = "High Small" -family = "test" -small_default = true - -[models.high_small.limits] -context_window = 1000 - -[models.high_small.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_configured_ids(&[ - ProviderId::new("low"), - ProviderId::new("high") - ]) - .id, - "high_small" - ); - assert_eq!( - catalog - .small_default_for_configured_ids(&[ProviderId::new("low")]) - .id, - "low_small" - ); - assert_eq!( - catalog.small_default_for_configured_ids(&[]).id, - catalog.default_model().id - ); - } - - #[test] - fn small_default_for_configured_ids_skips_provider_without_a_small_model() { - let layer = minimal_settings( - r#" -[providers.low] -display_name = "Low" -adapter = "openai" -agent_profile = "openai" -priority = 10 - -[providers.high] -display_name = "High" -adapter = "openai" -agent_profile = "openai" -priority = 20 - -[models.low_small] -provider = "low" -display_name = "Low Small" -family = "test" -small_default = true - -[models.low_small.limits] -context_window = 1000 - -[models.low_small.features] -tools = false -vision = false -reasoning = false - -[models.high_default] -provider = "high" -display_name = "High Default" -family = "test" -default = true - -[models.high_default.limits] -context_window = 1000 - -[models.high_default.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - // `high` outranks `low` but marks no small default, so selection moves - // on rather than substituting `high_default`. - assert_eq!( - catalog - .small_default_for_configured_ids(&[ - ProviderId::new("low"), - ProviderId::new("high") - ]) - .id, - "low_small" - ); - } - - #[test] - fn small_default_for_configured_ids_falls_back_to_provider_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .small_default_for_configured_ids(&[ProviderId::new("test")]) - .id, - "default_model" - ); - } - - #[test] - fn multiple_small_default_models_for_provider_fail_catalog_build() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.first_small] -provider = "test" -display_name = "First Small" -family = "test" -small_default = true - -[models.first_small.limits] -context_window = 1000 - -[models.first_small.features] -tools = false -vision = false -reasoning = false - -[models.second_small] -provider = "test" -display_name = "Second Small" -family = "test" -small_default = true - -[models.second_small.limits] -context_window = 1000 - -[models.second_small.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let err = Catalog::from_settings(&layer).unwrap_err(); - - assert!(matches!( - err, - CatalogBuildError::MultipleProviderSmallDefaults { provider, models } - if provider == ProviderId::new("test") - && models == vec!["first_small".to_string(), "second_small".to_string()] - )); - } - - #[test] - fn small_default_false_override_clears_inherited_builtin_small_default_marker() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[models."gpt-5.4-mini"] -small_default = false -"#, - )) - .expect("sparse built-in model override should build"); - - let openai = ProviderId::openai(); - assert!(catalog.small_default_for_provider(&openai).is_none()); - } - - #[test] - fn multiple_probe_models_are_non_fatal_and_select_a_probe_model() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false - -[models.first_probe] -provider = "test" -display_name = "First Probe" -family = "test" -probe = true - -[models.first_probe.limits] -context_window = 1000 - -[models.first_probe.features] -tools = false -vision = false -reasoning = false - -[models.second_probe] -provider = "test" -display_name = "Second Probe" -family = "test" -probe = true - -[models.second_probe.limits] -context_window = 1000 - -[models.second_probe.features] -tools = false -vision = false -reasoning = false -"#, - ); - let catalog = Catalog::from_settings(&layer).unwrap(); - let selected = catalog - .probe_for_provider(&ProviderId::new("test")) - .unwrap() - .id - .as_str(); - - assert!(["first_probe", "second_probe"].contains(&selected)); - assert_ne!(selected, "default_model"); - } - - #[test] - fn provider_agent_profile_overrides_adapter_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai_compatible" -base_url = "https://api.test/v1" -agent_profile = "anthropic" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .provider(&ProviderId::new("test")) - .unwrap() - .agent_profile, - AgentProfileKind::Anthropic - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("test"), Some("default_model")), - Some(AgentProfileKind::Anthropic) - ); - } - - #[test] - fn adapter_defaults_provider_agent_profile_and_billing_policy() { - let settings = minimal_settings( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" - -[providers.openai] -display_name = "OpenAI" -adapter = "openai" - -[providers.gemini] -display_name = "Gemini" -adapter = "gemini" - -[providers.compat] -display_name = "Compatible" -adapter = "openai_compatible" -"#, - ); - - let providers = build_providers(&settings).unwrap(); - let provider = |id: &str| { - providers - .iter() - .find(|provider| provider.id.as_str() == id) - .unwrap() - }; - - assert_eq!( - provider("anthropic").agent_profile, - AgentProfileKind::Anthropic - ); - assert_eq!( - provider("anthropic").billing_policy, - BillingPolicy::Anthropic - ); - assert_eq!(provider("openai").agent_profile, AgentProfileKind::OpenAi); - assert_eq!(provider("openai").billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider("gemini").agent_profile, AgentProfileKind::Gemini); - assert_eq!(provider("gemini").billing_policy, BillingPolicy::Gemini); - assert_eq!(provider("compat").agent_profile, AgentProfileKind::OpenAi); - assert_eq!(provider("compat").billing_policy, BillingPolicy::OpenAi); - } - - #[test] - fn model_agent_profile_overrides_provider_profile_for_same_provider() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "anthropic" -aliases = ["alias"] - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true -agent_profile = "gemini" -aliases = ["default-alias"] - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .model_settings("default-alias") - .unwrap() - .agent_profile, - AgentProfileKind::Gemini - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("alias"), Some("default-alias")), - Some(AgentProfileKind::Gemini) - ); - } - - #[test] - fn effective_agent_profile_does_not_leak_unrelated_model_override() { - let layer = minimal_settings( - r#" -[providers.one] -display_name = "One" -adapter = "openai" -agent_profile = "openai" - -[providers.two] -display_name = "Two" -adapter = "openai" -agent_profile = "anthropic" - -[models.one_model] -provider = "one" -display_name = "One Model" -family = "test" -default = true - -[models.one_model.limits] -context_window = 1000 - -[models.one_model.features] -tools = false -vision = false -reasoning = false - -[models.two_model] -provider = "two" -display_name = "Two Model" -family = "test" -default = true -agent_profile = "gemini" - -[models.two_model.limits] -context_window = 1000 - -[models.two_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("one"), Some("two_model")), - Some(AgentProfileKind::OpenAi) - ); - } - - #[test] - fn effective_agent_profile_is_scoped_by_provider_for_shared_model_id() { - let layer = minimal_settings( - r#" -[providers.one] -display_name = "One" -adapter = "openai" -agent_profile = "openai" - -[providers.one.models.shared] -display_name = "Shared on One" -family = "test" -default = true - -[providers.one.models.shared.limits] -context_window = 1000 - -[providers.one.models.shared.features] -tools = false -vision = false -reasoning = false - -[providers.two] -display_name = "Two" -adapter = "openai" -agent_profile = "anthropic" - -[providers.two.models.shared] -display_name = "Shared on Two" -family = "test" -default = true -agent_profile = "gemini" - -[providers.two.models.shared.limits] -context_window = 1000 - -[providers.two.models.shared.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("one"), Some("shared")), - Some(AgentProfileKind::OpenAi) - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("two"), Some("shared")), - Some(AgentProfileKind::Gemini) - ); - } - - #[test] - fn omitted_agent_profile_uses_adapter_default() { - let layer = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "gemini" - -[models.default_model] -provider = "test" -display_name = "Default Model" -family = "test" -default = true - -[models.default_model.limits] -context_window = 1000 - -[models.default_model.features] -tools = false -vision = false -reasoning = false -"#, - ); - - let catalog = Catalog::from_settings(&layer).unwrap(); - - assert_eq!( - catalog - .provider(&ProviderId::new("test")) - .unwrap() - .agent_profile, - AgentProfileKind::Gemini - ); - assert_eq!( - catalog.effective_agent_profile(&ProviderId::new("test"), Some("default_model")), - Some(AgentProfileKind::Gemini) - ); - } - - #[test] - fn provider_auth_modes_and_billing_policy_are_catalog_owned() { - let settings = minimal_settings( - r#" -[providers.bearer] -display_name = "Bearer" -adapter = "openai" - -[providers.bearer.auth] -credentials = ["env:BEARER_API_KEY", "vault:BEARER_API_KEY"] - -[providers.custom] -display_name = "Custom" -adapter = "gemini" - -[providers.custom.auth] -credentials = ["env:CUSTOM_API_KEY"] -header = { custom = "x-api-key" } - -[providers.none] -display_name = "No Auth" -adapter = "openai_compatible" -billing_policy = "none" -"#, - ); - - let providers = build_providers(&settings).unwrap(); - let provider = |id: &str| { - providers - .iter() - .find(|provider| provider.id.as_str() == id) - .unwrap() - }; - - let bearer = provider("bearer"); - assert_eq!(bearer.billing_policy, BillingPolicy::OpenAi); - assert_eq!( - bearer.auth, - Some(ProviderAuthConfig { - credentials: vec![ - CredentialRef::Env("BEARER_API_KEY".to_string()), - CredentialRef::Vault("BEARER_API_KEY".to_string()), - ], - header: ApiKeyHeaderPolicy::Bearer, - }) - ); - - let custom = provider("custom"); - assert_eq!(custom.billing_policy, BillingPolicy::Gemini); - assert_eq!( - custom.auth, - Some(ProviderAuthConfig { - credentials: vec![CredentialRef::Env("CUSTOM_API_KEY".to_string())], - header: ApiKeyHeaderPolicy::Custom { - name: "x-api-key".to_string(), - }, - }) - ); - - let no_auth = provider("none"); - assert_eq!(no_auth.billing_policy, BillingPolicy::None); - assert!(no_auth.auth.is_none()); - } - - #[test] - fn provider_auth_header_defaults_to_bearer_when_omitted() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" - -[providers.test.auth] -credentials = ["env:TEST_API_KEY"] -"#, - ); - let providers = build_providers(&settings).unwrap(); - let test = providers - .iter() - .find(|provider| provider.id.as_str() == "test") - .unwrap(); - assert_eq!( - test.auth.as_ref().unwrap().header, - ApiKeyHeaderPolicy::Bearer - ); - } - - #[test] - fn catalog_from_settings_rejects_invalid_provider_auth_configs() { - let empty_api_key_credentials = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -credentials = [] -"#, - ); - assert!(matches!( - Catalog::from_settings(&empty_api_key_credentials).unwrap_err(), - CatalogBuildError::EmptyApiKeyCredentials { provider } - if provider == ProviderId::new("test") - )); - - let sigv4_on_openai = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -credentials = ["aws_sigv4"] -"#, - ); - assert!(matches!( - Catalog::from_settings(&sigv4_on_openai).unwrap_err(), - CatalogBuildError::UnsupportedAwsSigv4Credential { provider, adapter } - if provider == ProviderId::new("test") && adapter == AdapterKind::OpenAi - )); - } - - #[test] - fn provider_auth_deserialization_rejects_invalid_auth_shape() { - let invalid_header = toml::from_str::( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -credentials = ["env:TEST_API_KEY"] -header = { custom = "bad header" } -"#, - ) - .unwrap_err(); - assert!( - invalid_header - .to_string() - .contains("custom header name must be a valid HTTP header name") - ); - - let legacy_type_tag = toml::from_str::( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[providers.test.auth] -type = "api_key" -credentials = ["env:TEST_API_KEY"] -"#, - ) - .unwrap_err(); - assert!( - legacy_type_tag.to_string().contains("unknown field `type`"), - "expected unknown-field error for legacy `type` key, got: {legacy_type_tag}" - ); - } - - #[test] - fn catalog_from_settings_validates_model_controls_and_speed_costs() { - let invalid_effort = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = false -vision = false -reasoning = true -reasoning_effort = "levels" - -[models.model.controls] -reasoning_effort = ["turbo"] -"#, - ); - assert!(matches!( - Catalog::from_settings(&invalid_effort).unwrap_err(), - CatalogBuildError::InvalidReasoningEffort { model, value, .. } - if model == "model" && value == "turbo" - )); - - let undeclared_speed_cost = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "anthropic" -agent_profile = "anthropic" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = false -vision = false -reasoning = false - -[models.model.costs.speed.fast] -input_cost_per_mtok = 1.0 -"#, - ); - assert!(matches!( - Catalog::from_settings(&undeclared_speed_cost).unwrap_err(), - CatalogBuildError::UndeclaredSpeedCost { model, speed } - if model == "model" && speed == Speed::Fast - )); - } - - #[test] - fn catalog_from_settings_accepts_reasoning_effort_feature_levels() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_by_default = false -reasoning_effort = "levels" -prompt_cache = true - -[models.model.controls] -reasoning_effort = ["low", "medium"] -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - let model = catalog.get("model").unwrap(); - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::Levels - ); - assert!(model.features.prompt_cache); - assert!( - !catalog - .model_settings("model") - .unwrap() - .reasoning_by_default - ); - assert_eq!( - catalog - .model_settings("model") - .unwrap() - .controls - .reasoning_effort, - vec![ReasoningEffort::Low, ReasoningEffort::Medium] - ); - } - - #[test] - fn catalog_from_settings_accepts_reasoning_effort_feature_always_adaptive() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - let model = catalog.get("model").unwrap(); - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::AlwaysAdaptive - ); - assert!(model.supports_reasoning_effort()); - assert!( - catalog - .model_settings("model") - .unwrap() - .reasoning_by_default - ); - // Always-adaptive models get the full default effort controls, same as - // Levels. - assert_eq!( - catalog - .model_settings("model") - .unwrap() - .controls - .reasoning_effort, - ReasoningEffort::VARIANTS.to_vec() - ); - } - - #[test] - fn catalog_from_settings_accepts_reasoning_effort_controls_without_native_effort_feature() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_by_default = true -reasoning_effort = "none" - -[models.model.controls] -reasoning_effort = ["low"] -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - let model = catalog.get("model").unwrap(); - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::None - ); - assert!( - catalog - .model_settings("model") - .unwrap() - .reasoning_by_default - ); - assert_eq!( - catalog - .model_settings("model") - .unwrap() - .controls - .reasoning_effort, - vec![ReasoningEffort::Low] - ); - } - - #[test] - fn catalog_from_settings_rejects_reasoning_effort_controls_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_effort = "none" - -[models.model.controls] -reasoning_effort = ["low"] -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::ReasoningEffortControlsWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_reasoning_effort_feature_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_effort = "levels" -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::ReasoningEffortWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_default_reasoning_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_by_default = true -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::DefaultReasoningWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_cache_control_breakpoints_without_prompt_cache() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://example.test/v1" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -cache_control_breakpoints = true -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::CacheControlBreakpointsWithoutPromptCache { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_rejects_always_adaptive_effort_without_reasoning() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = false -reasoning_effort = "always_adaptive" -"#, - ); - - assert!(matches!( - Catalog::from_settings(&settings).unwrap_err(), - CatalogBuildError::ReasoningEffortWithoutReasoning { model } - if model == "model" - )); - } - - #[test] - fn catalog_from_settings_sampling_params_defaults_true_and_accepts_false() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" -agent_profile = "openai" - -[models.with-sampling] -provider = "test" -display_name = "With" -family = "test" -default = true - -[models.with-sampling.limits] -context_window = 1000 - -[models.with-sampling.features] -tools = true -vision = false -reasoning = false - -[models.no-sampling] -provider = "test" -display_name = "Without" -family = "test" - -[models.no-sampling.limits] -context_window = 1000 - -[models.no-sampling.features] -tools = true -vision = false -reasoning = false -sampling_params = false -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - assert!( - catalog - .get("with-sampling") - .unwrap() - .features - .sampling_params - ); - assert!(!catalog.get("no-sampling").unwrap().features.sampling_params); - } - - // ---- Provider / catalog data integrity tests ---- - - #[test] - fn every_provider_has_catalog_models() { - let catalog = Catalog::builtin(); - for provider in catalog.providers() { - let models = catalog.list(Some(&provider.id)); - assert!( - !models.is_empty(), - "Provider {:?} has no models in catalog", - provider.id, - ); - } - } - - #[test] - fn every_provider_has_exactly_one_default_model() { - let catalog = Catalog::builtin(); - for provider in catalog.providers() { - let defaults: Vec<_> = catalog - .list(Some(&provider.id)) - .into_iter() - .filter(|m| m.default) - .collect(); - assert_eq!( - defaults.len(), - 1, - "Provider {:?} should have exactly one default model, found {}: {:?}", - provider.id, - defaults.len(), - defaults.iter().map(|m| &m.id).collect::>() - ); - } - } - - #[test] - fn every_catalog_model_provider_has_catalog_provider() { - let catalog = Catalog::builtin(); - for model in catalog.list(None) { - assert!( - catalog.provider(&model.provider).is_some(), - "catalog model '{}' provider {:?} has no provider metadata", - model.id, - model.provider, - ); - } - } - - // ---- Model info snapshot tests ---- - - #[test] - fn get_model_info_by_id() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap(); - insta::assert_debug_snapshot!(info, @r#" - Model { - id: "claude-opus-4-6", - provider: anthropic, - family: "claude-4", - display_name: "Claude Opus 4.6", - limits: ModelLimits { - context_window: 1000000, - max_output: Some( - 128000, - ), - }, - training: Some( - "2025-08-01", - ), - knowledge_cutoff: Some( - "May 2025", - ), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 5.0, - ), - output_cost_per_mtok: Some( - 25.0, - ), - cache_input_cost_per_mtok: Some( - 0.5, - ), - }, - estimated_output_tps: Some( - 25.0, - ), - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn get_model_info_returns_none_for_unknown() { - assert!(Catalog::builtin().get("nonexistent-model").is_none()); - } - - #[test] - fn kimi_k2_5_in_catalog() { - let m = Catalog::builtin().get("kimi-k2.5").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "kimi-k2.5", - provider: moonshot, - family: "kimi-k2", - display_name: "Kimi K2.5", - limits: ModelLimits { - context_window: 262144, - max_output: Some( - 32768, - ), - }, - training: Some( - "2025-10-01", - ), - knowledge_cutoff: Some( - "October 2025", - ), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: None, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 0.6, - ), - output_cost_per_mtok: Some( - 3.0, - ), - cache_input_cost_per_mtok: Some( - 0.1, - ), - }, - estimated_output_tps: Some( - 50.0, - ), - aliases: [], - default: false, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn kimi_k3_in_catalog() { - let catalog = Catalog::builtin(); - let m = catalog.get("kimi-k3").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "kimi-k3", - provider: moonshot, - family: "kimi-k3", - display_name: "Kimi K3", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: AlwaysAdaptive, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: false, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 3.0, - ), - output_cost_per_mtok: Some( - 15.0, - ), - cache_input_cost_per_mtok: Some( - 0.3, - ), - }, - estimated_output_tps: None, - aliases: [ - "kimi", - ], - default: true, - small_default: false, - configured: false, - } - "#); - assert_eq!( - catalog - .model_settings("kimi-k3") - .unwrap() - .controls - .reasoning_effort, - vec![ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ] - ); - } - - #[test] - fn kimi_alias() { - assert_eq!(Catalog::builtin().get("kimi").unwrap().id, "kimi-k3"); - } - - #[test] - fn glm_4_7_in_catalog() { - let m = Catalog::builtin().get("glm-4.7").unwrap(); - assert_eq!(m.provider, ProviderId::new("zai")); - assert_eq!(Catalog::builtin().get("glm4").unwrap().id, "glm-4.7"); - } - - #[test] - fn glm_5_2_in_catalog() { - let catalog = Catalog::builtin(); - let model = catalog.get("glm-5.2").expect("GLM 5.2 should be present"); - insta::assert_debug_snapshot!(model, @r#" - Model { - id: "glm-5.2", - provider: zai, - family: "glm-5", - display_name: "GLM 5.2", - limits: ModelLimits { - context_window: 1048576, - max_output: Some( - 131072, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - High, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 1.4, - ), - output_cost_per_mtok: Some( - 4.4, - ), - cache_input_cost_per_mtok: Some( - 0.26, - ), - }, - estimated_output_tps: None, - aliases: [ - "glm", - "glm5", - "glm52", - "glm5.2", - ], - default: true, - small_default: false, - configured: false, - } - "#); - - let settings = catalog - .model_settings("glm-5.2") - .expect("GLM 5.2 settings should be present"); - assert_eq!(settings.api_id, "glm-5.2"); - assert_eq!(settings.controls.reasoning_effort, vec![ - ReasoningEffort::High, - ReasoningEffort::Max - ]); - assert_eq!(catalog.get("glm").unwrap().id, "glm-5.2"); - assert_eq!(catalog.get("glm5").unwrap().id, "glm-5.2"); - assert_eq!(catalog.get("glm52").unwrap().id, "glm-5.2"); - assert_eq!(catalog.get("glm5.2").unwrap().id, "glm-5.2"); - } - - #[test] - fn minimax_m2_5_in_catalog() { - let m = Catalog::builtin().get("minimax-m2.5").unwrap(); - assert_eq!(m.provider, ProviderId::new("minimax")); - } - - #[test] - fn mercury_2_in_catalog() { - let m = Catalog::builtin().get("mercury-2").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "mercury-2", - provider: inception, - family: "mercury", - display_name: "Mercury 2", - limits: ModelLimits { - context_window: 131072, - max_output: Some( - 50000, - ), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 0.25, - ), - output_cost_per_mtok: Some( - 0.75, - ), - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: Some( - 1000.0, - ), - aliases: [ - "mercury", - ], - default: true, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn mercury_alias_resolves_to_mercury_2() { - assert_eq!(Catalog::builtin().get("mercury").unwrap().id, "mercury-2"); - } - - #[test] - fn gpt_5_4_pro_in_catalog() { - let m = Catalog::builtin().get("gpt-5.4-pro").unwrap(); - insta::assert_debug_snapshot!(m, @r#" - Model { - id: "gpt-5.4-pro", - provider: openai, - family: "gpt-5", - display_name: "GPT-5.4 Pro", - limits: ModelLimits { - context_window: 1047576, - max_output: Some( - 128000, - ), - }, - training: Some( - "2025-08-31", - ), - knowledge_cutoff: Some( - "April 2025", - ), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls { - reasoning_effort: [ - Low, - Medium, - High, - XHigh, - Max, - ], - }, - costs: ModelCosts { - input_cost_per_mtok: Some( - 30.0, - ), - output_cost_per_mtok: Some( - 180.0, - ), - cache_input_cost_per_mtok: Some( - 3.0, - ), - }, - estimated_output_tps: Some( - 20.0, - ), - aliases: [ - "gpt54-pro", - "gpt-54-pro", - ], - default: false, - small_default: false, - configured: false, - } - "#); - } - - #[test] - fn gpt54_alias() { - assert_eq!(Catalog::builtin().get("gpt54").unwrap().id, "gpt-5.4"); - } - - #[test] - fn gpt_54_hyphenated_alias() { - assert_eq!(Catalog::builtin().get("gpt-54").unwrap().id, "gpt-5.4"); - } - - #[test] - fn gpt_54_pro_hyphenated_alias() { - assert_eq!( - Catalog::builtin().get("gpt-54-pro").unwrap().id, - "gpt-5.4-pro" - ); - } - - #[test] - fn gpt_54_mini_hyphenated_alias() { - assert_eq!( - Catalog::builtin().get("gpt-54-mini").unwrap().id, - "gpt-5.4-mini" - ); - } - - #[test] - fn openai_codex_default_context_windows_match_codex_catalog() { - let catalog = Catalog::builtin(); - - for model in [ - "gpt-5.2", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.5", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", - ] { - assert_eq!( - catalog.get(model).unwrap().context_window(), - 272_000, - "{model} should use the Codex-safe default context window" - ); - } - } - - #[test] - fn openai_context_window_can_be_overridden_for_direct_api_usage() { - let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( - r#" -[providers.openai.models."gpt-5.5".limits] -context_window = 1050000 -"#, - )) - .expect("sparse built-in model limit override should build"); - - let model = catalog.get("gpt-5.5").unwrap(); - assert_eq!(model.context_window(), 1_050_000); - assert_eq!(model.max_output(), Some(128_000)); - } - - // ---- Closest model tests ---- - - #[test] - fn closest_model_sonnet_to_gemini() { - let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); - let result = Catalog::builtin() - .closest(&ProviderId::gemini(), sonnet) - .unwrap(); - assert_eq!(result.id, "gemini-3.1-pro-preview"); - } - - #[test] - fn closest_model_haiku_to_moonshot() { - let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); - assert!( - Catalog::builtin() - .closest(&ProviderId::new("moonshot"), haiku) - .is_none() - ); - } - - #[test] - fn closest_model_no_capability_match() { - let glm = Catalog::builtin().get("glm-4.7").unwrap(); - assert!( - Catalog::builtin() - .closest(&ProviderId::gemini(), glm) - .is_none() - ); - } - - // ---- Cost tests ---- - - #[test] - fn model_info_costs() { - let claude = Catalog::builtin().get("claude-opus-4-6").unwrap(); - assert_eq!(claude.costs.input_cost_per_mtok, Some(5.0)); - assert_eq!(claude.costs.output_cost_per_mtok, Some(25.0)); - - let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); - assert_eq!(sonnet.costs.input_cost_per_mtok, Some(3.0)); - } -} diff --git a/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml b/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml deleted file mode 100644 index 6d0adb16c..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/anthropic.toml +++ /dev/null @@ -1,261 +0,0 @@ -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -api_key_url = "https://console.anthropic.com/settings/keys" -base_url = "https://api.anthropic.com/v1" -priority = 100 - -[providers.anthropic.auth] -credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"] -header = { custom = "x-api-key" } - -[providers.anthropic.models."claude-fable-5"] -display_name = "Claude Fable 5" -family = "claude-5" -aliases = ["fable", "claude-fable"] -agent_profile = "claude-5" - -[providers.anthropic.models."claude-fable-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-fable-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[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 - -[providers.anthropic.models."claude-opus-5"] -display_name = "Claude Opus 5" -family = "claude-5" -training = "2026-05-01" -knowledge_cutoff = "May 2026" -aliases = ["opus", "claude-opus"] -agent_profile = "claude-5" - -[providers.anthropic.models."claude-opus-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-opus-5".controls] -speed = ["fast"] - -[providers.anthropic.models."claude-opus-5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.anthropic.models."claude-opus-5".costs.speed.fast] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -[providers.anthropic.models."claude-sonnet-5"] -display_name = "Claude Sonnet 5" -family = "claude-5" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -default = true -aliases = ["sonnet", "claude-sonnet"] -agent_profile = "claude-5" - -[providers.anthropic.models."claude-sonnet-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-sonnet-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -# Introductory pricing through August 31, 2026. -[providers.anthropic.models."claude-sonnet-5".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 10.0 -cache_input_cost_per_mtok = 0.2 - -[providers.anthropic.models."claude-opus-4-8"] -display_name = "Claude Opus 4.8" -family = "claude-4" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -estimated_output_tps = 25 - -[providers.anthropic.models."claude-opus-4-8".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-4-8".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-opus-4-8".controls] -speed = ["fast"] - -[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 - -[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 - -[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 - -[providers.anthropic.models."claude-opus-4-7".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-4-7".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.anthropic.models."claude-opus-4-7".controls] -speed = ["fast"] - -[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 - -[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 - -[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 - -[providers.anthropic.models."claude-opus-4-6".limits] -context_window = 1000000 -max_output = 128000 - -[providers.anthropic.models."claude-opus-4-6".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.anthropic.models."claude-opus-4-6".controls] -speed = ["fast"] - -[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 - -[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 - -[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 - -[providers.anthropic.models."claude-sonnet-4-5".limits] -context_window = 200000 -max_output = 64000 - -[providers.anthropic.models."claude-sonnet-4-5".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[providers.anthropic.models."claude-sonnet-4-5".controls] -reasoning_effort = ["low", "medium", "high", "xhigh", "max"] - -[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 - -[providers.anthropic.models."claude-sonnet-4-6"] -display_name = "Claude Sonnet 4.6" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 50 - -[providers.anthropic.models."claude-sonnet-4-6".limits] -context_window = 200000 -max_output = 64000 - -[providers.anthropic.models."claude-sonnet-4-6".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[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 - -[providers.anthropic.models."claude-haiku-4-5"] -display_name = "Claude Haiku 4.5" -family = "claude-4" -training = "2025-08-01" -knowledge_cutoff = "May 2025" -estimated_output_tps = 100 -aliases = ["haiku", "claude-haiku"] -probe = true -small_default = true - -[providers.anthropic.models."claude-haiku-4-5".limits] -context_window = 200000 -max_output = 8192 - -[providers.anthropic.models."claude-haiku-4-5".features] -tools = true -vision = true -reasoning = false -prompt_cache = true - -[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/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml deleted file mode 100644 index 21384d9b2..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/bedrock-openai.toml +++ /dev/null @@ -1,75 +0,0 @@ -[providers.bedrock-openai] -display_name = "Amazon Bedrock (OpenAI frontier)" -adapter = "openai" -api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" -base_url = "https://bedrock-mantle.us-east-1.api.aws/openai/v1" -priority = 19 -enabled = false - -[providers.bedrock-openai.auth] -credentials = [ - "env:AWS_BEARER_TOKEN_BEDROCK", - "env:BEDROCK_API_KEY", - "vault:AWS_BEARER_TOKEN_BEDROCK", - "vault:BEDROCK_API_KEY", -] - -# OpenAI's frontier models on Bedrock (GPT-5.5/5.4) are served ONLY by the -# bedrock-mantle endpoint's OpenAI Responses API — they are not reachable -# through Converse or InvokeModel on bedrock-runtime. That surface speaks -# the openai_responses dialect with a Bedrock API key as the bearer token, -# so this companion provider row is pure configuration over the existing -# openai adapter: same AWS account and key as the `bedrock` provider, a -# different endpoint and wire dialect. -# -# Notes: -# - Auth is Bedrock-API-key only on this row (SigV4 on mantle uses the -# `bedrock-mantle` signing name, which the openai adapter does not do). -# - bedrock-mantle is regional (13 regions); change base_url to -# `https://bedrock-mantle..api.aws/openai/v1` as needed. -# - Responses state: Fabro always sends `store: false`, so nothing is -# retained under mantle's default 30-day Project retention. -# -# To enable, add to ~/.fabro/settings.toml: -# -# [llm.providers.bedrock-openai] -# enabled = true - -[providers.bedrock-openai.models."gpt-5.5"] -api_id = "openai.gpt-5.5" -display_name = "GPT-5.5 (Bedrock)" -family = "gpt-5" -default = true - -[providers.bedrock-openai.models."gpt-5.5".limits] -context_window = 272000 -max_output = 128000 - -[providers.bedrock-openai.models."gpt-5.5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.bedrock-openai.models."gpt-5.5".costs] -input_cost_per_mtok = 5.5 -output_cost_per_mtok = 33.0 - -[providers.bedrock-openai.models."gpt-5.4"] -api_id = "openai.gpt-5.4" -display_name = "GPT-5.4 (Bedrock)" -family = "gpt-5" - -[providers.bedrock-openai.models."gpt-5.4".limits] -context_window = 272000 -max_output = 128000 - -[providers.bedrock-openai.models."gpt-5.4".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[providers.bedrock-openai.models."gpt-5.4".costs] -input_cost_per_mtok = 2.75 -output_cost_per_mtok = 16.5 diff --git a/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml b/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml deleted file mode 100644 index 401037c42..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/bedrock.toml +++ /dev/null @@ -1,409 +0,0 @@ -[providers.bedrock] -display_name = "Amazon Bedrock" -adapter = "bedrock" -api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" -base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" -priority = 20 -enabled = false - -[providers.bedrock.auth] -# An explicit Bedrock API key wins (from the process env, or the server -# vault via `fabro secret set `, matching every other provider's -# env-then-vault order); SigV4 (the AWS default credential chain, resolved -# at request time) is the fallback. `aws_sigv4` always resolves, which is -# why this provider ships disabled: enabling it is the operator's statement -# that AWS credentials are expected to work. The key is read from either -# `AWS_BEARER_TOKEN_BEDROCK` (the AWS-canonical name, also honored by the -# AWS SDKs/CLI) or `BEDROCK_API_KEY` (Fabro's `_API_KEY` -# convention); the env names are checked before the vault. -credentials = [ - "env:AWS_BEARER_TOKEN_BEDROCK", - "env:BEDROCK_API_KEY", - "vault:AWS_BEARER_TOKEN_BEDROCK", - "vault:BEDROCK_API_KEY", - "aws_sigv4", -] - -# To enable Bedrock, add the following to ~/.fabro/settings.toml: -# -# [llm.providers.bedrock] -# enabled = true -# base_url = "https://bedrock-runtime..amazonaws.com" -# -# The signing region is derived from the base_url. Authenticate with -# either a Bedrock API key (AWS_BEARER_TOKEN_BEDROCK or BEDROCK_API_KEY) or -# any AWS default credential chain source (env keys, profile, IMDS, IRSA, SSO). -# -# Model ids use cross-region inference profiles (`us.` / `global.` -# prefixes) where on-demand access requires them. Pricing rows are -# best-effort estimates from June 2026 list prices. - -# ---------- Anthropic Claude ---------- -# -# Claude bills Anthropic-style cache reads/writes, so these rows override -# the provider's billing default. Claude 5 models appear at the end of this -# file because their Bedrock deployments pin sampling parameters and require -# extra endpoint-specific handling. - -[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" - -[providers.bedrock.models."claude-sonnet-4-6".limits] -context_window = 1000000 -max_output = 64000 - -[providers.bedrock.models."claude-sonnet-4-6".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[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 - -[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" - -[providers.bedrock.models."claude-opus-4-8".limits] -context_window = 1000000 -max_output = 128000 - -[providers.bedrock.models."claude-opus-4-8".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[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 - -[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 - -[providers.bedrock.models."claude-haiku-4-5".limits] -context_window = 200000 -max_output = 64000 - -[providers.bedrock.models."claude-haiku-4-5".features] -tools = true -vision = true -reasoning = false -prompt_cache = true - -[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 - -# ---------- OpenAI open-weights ---------- -# -# 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. - -[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" - -[providers.bedrock.models."gpt-oss-120b".limits] -context_window = 128000 -max_output = 16384 - -[providers.bedrock.models."gpt-oss-120b".features] -tools = true -vision = false -reasoning = true - -[providers.bedrock.models."gpt-oss-120b".costs] -input_cost_per_mtok = 0.15 -output_cost_per_mtok = 0.60 - -[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" - -[providers.bedrock.models."gpt-oss-20b".limits] -context_window = 128000 -max_output = 16384 - -[providers.bedrock.models."gpt-oss-20b".features] -tools = true -vision = false -reasoning = true - -[providers.bedrock.models."gpt-oss-20b".costs] -input_cost_per_mtok = 0.07 -output_cost_per_mtok = 0.30 - -# ---------- Amazon Nova ---------- - -[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" - -[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 - -[providers.bedrock.models."nova-2-lite".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."nova-2-lite".costs] -input_cost_per_mtok = 0.30 -output_cost_per_mtok = 2.50 - -# ---------- Open-weights ---------- - -[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" - -[providers.bedrock.models."llama-4-maverick".limits] -context_window = 1000000 -max_output = 8192 - -[providers.bedrock.models."llama-4-maverick".features] -tools = true -vision = true -reasoning = false - -[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" - -[providers.bedrock.models."mistral-large-3".limits] -context_window = 256000 -max_output = 32768 - -[providers.bedrock.models."mistral-large-3".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."mistral-large-3".costs] -input_cost_per_mtok = 0.50 -output_cost_per_mtok = 1.50 - -[providers.bedrock.models."devstral-2"] -api_id = "mistral.devstral-2-123b" -display_name = "Devstral 2 (Bedrock)" -family = "devstral" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."devstral-2".limits] -context_window = 256000 -max_output = 32768 - -[providers.bedrock.models."devstral-2".features] -tools = true -vision = false -reasoning = false - -[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" - -[providers.bedrock.models."deepseek-v3.2".limits] -context_window = 164000 -max_output = 8192 - -[providers.bedrock.models."deepseek-v3.2".features] -tools = true -vision = false -reasoning = true - -[providers.bedrock.models."deepseek-v3.2".costs] -input_cost_per_mtok = 0.62 -output_cost_per_mtok = 1.85 - -# Qwen3 Coder Next: omitted pending a verified Bedrock model/inference-profile -# id. The fabro id is not itself a valid Bedrock identifier (Converse returns -# "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: -# [providers.bedrock.models."qwen3-coder-next"] -# api_id = "" -# display_name = "Qwen3 Coder Next (Bedrock)" -# family = "qwen3" -# billing_policy = "openai" -# agent_profile = "openai" -# [providers.bedrock.models."qwen3-coder-next".limits] -# context_window = 256000 -# max_output = 16384 -# [providers.bedrock.models."qwen3-coder-next".features] -# tools = true - -[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" - -[providers.bedrock.models."kimi-k2.5".limits] -context_window = 262144 -max_output = 16384 - -[providers.bedrock.models."kimi-k2.5".features] -tools = true -vision = true -reasoning = false - -[providers.bedrock.models."kimi-k2.5".costs] -input_cost_per_mtok = 0.60 -output_cost_per_mtok = 3.00 - -[providers.bedrock.models."glm-5"] -api_id = "zai.glm-5" -display_name = "GLM 5 (Bedrock)" -family = "glm" -billing_policy = "openai" -agent_profile = "openai" - -[providers.bedrock.models."glm-5".limits] -context_window = 200000 -max_output = 128000 - -[providers.bedrock.models."glm-5".features] -tools = true -vision = false -reasoning = false - -[providers.bedrock.models."glm-5".costs] -input_cost_per_mtok = 1.00 -output_cost_per_mtok = 3.20 - -[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" - -[providers.bedrock.models."minimax-m2.5".limits] -context_window = 196000 -max_output = 8192 - -[providers.bedrock.models."minimax-m2.5".features] -tools = true -vision = false -reasoning = false - -[providers.bedrock.models."minimax-m2.5".costs] -input_cost_per_mtok = 0.30 -output_cost_per_mtok = 1.20 - -[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" - -[providers.bedrock.models."nemotron-3-super".limits] -context_window = 256000 -max_output = 32768 - -[providers.bedrock.models."nemotron-3-super".features] -tools = true -vision = false -reasoning = false - -# Claude Fable 5: adaptive thinking is always on server-side; the row pins -# sampling_params = false so the Converse encoder drops temperature/top_p -# (Bedrock rejects them for this model). Requires the account-level -# provider_data_share opt-in in the Bedrock console. Effort-level mapping -# through additionalModelRequestFields is a named follow-up, so -# reasoning_effort stays undeclared here (requests carrying one are -# rejected up front rather than silently dropped). - -[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" -agent_profile = "claude-5" - -[providers.bedrock.models."claude-fable-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.bedrock.models."claude-fable-5".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[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 - -# Claude Sonnet 5 uses adaptive thinking by default and rejects non-default -# sampling parameters. Effort-level mapping through -# additionalModelRequestFields is a named follow-up, as for Fable 5. - -[providers.bedrock.models."claude-sonnet-5"] -api_id = "us.anthropic.claude-sonnet-5" -display_name = "Claude Sonnet 5 (Bedrock)" -family = "claude-5" -billing_policy = "anthropic" -default = true -agent_profile = "claude-5" - -[providers.bedrock.models."claude-sonnet-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.bedrock.models."claude-sonnet-5".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -# Introductory pricing through August 31, 2026. -[providers.bedrock.models."claude-sonnet-5".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 10.0 -cache_input_cost_per_mtok = 0.2 diff --git a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml deleted file mode 100644 index e97981178..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml +++ /dev/null @@ -1,74 +0,0 @@ -[providers.deepseek] -display_name = "DeepSeek" -adapter = "openai_compatible" -api_key_url = "https://platform.deepseek.com/api_keys" -base_url = "https://api.deepseek.com" -priority = 75 - -[providers.deepseek.auth] -credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"] - -# DeepSeek V4 uses thinking mode by default. The API accepts sampling -# parameters in that mode but ignores them, so Fabro omits those parameters. -# Prompt caching is automatic and usage reports prompt_cache_hit_tokens. -# Prices are from api-docs.deepseek.com/quick_start/pricing, verified -# 2026-07-31. - -[providers.deepseek.models."deepseek-v4-flash"] -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] -# DeepSeek uses a general coding prompt and standard JSON function tools. -agent_profile = "openai" -default = true -small_default = true -probe = true - -[providers.deepseek.models."deepseek-v4-flash".limits] -context_window = 1048576 -max_output = 384000 - -[providers.deepseek.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.deepseek.models."deepseek-v4-flash".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.deepseek.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.14 -output_cost_per_mtok = 0.28 -cache_input_cost_per_mtok = 0.0028 - -[providers.deepseek.models."deepseek-v4-pro"] -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" - -[providers.deepseek.models."deepseek-v4-pro".limits] -context_window = 1048576 -max_output = 384000 - -[providers.deepseek.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.deepseek.models."deepseek-v4-pro".controls] -# V4 Pro currently maps low to high. Keep only its distinct effort levels; -# DeepSeek says it plans to change Pro's mapping in early August 2026. -reasoning_effort = ["high", "max"] - -[providers.deepseek.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 0.435 -output_cost_per_mtok = 0.87 -cache_input_cost_per_mtok = 0.003625 diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml deleted file mode 100644 index 48024f3ea..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml +++ /dev/null @@ -1,281 +0,0 @@ -[providers.fireworks] -display_name = "Fireworks AI" -adapter = "openai_compatible" -api_key_url = "https://app.fireworks.ai/settings/users/api-keys" -base_url = "https://api.fireworks.ai/inference/v1" -priority = 30 -enabled = false - -[providers.fireworks.auth] -credentials = ["env:FIREWORKS_API_KEY", "vault:FIREWORKS_API_KEY"] - -# To enable Fireworks, add the following to ~/.fabro/settings.toml: -# -# [llm.providers.fireworks] -# enabled = true -# -# Then run `fabro provider login --provider fireworks` to store the API key, -# or set the FIREWORKS_API_KEY environment variable. -# -# api_id values use Fireworks account-scoped paths; dots in upstream model -# names become "p" (glm-5.2 -> glm-5p2). `GET /v1/models` only returns a -# featured subset of serverless models, so validate api_ids against -# /chat/completions, not the models list. -# -# Prompt caching is automatic prefix caching (no cache_control breakpoints); -# serverless responses report prompt_tokens_details.cached_tokens in the -# usage body. Costs below are from docs.fireworks.ai/serverless/pricing, -# verified 2026-08-04. Rows use standard-tier prices unless noted otherwise. - -[providers.fireworks.models."kimi-k3"] -api_id = "accounts/fireworks/models/kimi-k3" -display_name = "Kimi K3" -family = "kimi-k3" -agent_profile = "kimi" - -[providers.fireworks.models."kimi-k3".limits] -context_window = 1048576 -max_output = 131072 - -[providers.fireworks.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."kimi-k3".controls] -reasoning_effort = ["low", "medium", "high"] - -[providers.fireworks.models."kimi-k3".costs] -input_cost_per_mtok = 3.0 -output_cost_per_mtok = 15.0 -cache_input_cost_per_mtok = 0.3 - -# Fireworks exposes the Fast tier through a separate router model ID. Its -# published prices are 50% above the standard Kimi K3 rates. -[providers.fireworks.models."kimi-k3-fast"] -api_id = "accounts/fireworks/routers/kimi-k3-fast" -display_name = "Kimi K3 Fast" -family = "kimi-k3" -agent_profile = "kimi" - -[providers.fireworks.models."kimi-k3-fast".limits] -context_window = 1048576 -max_output = 131072 - -[providers.fireworks.models."kimi-k3-fast".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."kimi-k3-fast".controls] -reasoning_effort = ["low", "medium", "high"] - -[providers.fireworks.models."kimi-k3-fast".costs] -input_cost_per_mtok = 4.5 -output_cost_per_mtok = 22.5 -cache_input_cost_per_mtok = 0.45 - -[providers.fireworks.models."kimi-k2.7-code"] -api_id = "accounts/fireworks/models/kimi-k2p7-code" -display_name = "Kimi K2.7 Code" -family = "kimi-k2" -default = true - -[providers.fireworks.models."kimi-k2.7-code".limits] -context_window = 262144 -max_output = 32768 - -[providers.fireworks.models."kimi-k2.7-code".features] -tools = true -vision = true -reasoning = true -prompt_cache = true - -[providers.fireworks.models."kimi-k2.7-code".costs] -input_cost_per_mtok = 0.95 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.19 - -[providers.fireworks.models."kimi-k2.6"] -api_id = "accounts/fireworks/models/kimi-k2p6" -display_name = "Kimi K2.6" -family = "kimi-k2" - -[providers.fireworks.models."kimi-k2.6".limits] -context_window = 262144 -max_output = 16384 - -[providers.fireworks.models."kimi-k2.6".features] -tools = true -vision = false -reasoning = false -prompt_cache = true - -[providers.fireworks.models."kimi-k2.6".costs] -input_cost_per_mtok = 0.95 -output_cost_per_mtok = 4.0 -cache_input_cost_per_mtok = 0.16 - -[providers.fireworks.models."deepseek-v4-pro"] -api_id = "accounts/fireworks/models/deepseek-v4-pro" -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" - -[providers.fireworks.models."deepseek-v4-pro".limits] -context_window = 1048576 -max_output = 16384 - -[providers.fireworks.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."deepseek-v4-pro".controls] -# Fireworks promotes low/medium to high and xhigh to max for DeepSeek V4. -reasoning_effort = ["high", "max"] - -[providers.fireworks.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 1.74 -output_cost_per_mtok = 3.48 -cache_input_cost_per_mtok = 0.145 - -[providers.fireworks.models."deepseek-v4-flash"] -api_id = "accounts/fireworks/models/deepseek-v4-flash" -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] -agent_profile = "openai" - -[providers.fireworks.models."deepseek-v4-flash".limits] -context_window = 1048576 -max_output = 384000 - -[providers.fireworks.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.fireworks.models."deepseek-v4-flash".controls] -reasoning_effort = ["high", "max"] - -[providers.fireworks.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.14 -output_cost_per_mtok = 0.28 -cache_input_cost_per_mtok = 0.028 - -[providers.fireworks.models."glm-5.2"] -api_id = "accounts/fireworks/models/glm-5p2" -display_name = "GLM 5.2 (via Fireworks)" -family = "glm-5" - -[providers.fireworks.models."glm-5.2".limits] -context_window = 1048576 -max_output = 131072 - -[providers.fireworks.models."glm-5.2".features] -tools = true -vision = false -reasoning = true -prompt_cache = true - -[providers.fireworks.models."glm-5.2".costs] -input_cost_per_mtok = 1.4 -output_cost_per_mtok = 4.4 -cache_input_cost_per_mtok = 0.14 - -[providers.fireworks.models."minimax-m2.7"] -api_id = "accounts/fireworks/models/minimax-m2p7" -display_name = "MiniMax M2.7" -family = "minimax-m2" - -[providers.fireworks.models."minimax-m2.7".limits] -context_window = 196608 -max_output = 16384 - -[providers.fireworks.models."minimax-m2.7".features] -tools = true -vision = false -reasoning = false -prompt_cache = true - -[providers.fireworks.models."minimax-m2.7".costs] -input_cost_per_mtok = 0.3 -output_cost_per_mtok = 1.2 -cache_input_cost_per_mtok = 0.059 - -[providers.fireworks.models."qwen3.7-plus"] -api_id = "accounts/fireworks/models/qwen3p7-plus" -display_name = "Qwen3.7 Plus" -family = "qwen3" - -[providers.fireworks.models."qwen3.7-plus".limits] -context_window = 262144 -max_output = 16384 - -[providers.fireworks.models."qwen3.7-plus".features] -tools = true -vision = true -reasoning = false -prompt_cache = true - -[providers.fireworks.models."qwen3.7-plus".costs] -input_cost_per_mtok = 0.4 -output_cost_per_mtok = 1.6 -cache_input_cost_per_mtok = 0.08 - -[providers.fireworks.models."gpt-oss-120b"] -api_id = "accounts/fireworks/models/gpt-oss-120b" -display_name = "GPT-OSS 120B" -family = "gpt-oss" - -[providers.fireworks.models."gpt-oss-120b".limits] -context_window = 131072 -max_output = 32768 - -[providers.fireworks.models."gpt-oss-120b".features] -tools = true -vision = false -reasoning = true -prompt_cache = true - -[providers.fireworks.models."gpt-oss-120b".costs] -input_cost_per_mtok = 0.15 -output_cost_per_mtok = 0.6 -cache_input_cost_per_mtok = 0.015 - -[providers.fireworks.models."gpt-oss-20b"] -api_id = "accounts/fireworks/models/gpt-oss-20b" -display_name = "GPT-OSS 20B" -family = "gpt-oss" -small_default = true -probe = true - -[providers.fireworks.models."gpt-oss-20b".limits] -context_window = 131072 -max_output = 32768 - -[providers.fireworks.models."gpt-oss-20b".features] -tools = true -vision = false -reasoning = true -prompt_cache = true - -[providers.fireworks.models."gpt-oss-20b".costs] -input_cost_per_mtok = 0.07 -output_cost_per_mtok = 0.3 -cache_input_cost_per_mtok = 0.035 diff --git a/lib/foundation/fabro-model/src/catalog/providers/gemini.toml b/lib/foundation/fabro-model/src/catalog/providers/gemini.toml deleted file mode 100644 index a03c2a249..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/gemini.toml +++ /dev/null @@ -1,127 +0,0 @@ -[providers.gemini] -display_name = "Gemini" -adapter = "gemini" -api_key_url = "https://aistudio.google.com/apikey" -base_url = "https://generativelanguage.googleapis.com/v1beta" -priority = 80 - -[providers.gemini.auth] -credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"] -header = { custom = "x-goog-api-key" } - -[providers.gemini.models."gemini-3.1-pro-preview"] -display_name = "Gemini 3.1 Pro (Preview)" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 85 -aliases = ["gemini-pro"] - -[providers.gemini.models."gemini-3.1-pro-preview".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.1-pro-preview".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[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" -knowledge_cutoff = "January 2025" -estimated_output_tps = 85 -aliases = ["gemini-customtools"] - -[providers.gemini.models."gemini-3.1-pro-preview-customtools".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.1-pro-preview-customtools".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.gemini.models."gemini-3.5-flash"] -display_name = "Gemini 3.5 Flash" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -default = true -estimated_output_tps = 150 -aliases = ["gemini-35-flash"] - -[providers.gemini.models."gemini-3.5-flash".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.5-flash".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.gemini.models."gemini-3-flash-preview"] -display_name = "Gemini 3 Flash (Preview)" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 150 -aliases = ["gemini-flash"] - -[providers.gemini.models."gemini-3-flash-preview".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3-flash-preview".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.gemini.models."gemini-3.1-flash-lite"] -display_name = "Gemini 3.1 Flash Lite" -family = "gemini-3" -training = "2025-01-01" -knowledge_cutoff = "January 2025" -estimated_output_tps = 200 -aliases = ["gemini-flash-lite", "gemini-3.1-flash-lite-preview"] -small_default = true - -[providers.gemini.models."gemini-3.1-flash-lite".limits] -context_window = 1048576 -max_output = 65536 - -[providers.gemini.models."gemini-3.1-flash-lite".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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/foundation/fabro-model/src/catalog/providers/inception.toml b/lib/foundation/fabro-model/src/catalog/providers/inception.toml deleted file mode 100644 index 965120f27..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/inception.toml +++ /dev/null @@ -1,30 +0,0 @@ -[providers.inception] -display_name = "Inception" -adapter = "openai_compatible" -api_key_url = "https://console.inceptionlabs.ai/api-keys" -base_url = "https://api.inceptionlabs.ai/v1" -priority = 40 - -[providers.inception.auth] -credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"] - -[providers.inception.models."mercury-2"] -display_name = "Mercury 2" -family = "mercury" -default = true -estimated_output_tps = 1000 -aliases = ["mercury"] - -[providers.inception.models."mercury-2".limits] -context_window = 131072 -max_output = 50000 - -[providers.inception.models."mercury-2".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" - -[providers.inception.models."mercury-2".costs] -input_cost_per_mtok = 0.25 -output_cost_per_mtok = 0.75 diff --git a/lib/foundation/fabro-model/src/catalog/providers/litellm.toml b/lib/foundation/fabro-model/src/catalog/providers/litellm.toml deleted file mode 100644 index 1307378c1..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/litellm.toml +++ /dev/null @@ -1,30 +0,0 @@ -[providers.litellm] -display_name = "LiteLLM" -adapter = "openai_compatible" -base_url = "http://localhost:4000/v1" -priority = 50 -enabled = false - -[providers.litellm.auth] -credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"] - -# To enable LiteLLM, add entries like these to settings.toml: -# -# [llm.providers.litellm] -# enabled = true -# base_url = "http://localhost:4000/v1" -# -# [llm.providers.litellm.models."litellm-gpt-5"] -# api_id = "gpt-5" -# display_name = "LiteLLM GPT-5" -# family = "litellm" -# default = true -# -# [llm.providers.litellm.models."litellm-gpt-5".limits] -# context_window = 128000 -# max_output = 8192 -# -# [llm.providers.litellm.models."litellm-gpt-5".features] -# tools = true -# vision = false -# reasoning = false diff --git a/lib/foundation/fabro-model/src/catalog/providers/minimax.toml b/lib/foundation/fabro-model/src/catalog/providers/minimax.toml deleted file mode 100644 index e68dfc290..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/minimax.toml +++ /dev/null @@ -1,29 +0,0 @@ -[providers.minimax] -display_name = "MiniMax" -adapter = "openai_compatible" -api_key_url = "https://platform.minimaxi.com/user-center/basic-information/interface-key" -base_url = "https://api.minimax.io/v1" -priority = 50 - -[providers.minimax.auth] -credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"] - -[providers.minimax.models."minimax-m2.5"] -display_name = "Minimax M2.5" -family = "minimax-m2" -default = true -estimated_output_tps = 45 -aliases = ["minimax"] - -[providers.minimax.models."minimax-m2.5".limits] -context_window = 196608 -max_output = 16384 - -[providers.minimax.models."minimax-m2.5".features] -tools = true -vision = false -reasoning = false - -[providers.minimax.models."minimax-m2.5".costs] -input_cost_per_mtok = 0.3 -output_cost_per_mtok = 1.2 diff --git a/lib/foundation/fabro-model/src/catalog/providers/modal.toml b/lib/foundation/fabro-model/src/catalog/providers/modal.toml deleted file mode 100644 index d00e40bd1..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/modal.toml +++ /dev/null @@ -1,53 +0,0 @@ -[providers.modal] -display_name = "Modal" -adapter = "openai_compatible" -agent_profile = "kimi" -api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens" -priority = 75 -enabled = false - -[providers.modal.extra_headers] -"Modal-Key" = "{{ secrets.MODAL_TOKEN_ID }}" -"Modal-Secret" = "{{ secrets.MODAL_TOKEN_SECRET }}" - -# Modal assigns an endpoint URL when the Shared API or an Auto Endpoint is -# created. To enable Modal, add the endpoint URL to ~/.fabro/settings.toml: -# -# [llm.providers.modal] -# enabled = true -# base_url = "https://.modal.run/v1" -# -# Then store both proxy-token values in the Fabro server vault: -# -# fabro secret set MODAL_TOKEN_ID wk-... -# fabro secret set MODAL_TOKEN_SECRET ws-... - -# Modal serves the Hugging Face repository id, so `api_id` keeps that -# capitalization. OpenRouter routes the same model under its own lowercase -# slug (`moonshotai/kimi-k3`). -[providers.modal.models."kimi-k3"] -api_id = "moonshotai/Kimi-K3" -display_name = "Kimi K3 (via Modal)" -family = "kimi-k3" -default = true -estimated_output_tps = 460 - -[providers.modal.models."kimi-k3".limits] -context_window = 1048576 -max_output = 131072 - -[providers.modal.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.modal.models."kimi-k3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.modal.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/foundation/fabro-model/src/catalog/providers/moonshot.toml b/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml deleted file mode 100644 index aaa4d7ec4..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml +++ /dev/null @@ -1,67 +0,0 @@ -[providers.moonshot] -display_name = "Moonshot AI" -adapter = "openai_compatible" -agent_profile = "kimi" -api_key_url = "https://platform.kimi.ai/console/api-keys" -base_url = "https://api.moonshot.ai/v1" -priority = 70 - -[providers.moonshot.auth] -credentials = [ - "env:MOONSHOT_API_KEY", - "env:KIMI_API_KEY", - "vault:MOONSHOT_API_KEY", - "vault:KIMI_API_KEY", -] - -[providers.moonshot.models."kimi-k2.5"] -display_name = "Kimi K2.5" -family = "kimi-k2" -training = "2025-10-01" -knowledge_cutoff = "October 2025" -estimated_output_tps = 50 - -[providers.moonshot.models."kimi-k2.5".limits] -context_window = 262144 -max_output = 32768 - -[providers.moonshot.models."kimi-k2.5".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.moonshot.models."kimi-k2.5".costs] -input_cost_per_mtok = 0.6 -output_cost_per_mtok = 3.0 -cache_input_cost_per_mtok = 0.1 - -[providers.moonshot.models."kimi-k3"] -display_name = "Kimi K3" -family = "kimi-k3" -default = true -aliases = ["kimi"] - -[providers.moonshot.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 - -[providers.moonshot.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.moonshot.models."kimi-k3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.moonshot.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/foundation/fabro-model/src/catalog/providers/ollama.toml b/lib/foundation/fabro-model/src/catalog/providers/ollama.toml deleted file mode 100644 index 78dc5db69..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/ollama.toml +++ /dev/null @@ -1,25 +0,0 @@ -[providers.ollama] -display_name = "Ollama" -adapter = "openai_compatible" -billing_policy = "none" -base_url = "http://localhost:11434/v1" -priority = 30 -enabled = false - -# Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true` -# above) to expose it through the OpenAI-compatible adapter. -# -# [providers.ollama.models."qwen3.5"] -# api_id = "qwen3.5:latest" -# display_name = "Qwen3.5" -# family = "qwen3.5" -# default = true -# aliases = ["ollama-qwen3.5"] -# -# [providers.ollama.models."qwen3.5".limits] -# context_window = 32768 -# -# [providers.ollama.models."qwen3.5".features] -# tools = true -# vision = false -# reasoning = false diff --git a/lib/foundation/fabro-model/src/catalog/providers/openai.toml b/lib/foundation/fabro-model/src/catalog/providers/openai.toml deleted file mode 100644 index a95919357..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/openai.toml +++ /dev/null @@ -1,201 +0,0 @@ -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -api_key_url = "https://platform.openai.com/api-keys" -base_url = "https://api.openai.com/v1" -priority = 90 - -[providers.openai.auth] -credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY", "vault:OPENAI_CODEX"] - -[providers.openai.models."gpt-5.6-sol"] -display_name = "GPT-5.6 Sol" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -default = true -aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] -# Codex drives the 5.6 models with a much narrower tool set than the other -# OpenAI models. Set per model so the rest of this provider keeps "openai". -agent_profile = "gpt56" - -[providers.openai.models."gpt-5.6-sol".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.6-sol".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[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 - -[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 = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"] -agent_profile = "gpt56" - -[providers.openai.models."gpt-5.6-terra".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.6-terra".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[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 - -[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 = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"] -agent_profile = "gpt56" - -[providers.openai.models."gpt-5.6-luna".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.6-luna".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[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 - -[providers.openai.models."gpt-5.4"] -display_name = "GPT-5.4" -family = "gpt-5" -training = "2025-08-31" -knowledge_cutoff = "April 2025" -estimated_output_tps = 70 -aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] - -[providers.openai.models."gpt-5.4".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.4".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.openai.models."gpt-5.5"] -display_name = "GPT-5.5" -family = "gpt-5" -training = "2025-12-01" -knowledge_cutoff = "December 2025" -estimated_output_tps = 70 -aliases = ["gpt55", "gpt-55"] - -[providers.openai.models."gpt-5.5".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.openai.models."gpt-5.5-pro"] -display_name = "GPT-5.5 Pro" -family = "gpt-5" -training = "2025-12-01" -knowledge_cutoff = "December 2025" -estimated_output_tps = 20 -aliases = ["gpt55-pro", "gpt-55-pro"] - -[providers.openai.models."gpt-5.5-pro".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openai.models."gpt-5.5-pro".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.openai.models."gpt-5.4-pro"] -display_name = "GPT-5.4 Pro" -family = "gpt-5" -training = "2025-08-31" -knowledge_cutoff = "April 2025" -estimated_output_tps = 20 -aliases = ["gpt54-pro", "gpt-54-pro"] - -[providers.openai.models."gpt-5.4-pro".limits] -context_window = 1047576 -max_output = 128000 - -[providers.openai.models."gpt-5.4-pro".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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 - -[providers.openai.models."gpt-5.4-mini"] -display_name = "GPT-5.4 Mini" -family = "gpt-5" -training = "2025-08-31" -knowledge_cutoff = "April 2025" -estimated_output_tps = 140 -aliases = ["gpt54-mini", "gpt-54-mini", "gpt-5.3-codex-spark", "codex-spark"] -probe = true -small_default = true - -[providers.openai.models."gpt-5.4-mini".limits] -context_window = 272000 -max_output = 128000 - -[providers.openai.models."gpt-5.4-mini".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" - -[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/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml deleted file mode 100644 index 427d178b4..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ /dev/null @@ -1,699 +0,0 @@ -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -api_key_url = "https://openrouter.ai/keys" -base_url = "https://openrouter.ai/api/v1" -priority = 25 -enabled = false - -[providers.openrouter.auth] -credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"] - -# Attribution headers (HTTP-Referer, X-Title) are NOT sent by default. -# Self-hosted Fabro installations stay anonymous on OpenRouter's public -# leaderboard unless the operator opts in. To advertise, add to -# settings.toml: -# -# [llm.providers.openrouter.extra_headers] -# "HTTP-Referer" = "https://your-site.example" -# "X-Title" = "Your App" -# -# To enable OpenRouter, add the following to ~/.fabro/settings.toml: -# -# [llm.providers.openrouter] -# enabled = true -# -# Then run `fabro provider login openrouter` to store the API key, -# or set the OPENROUTER_API_KEY environment variable. - -# ---------- Anthropic via OpenRouter ---------- -# -# Claude models bill Anthropic-style (cache read/write pricing), so these -# rows override the provider's OpenAI-default billing_policy. Costs are -# best-effort estimates; OpenRouter returns the authoritative usage.cost -# in-band on every response. - -[providers.openrouter.models."claude-fable-5"] -api_id = "anthropic/claude-fable-5" -display_name = "Claude Fable 5 (via OpenRouter)" -family = "claude-5" -billing_policy = "anthropic" -aliases = ["fable", "claude-fable"] -agent_profile = "claude-5" - -[providers.openrouter.models."claude-fable-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-fable-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -[providers.openrouter.models."claude-fable-5".costs] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 50.0 -cache_input_cost_per_mtok = 1.0 - -[providers.openrouter.models."claude-opus-5"] -api_id = "anthropic/claude-opus-5" -display_name = "Claude Opus 5 (via OpenRouter)" -family = "claude-5" -billing_policy = "anthropic" -training = "2026-05-01" -knowledge_cutoff = "May 2026" -aliases = ["opus", "claude-opus"] -agent_profile = "claude-5" - -[providers.openrouter.models."claude-opus-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-opus-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -[providers.openrouter.models."claude-opus-5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 25.0 -cache_input_cost_per_mtok = 0.5 - -[providers.openrouter.models."claude-sonnet-5"] -api_id = "anthropic/claude-sonnet-5" -display_name = "Claude Sonnet 5 (via OpenRouter)" -family = "claude-5" -billing_policy = "anthropic" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -default = true -aliases = ["sonnet", "claude-sonnet"] -agent_profile = "claude-5" - -[providers.openrouter.models."claude-sonnet-5".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-sonnet-5".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -# Current introductory rate. OpenRouter's authoritative in-band usage.cost -# supersedes this estimate on completed responses. -[providers.openrouter.models."claude-sonnet-5".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 10.0 -cache_input_cost_per_mtok = 0.2 - -[providers.openrouter.models."claude-opus-4-8"] -api_id = "anthropic/claude-opus-4.8" -display_name = "Claude Opus 4.8 (via OpenRouter)" -family = "claude-4" -billing_policy = "anthropic" -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" - -[providers.openrouter.models."claude-opus-4-8".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-opus-4-8".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -cache_control_breakpoints = true -sampling_params = false - -[providers.openrouter.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 - -[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" - -[providers.openrouter.models."claude-opus-4-7".limits] -context_window = 1000000 -max_output = 128000 - -[providers.openrouter.models."claude-opus-4-7".features] -tools = true -vision = true -reasoning = true -prompt_cache = true -cache_control_breakpoints = true - -[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 - -[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" - -[providers.openrouter.models."claude-sonnet-4-6".limits] -context_window = 1000000 -max_output = 64000 - -[providers.openrouter.models."claude-sonnet-4-6".features] -tools = true -vision = true -reasoning = true -prompt_cache = true -cache_control_breakpoints = true - -[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 - -[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 -aliases = ["haiku", "claude-haiku"] - -[providers.openrouter.models."claude-haiku-4-5".limits] -context_window = 200000 -max_output = 8192 - -[providers.openrouter.models."claude-haiku-4-5".features] -tools = true -vision = true -reasoning = false -prompt_cache = true -cache_control_breakpoints = true - -[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 ---------- - -# GPT-5.6 entries use OpenRouter's base rates below the 272k-token -# long-context threshold. Authoritative in-band usage.cost covers -# long-context and cache-write pricing. - -[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" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] -# Same narrow Codex tool set as on the openai provider; the gateway does not -# change what the model was trained against. -agent_profile = "gpt56" - -[providers.openrouter.models."gpt-5.6-sol".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openrouter.models."gpt-5.6-sol".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.openrouter.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 - -[providers.openrouter.models."gpt-5.6-terra"] -api_id = "openai/gpt-5.6-terra" -display_name = "GPT-5.6 Terra (via OpenRouter)" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"] -agent_profile = "gpt56" - -[providers.openrouter.models."gpt-5.6-terra".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openrouter.models."gpt-5.6-terra".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.openrouter.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 - -[providers.openrouter.models."gpt-5.6-luna"] -api_id = "openai/gpt-5.6-luna" -display_name = "GPT-5.6 Luna (via OpenRouter)" -family = "gpt-5" -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -aliases = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"] -agent_profile = "gpt56" - -[providers.openrouter.models."gpt-5.6-luna".limits] -context_window = 1050000 -max_output = 128000 - -[providers.openrouter.models."gpt-5.6-luna".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true -sampling_params = false - -[providers.openrouter.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 - -[providers.openrouter.models."gpt-5.4"] -api_id = "openai/gpt-5.4" -display_name = "GPT-5.4 (via OpenRouter)" -family = "gpt-5" -aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] - -[providers.openrouter.models."gpt-5.4".limits] -context_window = 1050000 -max_output = 32768 - -[providers.openrouter.models."gpt-5.4".features] -tools = true -vision = true -reasoning = true - -[providers.openrouter.models."gpt-5.4".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 15.0 - -[providers.openrouter.models."gpt-5.5"] -api_id = "openai/gpt-5.5" -display_name = "GPT-5.5 (via OpenRouter)" -family = "gpt-5" -aliases = ["gpt55", "gpt-55"] - -[providers.openrouter.models."gpt-5.5".limits] -context_window = 1050000 -max_output = 32768 - -[providers.openrouter.models."gpt-5.5".features] -tools = true -vision = true -reasoning = true - -[providers.openrouter.models."gpt-5.5".costs] -input_cost_per_mtok = 5.0 -output_cost_per_mtok = 30.0 - -# ---------- Google Gemini via 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" - -[providers.openrouter.models."gemini-3.1-pro-preview".limits] -context_window = 1048576 -max_output = 65536 - -[providers.openrouter.models."gemini-3.1-pro-preview".features] -tools = true -vision = true -reasoning = true - -[providers.openrouter.models."gemini-3.1-pro-preview".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 12.0 - -[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" - -[providers.openrouter.models."gemini-3.5-flash".limits] -context_window = 1048576 -max_output = 65536 - -[providers.openrouter.models."gemini-3.5-flash".features] -tools = true -vision = true -reasoning = false - -[providers.openrouter.models."gemini-3.5-flash".costs] -input_cost_per_mtok = 1.5 -output_cost_per_mtok = 9.0 - -# ---------- Open-weights models ---------- - -[providers.openrouter.models."mimo-v2.5-pro"] -api_id = "xiaomi/mimo-v2.5-pro" -display_name = "Xiaomi MiMo v2.5 Pro" -family = "mimo-v2" - -[providers.openrouter.models."mimo-v2.5-pro".limits] -context_window = 1050000 -max_output = 16384 - -[providers.openrouter.models."mimo-v2.5-pro".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."mimo-v2.5-pro".costs] -input_cost_per_mtok = 0.435 -output_cost_per_mtok = 0.87 - -[providers.openrouter.models."minimax-m2.7"] -api_id = "minimax/minimax-m2.7" -display_name = "MiniMax M2.7" -family = "minimax-m2" - -[providers.openrouter.models."minimax-m2.7".limits] -context_window = 200000 -max_output = 16384 - -[providers.openrouter.models."minimax-m2.7".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."minimax-m2.7".costs] -input_cost_per_mtok = 0.28 -output_cost_per_mtok = 1.20 - -[providers.openrouter.models."deepseek-v4-pro"] -api_id = "deepseek/deepseek-v4-pro" -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" - -[providers.openrouter.models."deepseek-v4-pro".limits] -context_window = 1048576 -max_output = 384000 - -[providers.openrouter.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."deepseek-v4-pro".controls] -# OpenRouter names DeepSeek's max tier xhigh on this route. -reasoning_effort = ["high", "xhigh"] - -[providers.openrouter.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 0.435 -output_cost_per_mtok = 0.87 -cache_input_cost_per_mtok = 0.003625 - -[providers.openrouter.models."deepseek-v4-flash"] -api_id = "deepseek/deepseek-v4-flash-0731" -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] -agent_profile = "openai" - -[providers.openrouter.models."deepseek-v4-flash".limits] -context_window = 1048576 -max_output = 384000 - -[providers.openrouter.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."deepseek-v4-flash".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.openrouter.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.14 -output_cost_per_mtok = 0.28 -cache_input_cost_per_mtok = 0.0028 - -[providers.openrouter.models."kimi-k2.6"] -api_id = "moonshotai/kimi-k2.6" -display_name = "Kimi K2.6" -family = "kimi-k2" -# Kimi models get the Kimi agent profile wherever they are served from, so a -# gateway route behaves like the direct Moonshot one. -agent_profile = "kimi" - -[providers.openrouter.models."kimi-k2.6".limits] -context_window = 262144 -max_output = 16384 - -[providers.openrouter.models."kimi-k2.6".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."kimi-k2.6".costs] -input_cost_per_mtok = 0.73 -output_cost_per_mtok = 3.49 - -[providers.openrouter.models."kimi-k3"] -api_id = "moonshotai/kimi-k3" -display_name = "Kimi K3 (via OpenRouter)" -family = "kimi-k3" -agent_profile = "kimi" - -[providers.openrouter.models."kimi-k3".limits] -context_window = 1048576 -max_output = 131072 - -[providers.openrouter.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "always_adaptive" -prompt_cache = true -sampling_params = false - -[providers.openrouter.models."kimi-k3".controls] -reasoning_effort = ["low", "high", "max"] - -[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 - -[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" - -[providers.openrouter.models."laguna-s-2.1".limits] -context_window = 1048576 -max_output = 131072 - -[providers.openrouter.models."laguna-s-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -[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 - -[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" - -[providers.openrouter.models."laguna-xs-2.1".limits] -context_window = 262144 -max_output = 32768 - -[providers.openrouter.models."laguna-xs-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -# Current promotional rate. OpenRouter's authoritative in-band usage.cost -# supersedes this estimate on completed responses. -[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 - -[providers.openrouter.models."qwen3-coder"] -api_id = "qwen/qwen3-coder" -display_name = "Qwen3 Coder" -family = "qwen3" - -[providers.openrouter.models."qwen3-coder".limits] -context_window = 1050000 -max_output = 16384 - -[providers.openrouter.models."qwen3-coder".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."qwen3-coder".costs] -input_cost_per_mtok = 0.22 -output_cost_per_mtok = 1.80 - -[providers.openrouter.models."qwen3.6-flash"] -api_id = "qwen/qwen3.6-flash" -display_name = "Qwen3.6 Flash" -family = "qwen3" - -[providers.openrouter.models."qwen3.6-flash".limits] -context_window = 1000000 -max_output = 16384 - -[providers.openrouter.models."qwen3.6-flash".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."qwen3.6-flash".costs] -input_cost_per_mtok = 0.1875 -output_cost_per_mtok = 1.125 - -[providers.openrouter.models."qwen3.8-max"] -api_id = "qwen/qwen3.8-max" -display_name = "Qwen3.8 Max" -family = "qwen3" - -[providers.openrouter.models."qwen3.8-max".limits] -context_window = 1000000 -max_output = 131072 - -[providers.openrouter.models."qwen3.8-max".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openrouter.models."qwen3.8-max".controls] -reasoning_effort = ["low", "medium", "high", "xhigh"] - -[providers.openrouter.models."qwen3.8-max".costs] -input_cost_per_mtok = 2.0 -output_cost_per_mtok = 6.0 -cache_input_cost_per_mtok = 0.25 - -[providers.openrouter.models."glm-5.2"] -api_id = "z-ai/glm-5.2" -display_name = "GLM 5.2 (via OpenRouter)" -family = "glm-5" -aliases = ["glm", "glm5", "glm52", "glm5.2"] - -[providers.openrouter.models."glm-5.2".limits] -context_window = 1048576 -max_output = 131072 - -[providers.openrouter.models."glm-5.2".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.openrouter.models."glm-5.2".controls] -reasoning_effort = ["high", "xhigh"] - -[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 - -[providers.openrouter.models."glm-4.6"] -api_id = "z-ai/glm-4.6" -display_name = "GLM 4.6" -family = "glm-4" - -[providers.openrouter.models."glm-4.6".limits] -context_window = 203000 -max_output = 16384 - -[providers.openrouter.models."glm-4.6".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."glm-4.6".costs] -input_cost_per_mtok = 0.43 -output_cost_per_mtok = 1.74 - -[providers.openrouter.models."nemotron-3-super-120b-a12b"] -api_id = "nvidia/nemotron-3-super-120b-a12b" -display_name = "NVIDIA Nemotron 3 Super 120B" -family = "nemotron-3" - -[providers.openrouter.models."nemotron-3-super-120b-a12b".limits] -context_window = 1000000 -max_output = 16384 - -[providers.openrouter.models."nemotron-3-super-120b-a12b".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."nemotron-3-super-120b-a12b".costs] -input_cost_per_mtok = 0.09 -output_cost_per_mtok = 0.45 - -[providers.openrouter.models."devstral-2512"] -api_id = "mistralai/devstral-2512" -display_name = "Devstral 2512" -family = "devstral" - -[providers.openrouter.models."devstral-2512".limits] -context_window = 262144 -max_output = 16384 - -[providers.openrouter.models."devstral-2512".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."devstral-2512".costs] -input_cost_per_mtok = 0.40 -output_cost_per_mtok = 2.00 diff --git a/lib/foundation/fabro-model/src/catalog/providers/poolside.toml b/lib/foundation/fabro-model/src/catalog/providers/poolside.toml deleted file mode 100644 index 65ce87246..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/poolside.toml +++ /dev/null @@ -1,61 +0,0 @@ -[providers.poolside] -display_name = "Poolside" -adapter = "openai_compatible" -api_key_url = "https://platform.poolside.ai" -base_url = "https://inference.poolside.ai/v1" -priority = 65 - -[providers.poolside.auth] -credentials = ["env:POOLSIDE_API_KEY", "vault:POOLSIDE_API_KEY"] - -[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"] - -[providers.poolside.models."laguna-s-2.1".limits] -context_window = 1048576 -max_output = 131072 - -[providers.poolside.models."laguna-s-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -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. -[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 - -[providers.poolside.models."laguna-xs-2.1"] -api_id = "poolside/laguna-xs-2.1" -display_name = "Laguna XS 2.1" -family = "laguna-2" -small_default = true -probe = true -aliases = ["laguna-xs"] - -[providers.poolside.models."laguna-xs-2.1".limits] -context_window = 262144 -max_output = 32768 - -[providers.poolside.models."laguna-xs-2.1".features] -tools = true -vision = false -reasoning = true -prompt_cache = true -sampling_params = true - -# Poolside Platform is free for a limited preview period. These are Poolside's -# published paid endpoint rates. -[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/foundation/fabro-model/src/catalog/providers/venice.toml b/lib/foundation/fabro-model/src/catalog/providers/venice.toml deleted file mode 100644 index 9791591e0..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/venice.toml +++ /dev/null @@ -1,212 +0,0 @@ -# Model IDs, capabilities, contexts, and prices are from Venice's published -# model catalog, verified 2026-08-21: -# https://github.com/veniceai/api-docs/blob/59a300b1d036c0c0acc0e5f75c0ab0dd07c40c1c/data/static-models.json - -[providers.venice] -display_name = "Venice" -adapter = "openai_compatible" -base_url = "https://api.venice.ai/api/v1" -priority = 35 -aliases = ["venice-ai"] -billing_policy = "openai" - -[providers.venice.auth] -credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"] - -[providers.venice.models."kimi-k3"] -display_name = "Kimi K3" -family = "kimi-k3" -agent_profile = "kimi" -aliases = ["kimi"] - -[providers.venice.models."kimi-k3".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."kimi-k3".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."kimi-k3".costs] -input_cost_per_mtok = 3.75 -output_cost_per_mtok = 18.75 -cache_input_cost_per_mtok = 0.375 - -[providers.venice.models."kimi-k3-fast"] -api_id = "kimi-k3-fast-api" -display_name = "Kimi K3 Fast" -family = "kimi-k3" -agent_profile = "kimi" -aliases = ["kimi-fast"] - -[providers.venice.models."kimi-k3-fast".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."kimi-k3-fast".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."kimi-k3-fast".costs] -input_cost_per_mtok = 4.5 -output_cost_per_mtok = 22.5 -cache_input_cost_per_mtok = 0.45 - -[providers.venice.models."grok-4.6"] -api_id = "grok-4-6" -display_name = "Grok 4.6" -family = "grok-4" -aliases = ["grok", "grok46", "grok-46"] - -[providers.venice.models."grok-4.6".limits] -context_window = 500000 -max_output = 32000 - -[providers.venice.models."grok-4.6".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true - -[providers.venice.models."grok-4.6".controls] -reasoning_effort = ["low", "medium", "high", "xhigh"] - -[providers.venice.models."grok-4.6".costs] -input_cost_per_mtok = 2.27 -output_cost_per_mtok = 6.8 -cache_input_cost_per_mtok = 0.57 - -[providers.venice.models."glm-5.3"] -api_id = "z-ai-glm-5-3" -display_name = "GLM 5.3" -family = "glm-5" -aliases = ["glm", "glm5", "glm53", "glm5.3", "glm-5-3"] - -[providers.venice.models."glm-5.3".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."glm-5.3".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true - -[providers.venice.models."glm-5.3".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.venice.models."glm-5.3".costs] -input_cost_per_mtok = 1.75 -output_cost_per_mtok = 5.5 -cache_input_cost_per_mtok = 0.325 - -[providers.venice.models."deepseek-v4-flash"] -api_id = "deepseek-v4-flash-0731" -display_name = "DeepSeek V4 Flash" -family = "deepseek-v4" -agent_profile = "openai" -default = true -aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] - -[providers.venice.models."deepseek-v4-flash".limits] -context_window = 1000000 -max_output = 32768 - -[providers.venice.models."deepseek-v4-flash".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."deepseek-v4-flash".controls] -reasoning_effort = ["low", "high", "max"] - -[providers.venice.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.175 -output_cost_per_mtok = 0.35 -cache_input_cost_per_mtok = 0.035 - -[providers.venice.models."deepseek-v4-pro"] -api_id = "deepseek-v4-pro-0813" -display_name = "DeepSeek V4 Pro" -family = "deepseek-v4" -agent_profile = "openai" -aliases = ["deepseek-pro"] - -[providers.venice.models."deepseek-v4-pro".limits] -context_window = 1000000 -max_output = 32768 - -[providers.venice.models."deepseek-v4-pro".features] -tools = true -vision = false -reasoning = true -reasoning_by_default = true -prompt_cache = true -sampling_params = false - -[providers.venice.models."deepseek-v4-pro".costs] -input_cost_per_mtok = 1.65 -output_cost_per_mtok = 4.95 -cache_input_cost_per_mtok = 0.165 - -[providers.venice.models."qwen3.8-max"] -api_id = "qwen-3-8-max" -display_name = "Qwen 3.8 Max" -family = "qwen3" -aliases = ["qwen", "qwen-max", "qwen3.8", "qwen-3.8", "qwen38", "qwen-3.8-max", "qwen38-max"] - -[providers.venice.models."qwen3.8-max".limits] -context_window = 1000000 -max_output = 131072 - -[providers.venice.models."qwen3.8-max".features] -tools = true -vision = true -reasoning = true -reasoning_by_default = true -prompt_cache = true - -[providers.venice.models."qwen3.8-max".costs] -input_cost_per_mtok = 2.5 -output_cost_per_mtok = 7.5 -cache_input_cost_per_mtok = 0.3125 - -[providers.venice.models."qwen3.8-27b"] -api_id = "qwen-3-8-27b" -display_name = "Qwen 3.8 27B" -family = "qwen3.8" -aliases = ["qwen-27b", "qwen-3.8-27b", "qwen38-27b"] - -[providers.venice.models."qwen3.8-27b".limits] -context_window = 262144 -max_output = 131072 - -[providers.venice.models."qwen3.8-27b".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -reasoning_by_default = true - -[providers.venice.models."qwen3.8-27b".controls] -reasoning_effort = ["low", "medium", "xhigh"] - -[providers.venice.models."qwen3.8-27b".costs] -input_cost_per_mtok = 0.45 -output_cost_per_mtok = 3.2 diff --git a/lib/foundation/fabro-model/src/catalog/providers/zai.toml b/lib/foundation/fabro-model/src/catalog/providers/zai.toml deleted file mode 100644 index 58fb8c38a..000000000 --- a/lib/foundation/fabro-model/src/catalog/providers/zai.toml +++ /dev/null @@ -1,53 +0,0 @@ -[providers.zai] -display_name = "Z.ai" -adapter = "openai_compatible" -api_key_url = "https://open.bigmodel.cn/usercenter/apikeys" -base_url = "https://api.z.ai/api/coding/paas/v4" -priority = 60 - -[providers.zai.auth] -credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"] - -[providers.zai.models."glm-5.2"] -display_name = "GLM 5.2" -family = "glm-5" -default = true -aliases = ["glm", "glm5", "glm52", "glm5.2"] - -[providers.zai.models."glm-5.2".limits] -context_window = 1048576 -max_output = 131072 - -[providers.zai.models."glm-5.2".features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -prompt_cache = true - -[providers.zai.models."glm-5.2".controls] -reasoning_effort = ["high", "max"] - -[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 - -[providers.zai.models."glm-4.7"] -display_name = "GLM 4.7" -family = "glm-4" -estimated_output_tps = 100 -aliases = ["glm4"] - -[providers.zai.models."glm-4.7".limits] -context_window = 202752 -max_output = 16384 - -[providers.zai.models."glm-4.7".features] -tools = true -vision = false -reasoning = false - -[providers.zai.models."glm-4.7".costs] -input_cost_per_mtok = 0.6 -output_cost_per_mtok = 2.2 diff --git a/lib/foundation/fabro-model/src/codec.rs b/lib/foundation/fabro-model/src/codec.rs deleted file mode 100644 index b9667d286..000000000 --- a/lib/foundation/fabro-model/src/codec.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Wire-dialect identity shared by the model catalog and LLM route assembly. -//! -//! A codec names *what the bytes say* — the wire dialect a route speaks — -//! independently of the transport/auth scheme named by -//! [`AdapterKind`](crate::AdapterKind). Catalog rows may select a codec -//! explicitly; rows that omit it inherit the adapter's default, which -//! reproduces the historical adapter→dialect fusion exactly. - -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr, VariantArray}; - -use crate::adapter::AdapterKind; - -/// Stable wire-dialect identity for a route. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, - VariantArray, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum CodecKind { - AnthropicMessages, - #[serde(rename = "openai_responses")] - #[strum(to_string = "openai_responses")] - OpenAiResponses, - /// The conservative Chat Completions dialect. The name matches today's - /// `openai_compatible` adapter string; `openai_chat` stays reserved for a - /// possible future full-proprietary Chat Completions dialect. - #[serde(rename = "openai_compatible")] - #[strum(to_string = "openai_compatible")] - OpenAiCompatible, - GeminiGenerate, - /// Amazon Bedrock's unified Converse/ConverseStream dialect: one - /// model-agnostic envelope AWS translates to each hosted family's - /// native format server-side. - BedrockConverse, -} - -impl CodecKind { - /// The codec each adapter kind drives when a catalog row does not - /// configure `codec` explicitly. These defaults reproduce the historical - /// behavior where the adapter implied the wire dialect. - #[must_use] - pub fn default_for(adapter: AdapterKind) -> Self { - match adapter { - AdapterKind::Anthropic => Self::AnthropicMessages, - AdapterKind::OpenAi => Self::OpenAiResponses, - AdapterKind::Gemini => Self::GeminiGenerate, - AdapterKind::OpenAiCompatible => Self::OpenAiCompatible, - AdapterKind::Bedrock => Self::BedrockConverse, - } - } - - #[must_use] - pub fn as_str(self) -> &'static str { - self.into() - } -} - -impl AsRef for CodecKind { - fn as_ref(&self) -> &str { - (*self).as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn codec_kind_round_trips_as_snake_case() { - for kind in CodecKind::VARIANTS { - let json = serde_json::to_string(kind).unwrap(); - assert_eq!(json, format!("\"{}\"", kind.as_str())); - let parsed: CodecKind = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, *kind); - assert_eq!(kind.as_str().parse::().unwrap(), *kind); - } - } - - #[test] - fn codec_kind_strings_match_route_vocabulary() { - for (kind, expected) in [ - (CodecKind::AnthropicMessages, "anthropic_messages"), - (CodecKind::OpenAiResponses, "openai_responses"), - (CodecKind::OpenAiCompatible, "openai_compatible"), - (CodecKind::GeminiGenerate, "gemini_generate"), - (CodecKind::BedrockConverse, "bedrock_converse"), - ] { - assert_eq!(kind.as_str(), expected); - assert_eq!(kind.to_string(), expected); - } - } - - #[test] - fn adapter_defaults_reproduce_the_historical_fusion() { - for (adapter, expected) in [ - (AdapterKind::Anthropic, CodecKind::AnthropicMessages), - (AdapterKind::OpenAi, CodecKind::OpenAiResponses), - (AdapterKind::Gemini, CodecKind::GeminiGenerate), - (AdapterKind::OpenAiCompatible, CodecKind::OpenAiCompatible), - (AdapterKind::Bedrock, CodecKind::BedrockConverse), - ] { - assert_eq!(CodecKind::default_for(adapter), expected); - } - } -} diff --git a/lib/foundation/fabro-model/src/ids.rs b/lib/foundation/fabro-model/src/ids.rs deleted file mode 100644 index b46d89cfe..000000000 --- a/lib/foundation/fabro-model/src/ids.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! String-backed provider and model identifiers. -//! -//! Provider and model identity are catalog data, not closed enums. These -//! newtypes give catalog/auth/server seams a single, type-safe wrapper while -//! keeping wire format compatible with plain strings. - -use std::fmt; -use std::str::FromStr; - -use serde::{Deserialize, Serialize}; - -/// Stable provider identifier referenced from settings, vault, and request -/// routing. -/// -/// Wraps a `String` because the set of providers is open-ended and supplied -/// by `[llm.providers]` settings rather than compiled into a Rust enum. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ProviderId(String); - -impl ProviderId { - pub const ANTHROPIC: &'static str = "anthropic"; - pub const OPENAI: &'static str = "openai"; - pub const GEMINI: &'static str = "gemini"; - - /// Construct a provider ID from any string-like value without validation. - /// Catalog construction is responsible for canonicalisation; consumers - /// only need a wrapper for type clarity. - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - /// Borrow the inner string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Consume the wrapper and return the inner `String`. - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } - - #[must_use] - pub fn anthropic() -> Self { - Self::new(Self::ANTHROPIC) - } - - #[must_use] - pub fn openai() -> Self { - Self::new(Self::OPENAI) - } - - #[must_use] - pub fn gemini() -> Self { - Self::new(Self::GEMINI) - } - - #[must_use] - pub fn display_name(&self) -> String { - self.0.clone() - } -} - -impl fmt::Display for ProviderId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl fmt::Debug for ProviderId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl From<&str> for ProviderId { - fn from(s: &str) -> Self { - Self(s.to_string()) - } -} - -impl From for ProviderId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromStr for ProviderId { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> Result { - Ok(Self::from(s)) - } -} - -impl AsRef for ProviderId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -/// Stable canonical, human-facing model identifier. -/// -/// Aliases are alternate selectors for a model offering; they are not model -/// IDs. The same `ModelId` may be offered by more than one provider, so a -/// concrete catalog offering is identified by `(ProviderId, ModelId)`. -#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct ModelId(String); - -impl ModelId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } -} - -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 { - self.0.fmt(f) - } -} - -impl From<&str> for ModelId { - fn from(s: &str) -> Self { - Self(s.to_string()) - } -} - -impl From for ModelId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl AsRef for ModelId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl PartialEq for ModelId { - fn eq(&self, other: &str) -> bool { - self.0 == other - } -} - -impl PartialEq<&str> for ModelId { - fn eq(&self, other: &&str) -> bool { - self.0 == *other - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn provider_id_is_transparent_string_in_json() { - let id = ProviderId::new("moonshot"); - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"moonshot\""); - let back: ProviderId = serde_json::from_str(&json).unwrap(); - assert_eq!(back, id); - } - - #[test] - fn model_id_is_transparent_string_in_json() { - let id = ModelId::new("kimi-k2.5"); - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"kimi-k2.5\""); - let back: ModelId = serde_json::from_str(&json).unwrap(); - assert_eq!(back, id); - } - - #[test] - fn display_writes_inner_string() { - assert_eq!(ProviderId::new("anthropic").to_string(), "anthropic"); - assert_eq!( - ModelId::new("claude-opus-4-7").to_string(), - "claude-opus-4-7" - ); - } - - #[test] - fn ord_is_lexicographic() { - let mut v = [ProviderId::new("zai"), ProviderId::new("anthropic")]; - v.sort(); - assert_eq!(v[0].as_str(), "anthropic"); - assert_eq!(v[1].as_str(), "zai"); - } -} diff --git a/lib/foundation/fabro-model/src/lib.rs b/lib/foundation/fabro-model/src/lib.rs deleted file mode 100644 index fc43c95a7..000000000 --- a/lib/foundation/fabro-model/src/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod adapter; -pub mod billing; -pub mod bootstrap_catalog; -pub mod catalog; -pub mod codec; -pub mod ids; -pub mod model_ref; -pub mod model_test; -pub mod provider; -pub mod reasoning; -pub mod types; - -pub use adapter::{AdapterKind, AgentProfileKind}; -pub use billing::{ - AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, CostSource, - GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment, - ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage, - OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, -}; -pub use catalog::{ - ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError, - FallbackTarget, ModelSelectionError, ProviderAuthConfig, SelectedModel, -}; -pub use codec::CodecKind; -pub use ids::{ModelId, ProviderId}; -pub use model_ref::ModelHandle; -pub use model_test::ModelTestMode; -pub use provider::Provider; -pub use reasoning::ReasoningEffort; -pub use types::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature, -}; diff --git a/lib/foundation/fabro-model/src/model_ref.rs b/lib/foundation/fabro-model/src/model_ref.rs deleted file mode 100644 index f8d1f0a8f..000000000 --- a/lib/foundation/fabro-model/src/model_ref.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::fmt; -use std::sync::Arc; - -use crate::ids::ProviderId; -use crate::types::Model; - -/// A reference to a model — either a fully resolved `Model` or a -/// provider + model-name pair that hasn't been looked up yet. -#[derive(Clone)] -pub enum ModelHandle { - /// A model whose metadata has been resolved from the catalog. - Resolved(Arc), - /// An unresolved provider:model pair (e.g. from CLI input or config). - ByName { - provider: ProviderId, - model: String, - }, -} - -impl ModelHandle { - /// The model identifier string (e.g. `"claude-opus-4-6"`). - #[must_use] - pub fn model_id(&self) -> &str { - match self { - Self::Resolved(m) => m.id.as_str(), - Self::ByName { model, .. } => model, - } - } - - /// The provider for this model. - #[must_use] - pub fn provider(&self) -> &ProviderId { - match self { - Self::Resolved(m) => &m.provider, - Self::ByName { provider, .. } => provider, - } - } -} - -impl fmt::Display for ModelHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{}", self.provider(), self.model_id()) - } -} - -impl fmt::Debug for ModelHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id), - Self::ByName { provider, model } => f - .debug_struct("ModelRef::ByName") - .field("provider", provider) - .field("model", model) - .finish(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ProviderId; - use crate::catalog::Catalog; - - #[test] - fn by_name_display() { - let r = ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "claude-opus-4-6".to_string(), - }; - assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); - } - - #[test] - fn by_name_accessors() { - let r = ModelHandle::ByName { - provider: ProviderId::openai(), - model: "gpt-5.4".to_string(), - }; - assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), &ProviderId::openai()); - } - - #[test] - fn resolved_display() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone(); - let r = ModelHandle::Resolved(Arc::new(info)); - assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); - } - - #[test] - fn resolved_accessors() { - let info = Catalog::builtin().get("gpt-5.4").unwrap().clone(); - let r = ModelHandle::Resolved(Arc::new(info)); - assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), &ProviderId::openai()); - } - - #[test] - fn debug_format() { - let r = ModelHandle::ByName { - provider: ProviderId::gemini(), - model: "gemini-3.1-pro-preview".to_string(), - }; - let debug = format!("{r:?}"); - assert!(debug.contains("ByName")); - assert!(debug.contains("gemini")); - } -} diff --git a/lib/foundation/fabro-model/src/model_test.rs b/lib/foundation/fabro-model/src/model_test.rs deleted file mode 100644 index f099bf4c2..000000000 --- a/lib/foundation/fabro-model/src/model_test.rs +++ /dev/null @@ -1,33 +0,0 @@ -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr}; - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Default, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ModelTestMode { - #[default] - Basic, - Deep, -} - -impl ModelTestMode { - #[must_use] - pub const fn timeout_secs(self) -> u64 { - match self { - Self::Basic => 30, - Self::Deep => 90, - } - } -} diff --git a/lib/foundation/fabro-model/src/provider.rs b/lib/foundation/fabro-model/src/provider.rs deleted file mode 100644 index 5d3e227be..000000000 --- a/lib/foundation/fabro-model/src/provider.rs +++ /dev/null @@ -1,95 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::adapter::AdapterKind; -use crate::catalog::CatalogProvider; -use crate::ids::ProviderId; - -/// A user-facing LLM provider from the catalog. -/// -/// The public projection of [`CatalogProvider`]. It deliberately omits -/// internal-only fields (`auth`, `extra_headers`, `billing_policy`, -/// `agent_profile`) so credential material never reaches the wire. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Provider { - pub id: ProviderId, - pub display_name: String, - pub adapter: AdapterKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api_key_url: Option, - pub priority: i32, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub aliases: Vec, - /// Number of catalog models for this provider. Stamped by the handler. - pub model_count: u32, - /// Catalog default model ID for this provider, if any. Stamped by the - /// handler. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_model: Option, - /// True if the server has credential material configured for this provider - /// when the response is produced. Always `false` in static catalog data; - /// stamped by `GET /providers` per request. - #[serde(default)] - pub configured: bool, - /// Suggested vault secret name for configuring this provider, derived - /// from the first vault credential in the catalog. `None` when the - /// provider has no vault credential (e.g. Ollama, env-only providers). - /// Used by the web UI to prefill the create-secret form. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_secret_name: Option, -} - -impl Provider { - #[must_use] - pub fn from_catalog( - provider: &CatalogProvider, - model_count: u32, - default_model: Option, - configured: bool, - ) -> Self { - Self { - id: provider.id.clone(), - display_name: provider.display_name.clone(), - adapter: provider.adapter, - base_url: provider.base_url.clone(), - api_key_url: provider.api_key_url.clone(), - priority: provider.priority, - aliases: provider.aliases.clone(), - model_count, - default_model, - configured, - expected_secret_name: provider.vault_secret_name().map(str::to_owned), - } - } -} - -#[cfg(test)] -mod tests { - use super::Provider; - use crate::catalog::Catalog; - use crate::ids::ProviderId; - - #[test] - fn from_catalog_provider_copies_static_fields_and_supplied_runtime_fields() { - let catalog = Catalog::builtin(); - let anthropic = catalog - .provider(&ProviderId::anthropic()) - .expect("builtin catalog must define anthropic"); - - let provider = - Provider::from_catalog(anthropic, 7, Some("claude-opus-4-7".to_string()), true); - - assert_eq!(provider.id, ProviderId::anthropic()); - assert_eq!(provider.display_name, anthropic.display_name); - assert_eq!(provider.adapter, anthropic.adapter); - assert_eq!(provider.priority, anthropic.priority); - assert_eq!(provider.model_count, 7); - assert_eq!(provider.default_model.as_deref(), Some("claude-opus-4-7")); - assert!(provider.configured); - assert_eq!( - provider.expected_secret_name.as_deref(), - Some("ANTHROPIC_API_KEY"), - ); - } -} diff --git a/lib/foundation/fabro-model/src/reasoning.rs b/lib/foundation/fabro-model/src/reasoning.rs deleted file mode 100644 index 56e07ad26..000000000 --- a/lib/foundation/fabro-model/src/reasoning.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Shared reasoning-effort enum. -//! -//! `ReasoningEffort` is a Rust-owned vocabulary type. Catalog data, request -//! validation, OpenAPI replacement types, and the LLM client all share one -//! enum so that adding a new effort value remains a Rust change. - -use std::cmp::Reverse; - -use serde::{Deserialize, Serialize}; - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - PartialOrd, - Ord, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, - strum::VariantArray, -)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningEffort { - Low, - Medium, - High, - XHigh, - Max, -} - -impl ReasoningEffort { - #[must_use] - pub fn variants() -> &'static [Self] { - ::VARIANTS - } - - /// Select the supported effort nearest to this value. - /// - /// The enum declaration defines the ordered progression. When two values - /// are equally distant, the higher effort wins. - #[must_use] - pub fn closest_supported(self, supported: &[Self]) -> Option { - supported - .iter() - .copied() - .min_by_key(|effort| ((self as u8).abs_diff(*effort as u8), Reverse(*effort))) - } -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use strum::VariantArray; - - use super::*; - - #[test] - fn parses_canonical_lowercase_strings() { - assert_eq!( - ReasoningEffort::from_str("low").unwrap(), - ReasoningEffort::Low - ); - assert_eq!( - ReasoningEffort::from_str("medium").unwrap(), - ReasoningEffort::Medium - ); - assert_eq!( - ReasoningEffort::from_str("high").unwrap(), - ReasoningEffort::High - ); - assert_eq!( - ReasoningEffort::from_str("xhigh").unwrap(), - ReasoningEffort::XHigh - ); - assert_eq!( - ReasoningEffort::from_str("max").unwrap(), - ReasoningEffort::Max - ); - } - - #[test] - fn rejects_unknown_strings() { - assert!(ReasoningEffort::from_str("none").is_err()); - assert!(ReasoningEffort::from_str("").is_err()); - assert!(ReasoningEffort::from_str("HIGH").is_err()); - } - - #[test] - fn display_matches_serde_lowercase() { - assert_eq!(ReasoningEffort::XHigh.to_string(), "xhigh"); - assert_eq!(<&'static str>::from(ReasoningEffort::Max), "max"); - } - - #[test] - fn variants_in_ordered_progression() { - let v = ReasoningEffort::VARIANTS; - assert_eq!(v[0], ReasoningEffort::Low); - assert_eq!(v[v.len() - 1], ReasoningEffort::Max); - } - - #[test] - fn closest_supported_uses_exact_match() { - assert_eq!( - ReasoningEffort::High.closest_supported(&[ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]), - Some(ReasoningEffort::High) - ); - } - - #[test] - fn closest_supported_rounds_equal_distance_up() { - let kimi = [ - ReasoningEffort::Low, - ReasoningEffort::High, - ReasoningEffort::Max, - ]; - assert_eq!( - ReasoningEffort::Medium.closest_supported(&kimi), - Some(ReasoningEffort::High) - ); - assert_eq!( - ReasoningEffort::XHigh.closest_supported(&kimi), - Some(ReasoningEffort::Max) - ); - } - - #[test] - fn closest_supported_uses_nearest_lower_value_when_needed() { - assert_eq!( - ReasoningEffort::Max - .closest_supported(&[ReasoningEffort::High, ReasoningEffort::XHigh]), - Some(ReasoningEffort::XHigh) - ); - } - - #[test] - fn closest_supported_returns_none_for_unsupported_control() { - assert_eq!(ReasoningEffort::High.closest_supported(&[]), None); - } - - #[test] - fn round_trip_through_json() { - let json = serde_json::to_string(&ReasoningEffort::High).unwrap(); - assert_eq!(json, "\"high\""); - let parsed: ReasoningEffort = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, ReasoningEffort::High); - } -} diff --git a/lib/foundation/fabro-model/src/types.rs b/lib/foundation/fabro-model/src/types.rs deleted file mode 100644 index 50ae020dc..000000000 --- a/lib/foundation/fabro-model/src/types.rs +++ /dev/null @@ -1,287 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::ids::{ModelId, ProviderId}; -use crate::reasoning::ReasoningEffort; - -// --- 2.9 Model --- - -#[derive( - Debug, - Clone, - Copy, - Default, - PartialEq, - Eq, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, - strum::IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum ReasoningEffortFeature { - Levels, - /// Effort levels are supported, and thinking is natively always-on - /// adaptive at the endpoint; a manual thinking on/off toggle is not - /// accepted. - AlwaysAdaptive, - #[default] - None, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelLimits { - pub context_window: i64, - pub max_output: Option, -} - -fn default_true() -> bool { - true -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelFeatures { - pub tools: bool, - pub vision: bool, - pub reasoning: bool, - /// Whether this model endpoint supports a native reasoning-effort - /// parameter. User-facing allowed effort values live in catalog controls. - #[serde(default)] - pub reasoning_effort: ReasoningEffortFeature, - /// Whether this model endpoint supports prompt caching annotations. - #[serde(default)] - pub prompt_cache: bool, - /// Whether the endpoint only caches when the request marks the cacheable - /// prefix with Anthropic-style `cache_control` breakpoints. Set on - /// OpenAI-compatible routes fronting Anthropic models (e.g. Claude via - /// OpenRouter); dialects whose caching mechanism is implied (native - /// Anthropic, Bedrock) ignore it. - #[serde(default)] - pub cache_control_breakpoints: bool, - /// Whether the model endpoint accepts classic sampling parameters - /// (`temperature`, `top_p`). Models with always-on adaptive behavior - /// reject them. - #[serde(default = "default_true")] - pub sampling_params: bool, -} - -impl ModelFeatures { - /// Whether the model endpoint accepts a native reasoning-effort level. - #[must_use] - pub fn supports_reasoning_effort(&self) -> bool { - matches!( - self.reasoning_effort, - ReasoningEffortFeature::Levels | ReasoningEffortFeature::AlwaysAdaptive - ) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelCosts { - pub input_cost_per_mtok: Option, - pub output_cost_per_mtok: Option, - pub cache_input_cost_per_mtok: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct ModelControls { - /// Exact reasoning-effort values accepted by this provider/model offering. - /// An empty list means the request control is unsupported. - #[serde(default)] - pub reasoning_effort: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Model { - pub id: ModelId, - pub provider: ProviderId, - pub family: String, - pub display_name: String, - pub limits: ModelLimits, - pub training: Option, - pub knowledge_cutoff: Option, - pub features: ModelFeatures, - /// Required in API responses; defaulted on deserialization so newer - /// clients tolerate older servers that predate this field. - #[serde(default)] - pub controls: ModelControls, - pub costs: ModelCosts, - pub estimated_output_tps: Option, - pub aliases: Vec, - #[serde(default)] - pub default: bool, - #[serde(default)] - pub small_default: bool, - /// Whether the server has any credential configured for this model's - /// provider at the time of the response. Always `false` in static catalog - /// data; populated by `GET /models` per request. - #[serde(default)] - pub configured: bool, -} - -impl Model { - pub fn id(&self) -> &str { - self.id.as_str() - } - - pub fn provider(&self) -> &ProviderId { - &self.provider - } - - pub fn family(&self) -> &str { - &self.family - } - - pub fn display_name(&self) -> &str { - &self.display_name - } - - pub fn context_window(&self) -> i64 { - self.limits.context_window - } - - pub fn max_output(&self) -> Option { - self.limits.max_output - } - - pub fn supports_tools(&self) -> bool { - self.features.tools - } - - pub fn supports_vision(&self) -> bool { - self.features.vision - } - - pub fn supports_reasoning(&self) -> bool { - self.features.reasoning - } - - pub fn supports_reasoning_effort(&self) -> bool { - self.features.supports_reasoning_effort() - } - - pub fn supports_prompt_cache(&self) -> bool { - self.features.prompt_cache - } - - pub fn supports_sampling_params(&self) -> bool { - self.features.sampling_params - } - - pub fn training(&self) -> Option<&str> { - self.training.as_deref() - } - - pub fn knowledge_cutoff(&self) -> Option<&str> { - self.knowledge_cutoff.as_deref() - } - - pub fn input_cost_per_mtok(&self) -> Option { - self.costs.input_cost_per_mtok - } - - pub fn output_cost_per_mtok(&self) -> Option { - self.costs.output_cost_per_mtok - } - - pub fn cache_input_cost_per_mtok(&self) -> Option { - self.costs.cache_input_cost_per_mtok - } - - pub fn estimated_output_tps(&self) -> Option { - self.estimated_output_tps - } - - pub fn aliases(&self) -> &[String] { - &self.aliases - } - - pub fn is_default(&self) -> bool { - self.default - } - - pub fn is_small_default(&self) -> bool { - self.small_default - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ids::ProviderId; - - #[test] - fn reasoning_effort_feature_always_adaptive_round_trips() { - let parsed: ReasoningEffortFeature = - serde_json::from_value(serde_json::json!("always_adaptive")).unwrap(); - assert_eq!(parsed, ReasoningEffortFeature::AlwaysAdaptive); - assert_eq!( - serde_json::to_value(parsed).unwrap(), - serde_json::json!("always_adaptive") - ); - assert_eq!(parsed.to_string(), "always_adaptive"); - assert_eq!( - "always_adaptive".parse::().unwrap(), - parsed - ); - } - - #[test] - fn inherent_methods_return_correct_values() { - let info = Model { - id: ModelId::new("model-id"), - provider: ProviderId::new("provider-id"), - family: "family".to_string(), - display_name: "Display Name".to_string(), - limits: ModelLimits { - context_window: 123_456, - max_output: Some(7_890), - }, - training: Some("training".to_string()), - knowledge_cutoff: Some("knowledge-cutoff".to_string()), - features: ModelFeatures { - tools: true, - vision: true, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }, - controls: ModelControls::default(), - costs: ModelCosts { - input_cost_per_mtok: Some(1.0), - output_cost_per_mtok: Some(2.0), - cache_input_cost_per_mtok: Some(0.1), - }, - estimated_output_tps: Some(42.0), - aliases: vec!["alias".to_string()], - default: true, - small_default: true, - configured: false, - }; - - assert_eq!(info.id(), "model-id"); - assert_eq!(info.provider(), &ProviderId::new("provider-id")); - assert_eq!(info.family(), "family"); - assert_eq!(info.display_name(), "Display Name"); - assert_eq!(info.context_window(), 123_456); - assert_eq!(info.max_output(), Some(7_890)); - assert!(info.supports_tools()); - assert!(info.supports_vision()); - assert!(info.supports_reasoning()); - assert!(info.supports_reasoning_effort()); - assert!(info.supports_prompt_cache()); - assert!(info.supports_sampling_params()); - assert_eq!(info.training(), Some("training")); - assert_eq!(info.knowledge_cutoff(), Some("knowledge-cutoff")); - assert_eq!(info.input_cost_per_mtok(), Some(1.0)); - assert_eq!(info.output_cost_per_mtok(), Some(2.0)); - assert_eq!(info.cache_input_cost_per_mtok(), Some(0.1)); - assert_eq!(info.estimated_output_tps(), Some(42.0)); - assert_eq!(info.aliases(), &["alias".to_string()]); - assert!(info.is_default()); - assert!(info.is_small_default()); - } -}