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