docs(llm): finish configurable provider cleanup (#260)
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run

## Summary

Finish phase 9 of the configurable LLM provider/model work by aligning
public docs, release notes, and guardrails with the implementation
already landed in phases 0-8.

- documents settings-driven providers/models, OpenAI-compatible gateway
examples, typed `extra_headers`, model `api_id`, controls, and per-speed
costs
- adds the 2026-05-13 changelog entry and provider string migration note
- updates the internal phase plan ledger to reflect current
implementation status
- adds a workspace policy test blocking direct production
`Catalog::builtin()` usage outside catalog owner/test code
- clarifies `Provider` as a built-in compatibility enum while open-ended
identity is `ProviderId`

## Verification

- `cargo nextest run -p fabro-dev --features dev --test it policy`
- `cargo dev docs check`
- `cargo nextest run -p fabro-model -p fabro-config -p fabro-auth -p
fabro-llm`
- `cargo build --workspace`
- `cargo nextest run --workspace` (5717 passed, 182 skipped, nextest
reported 1 leaky test)
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- `git diff --check`
This commit is contained in:
Bryan Helmkamp 2026-05-13 14:17:54 -07:00 committed by GitHub
parent a81eb09e78
commit 1b6189ee32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 990 additions and 660 deletions

View file

@ -6,11 +6,10 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
- `fabro_auth::CredentialSource` is the credential authority.
- Long-lived runtime contexts store `Arc<dyn CredentialSource>` and `Arc<Catalog>`, not `Client`.
- Call `fabro_llm::client::Client::from_source_with_catalog(&source, catalog).await?` at the point of use when runtime catalog settings are available.
- `Client::from_source(&source).await?` is the built-in-catalog fallback for setup, tests, and standalone contexts that do not have resolved runtime catalog settings.
- Call `fabro_llm::client::Client::from_source(&source, catalog).await?` at the point of use.
- Standalone setup and tests that use default settings build a default `Arc<Catalog>` locally, then pass it explicitly.
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve_for_catalog(catalog)` directly and consume both `credentials` and `auth_issues`.
- Use `source.resolve()` only in built-in-catalog fallback contexts.
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve(catalog)` directly and consume both `credentials` and `auth_issues`.
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
@ -26,11 +25,11 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
- Workflow state lives on `RunServices.llm_source` and `RunServices.catalog`.
- Server state lives on `AppState.llm_source` and `AppState.catalog()`.
- Hooks and other long-lived executors receive a source plus catalog and derive clients when they actually generate.
- One-shot CLI commands may resolve a source locally, then derive a client once for that operation. Use the built-in-catalog path only when those commands do not load runtime catalog settings.
- One-shot CLI commands may resolve a source locally, build a default settings catalog if they do not load runtime catalog settings, then derive a client once for that operation.
## Enforcement
- Do not add new `Client::from_env`-style shortcuts in production paths.
- Do not cache a long-lived `Client` where OAuth refresh or storage-dir rebinding matters.
- Do not route runtime request-serving paths through `Client::from_source` or `CredentialSource::resolve()` if they have an `Arc<Catalog>`.
- Do not construct LLM clients or resolve credentials without an explicit `Arc<Catalog>` or `&Catalog`.
- Mirror [server-secrets-strategy.md](server-secrets-strategy.md): production credential resolution should be explicit about where secrets come from and how they flow into subprocesses.

View file

@ -0,0 +1,44 @@
---
title: "Configurable LLM providers"
date: "2026-05-13"
---
## Configurable LLM providers and models
Fabro can now merge LLM provider and model catalog entries from settings. Teams can add OpenAI-compatible gateways, route through provider proxies, attach typed extra headers, map a Fabro model ID to a provider-specific `api_id`, and declare model controls and per-speed pricing without waiting for a new built-in catalog entry.
```toml
[llm.providers.proxy]
adapter = "openai_compatible"
base_url = "https://llm-gateway.example.com/v1"
credentials = ["env:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
[llm.models."team-code-large"]
provider = "proxy"
api_id = "provider-wire-model-name"
default = true
```
## Migration note
Provider values exposed by configuration, model routing, and the API are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, and custom IDs such as `proxy` now work wherever the selected catalog defines them.
Clients that generated closed provider enums from older API specs should regenerate against the current OpenAPI schema and treat model `provider` fields as strings.
## More
<Accordion title="LLM catalog">
- Added settings-reference coverage for `[llm.providers.<id>]`, provider `extra_headers`, `[llm.models.<id>]`, model limits, features, controls, base costs, and per-speed cost overrides
- Documented OpenAI-compatible provider configuration and gateway header examples
- Documented `api_id` for provider wire-model names
- Documented run-level model controls for reasoning effort and speed
</Accordion>
<Accordion title="Guardrails">
- Added a workspace policy test preventing direct production `Catalog::builtin()` usage outside the catalog owner and tests
- Clarified that `fabro_model::Provider` is a built-in compatibility enum, while open-ended provider identity is string-backed `ProviderId`
</Accordion>

View file

@ -39,9 +39,68 @@ No single model is best at everything. Fabro lets you assign the right model to
Each provider requires its own API key set via environment variable (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`). See the [Quick Start](/getting-started/quick-start) for setup.
## Configuring providers and models
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
base_url = "https://llm-gateway.example.com/v1"
credentials = ["env:ACME_GATEWAY_API_KEY"]
aliases = ["gateway"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
[llm.models."team-code-large"]
provider = "proxy"
api_id = "provider-wire-model-name"
display_name = "Team Code Large"
family = "team-code"
default = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.models."team-code-large".features]
tools = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
effort = true
[llm.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.
Header values and credentials are typed references, not raw secrets. Use `env:<NAME>` or `credential:<id>` for provider credentials, and `{ env = "NAME" }`, `{ credential = "id" }`, or `{ literal = "value" }` for extra headers.
<Note>
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.
</Note>
## Default models
When no model or provider is specified, Fabro auto-detects the provider by checking which API keys are configured, using precedence order Anthropic > OpenAI > Gemini. If no keys are found, it falls back to Anthropic. Each provider has a default model:
When no model or provider is specified, Fabro checks configured provider credentials and chooses the first configured provider by catalog priority. If no provider credentials are configured, it uses the catalog's global default model. Each provider has a default model:
| Provider | Default model |
|---|---|

View file

@ -250,6 +250,7 @@
"group": "May 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-05-13",
"changelog/2026-05-11",
"changelog/2026-05-10",
"changelog/2026-05-09",

View file

@ -47,6 +47,9 @@ working_dir = "/tmp/workdir"
name = "claude-sonnet-4-5"
fallbacks = ["openai", "gemini"]
[run.model.controls]
reasoning_effort = "high"
[[run.prepare.steps]]
script = "git clone https://github.com/fabro-sh/fabro repo"
@ -127,6 +130,27 @@ name = "claude-sonnet-4-5"
| `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. |
| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. |
Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.<id>]`.
#### `[run.model.controls]`
Set default model controls for all nodes that do not override them in the workflow stylesheet:
```toml title="run.toml"
[run.model]
provider = "proxy"
name = "team-code"
[run.model.controls]
reasoning_effort = "high"
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"`. |
| `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. |
#### Fallbacks with splice
Use the reserved `"..."` marker in `fallbacks` to splice in the inherited list from lower-precedence layers:

View file

@ -19,6 +19,7 @@ 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" }
tokio = { version = "1", features = ["full"] }
```
@ -30,13 +31,16 @@ use fabro_agent::{
};
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;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?.as_ref().clone();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let sandbox = Arc::new(LocalSandbox::new(PathBuf::from(".")));
let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-5"));
let config = SessionOptions::default();
@ -304,23 +308,28 @@ You can use it independently of Fabro's workflow engine — add it as a dependen
[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" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
```
### Quick start
The simplest path is an environment-backed `CredentialSource`, then `Client::from_source(&source)`. That keeps credential resolution explicit while still auto-reading environment variables such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY`.
The simplest path is an environment-backed `CredentialSource`, an explicit `Arc<Catalog>`, 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`.
```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;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?;
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
@ -342,9 +351,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use std::sync::Arc;
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?;
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
```
For env-backed usage, `EnvCredentialSource` checks for API key environment variables and registers adapters for each provider found:
@ -407,7 +420,8 @@ use fabro_llm::client::Client;
use fabro_llm::generate::{generate, GenerateParams};
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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.")
@ -428,7 +442,8 @@ use fabro_llm::client::Client;
use fabro_llm::types::Message;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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![
@ -523,7 +538,8 @@ let weather = Tool::active(
# use fabro_auth::EnvCredentialSource;
# use fabro_llm::client::Client;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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?")
@ -551,7 +567,8 @@ use fabro_llm::types::ToolChoice;
# use fabro_auth::EnvCredentialSource;
# use fabro_llm::client::Client;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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
@ -574,7 +591,8 @@ Passive tools let you handle execution yourself:
# use fabro_auth::EnvCredentialSource;
# use fabro_llm::client::Client;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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",
@ -612,7 +630,8 @@ use fabro_llm::generate::{stream, GenerateParams};
use futures::StreamExt;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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")
@ -636,7 +655,8 @@ use fabro_llm::types::StreamEvent;
use futures::StreamExt;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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")
@ -689,7 +709,8 @@ use fabro_llm::generate::{generate_object, GenerateParams};
use serde_json::json;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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": {
@ -755,13 +776,13 @@ Add middleware to the client:
```rust
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use std::sync::Arc;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
let source = EnvCredentialSource::new();
let mut client = Client::from_source(&source).await?;
Arc::get_mut(&mut client)
.expect("install middleware before sharing the client")
.add_middleware(Arc::new(LoggingMiddleware));
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
@ -873,7 +894,8 @@ use fabro_llm::client::Client;
use tokio_util::sync::CancellationToken;
# let source = EnvCredentialSource::new();
# let client = Client::from_source(&source).await?;
# 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();
@ -936,4 +958,4 @@ Register it on the client:
```rust
client.register_provider(Arc::new(MyProvider)).await?;
```
```

View file

@ -29,12 +29,13 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
## Who reads what
`settings.toml` uses the same schema as `.fabro/project.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`.
`settings.toml` uses the same schema as `.fabro/project.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[llm]`, `[cli]`, `[server]`, and `[features]`.
| Scope | Examples |
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Shared run defaults | `[run.model]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, `[llm.models.<id>]`, model limits, features, controls, and costs |
| 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.
@ -83,6 +84,36 @@ check = true
[cli.logging]
level = "info"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
base_url = "https://llm-gateway.example.com/v1"
credentials = ["env:ACME_GATEWAY_API_KEY"]
aliases = ["gateway"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
[llm.models."team-code-large"]
provider = "proxy"
api_id = "provider-wire-model-name"
display_name = "Team Code Large"
default = true
aliases = ["team-code"]
[llm.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
[llm.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
[run.model]
name = "claude-sonnet-4-5"
@ -133,6 +164,134 @@ 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.<id>]`
Define or override an LLM provider. Provider IDs are strings, so custom
providers can be added when they use an adapter Fabro already supports.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
base_url = "https://llm-gateway.example.com/v1"
credentials = ["env:ACME_GATEWAY_API_KEY"]
priority = 50
enabled = true
aliases = ["gateway"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
x-team-secret = { credential = "gateway_team_secret" }
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `display_name` | string | provider ID | Human-readable provider name. |
| `adapter` | string | inferred for built-ins | Adapter registry key, such as `"anthropic"`, `"openai"`, `"gemini"`, or `"openai_compatible"`. Custom providers normally use `"openai_compatible"`. |
| `base_url` | string | adapter default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `credentials` | array<string> | built-in env refs | Ordered credential refs. Accepted string forms are `credential:<id>` and `env:<NAME>`. Literal secret strings are rejected. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
## `[llm.models.<id>]`
Define or override a model in the catalog. The table key is the canonical
model ID Fabro users reference; `api_id` is the model string sent to the
provider API.
```toml title="settings.toml"
[llm.models."team-code-large"]
provider = "proxy"
api_id = "provider-wire-model-name"
display_name = "Team Code Large"
family = "team-code"
default = true
enabled = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.models."team-code-large".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
effort = true
[llm.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `provider` | string | None | Provider ID this model belongs to. |
| `api_id` | string | model ID | Identifier sent to the provider API. |
| `display_name` | string | model ID | Human-readable model name. |
| `family` | string | model ID | Family label used for catalog display and matching. |
| `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. |
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional model names accepted by routing and fallback config. |
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
## `[llm.models.<id>.limits]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `context_window` | integer | None | Maximum context window size in tokens. |
| `max_output` | integer | None | Maximum output tokens, if known. |
## `[llm.models.<id>.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_effort` | `"levels"` \| `"none"` | `"none"` | How Fabro may expose reasoning effort for this model. |
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `effort` | boolean | `false` | Whether the provider exposes a native effort parameter. |
## `[llm.models.<id>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | adapter defaults | User-facing reasoning effort values Fabro may send for this model. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.models.<id>.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.models.<id>.costs.speed.<speed>]`
Per-speed cost overrides use the same keys as `[llm.models.<id>.costs]`.
Each `<speed>` key must be declared in `[llm.models.<id>.controls].speed`.
The `standard` speed is implicit and always uses the base cost table.
## `[cli.updates]`
`[cli.updates]` — upgrade check toggle

View file

@ -1,247 +1,52 @@
# Settings-Driven LLM Providers And Models Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
# Settings-Driven LLM Providers And Models
**Goal:** Implement a settings-driven LLM provider/model catalog so new providers and models can be configured through TOML when they use an existing adapter.
**Architecture:** Treat provider and model identity as layered settings data. Keep adapters, agent profiles, auth schemes, billing policy shapes, and request control kinds as explicit Rust behavior. Build a resolved `Arc<Catalog>` from settings and pass that catalog through server, workflow, CLI, auth, and LLM client seams.
**Tech Stack:** Rust, serde/TOML settings layers, chrono `NaiveDate`, strum enums for code-owned control values, OpenAPI/progenitor, TypeScript API client generation, cargo nextest.
**Tech stack:** Rust, serde/TOML settings layers, strum enums for code-owned control values, OpenAPI/progenitor, TypeScript API client generation, cargo nextest.
---
## Implementation Status (2026-05-13)
## Implementation status (2026-05-04 session)
Phases 0-8 have landed on `main` across PRs #207, #244, #245, #247, and #249.
The foundation slice of this plan is implemented and shipped on the run branch:
- **Settings and catalog:** `[llm.providers.<id>]` and `[llm.models.<id>]` are trusted, layered settings. Built-in catalog data is merged through the same settings path as overrides, and catalog construction validates provider adapters, aliases, enablement, priority, model controls, and per-speed cost rows.
- **Provider identity:** public catalog/API provider fields use string-backed `ProviderId`. The closed `Provider` enum remains intentionally for built-in compatibility paths such as install/auth strategy selection, env-var mappings, adapter defaults, and tests.
- **Runtime plumbing:** server, workflow, CLI, hooks, auth, validation, and LLM client paths can use settings-resolved `Arc<Catalog>` values. `fabro_model::bootstrap_catalog` is the explicit bootstrap hatch for install/API-key validation and legacy no-catalog compatibility wrappers.
- **Controls and billing:** `ReasoningEffort` and `Speed` are typed, `[run.model.controls]` flows through resolved run settings, and catalog cost data supports per-speed overrides.
- **Phase 9:** docs, release notes, status cleanup, and policy tests close the plan by preventing regressions to direct production bootstrap/default catalog usage.
- **`fabro-model`**: new `adapter` module with the full vocabulary (`AdapterMetadata`, `AgentProfileKind`, `ApiKeyHeaderPolicy`, `AdapterControlCapabilities`) + four registered adapters (`anthropic`, `openai`, `gemini`, `openai_compatible`); `ProviderId` and `ModelId` newtypes; shared `ReasoningEffort` enum; `Speed` enum gains `strum::VariantArray`.
- **`fabro-llm`**: `adapter_registry` module mirroring `fabro_model::adapter` with infallible factories; tests enforce that every metadata key has a factory and vice-versa.
- **`fabro-config`**: new `[llm]` settings layer (`LlmLayer` + `ProviderSettings` + `ModelSettings` + `ModelControls` + `ModelCostTable` + `CostRates` + typed `CredentialRef`); legacy `[llm] provider = ...` migration error preserved while `[llm.providers]` and `[llm.models]` subtrees are accepted; whole-array replacement and field-merge semantics covered by tests.
- **`fabro-config` + `fabro-types`**: new `[run.model.controls]` block flowing through to `RunModelSettings.controls`.
- **`fabro-dev`**: workspace-policy test that scans every Rust source under `lib/` for non-comment `bootstrap_catalog` references and fails outside an explicit allowlist.
- All checked-in code passes `cargo build --workspace`, `cargo nextest run` for the affected crates (1,912+ tests), `cargo +nightly-2026-04-14 fmt --check --all`, and `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.
The remainder of the plan — replacing `fabro_model::Provider` with `ProviderId` across 80+ files, regenerating the OpenAPI clients, swapping the auth resolver to use `ProviderId`, replacing the 25 `Catalog::builtin()` production call sites with a settings-resolved `Arc<Catalog>` injected through server/workflow/CLI state, the `bootstrap_catalog` install hatch, the typed `Request.speed`/`GenerateParams.speed` swap, and the per-speed billing rows — is **deferred to follow-up sessions**. Each deferred step is marked individually below.
## Phase 1 gateway header update (2026-05-12 session)
Phase 1 gateway header work is motivated by @haroldolivieri's Portkey/Bedrock report on PR #207:
https://github.com/fabro-sh/fabro/pull/207#issuecomment-4377929769
This follow-up adds provider-level `extra_headers` with typed `literal`, `env`, and `credential` values, whole-map replacement semantics across settings layers, and adapter-registry pass-through coverage. It remains schema/seam work only; runtime credential resolution and settings-defined provider registration stay deferred to the resolved catalog/client phases.
---
## Summary
This is a breaking cross-crate refactor. `fabro_model::Provider` stops being the product identity type; provider identity becomes a string-backed `ProviderId`. OpenAPI provider fields become strings, and the resolved settings catalog becomes the source of truth for model lookup, provider lookup, default selection, credential resolution, adapter registration, and `/models`.
All settings layers are trusted execution configuration, including project TOML and workflow/run TOML. That trust model allows repository-provided settings to define or override provider routing. It does not make every credential interchangeable: Codex OAuth remains locked to the fixed ChatGPT Codex backend because it is a long-lived account-scoped credential, not a normal API key for arbitrary `base_url` routing.
Built-in providers and models ship as default settings data. User, server, project, and workflow/run settings merge on top of those defaults using the existing settings-layer model.
Venice-specific built-in support is intentionally out of scope for phase 9. Custom OpenAI-compatible providers are documented through generic settings examples instead.
## Key Interface Decisions
- Add trusted, mergeable `[llm]` settings. Provider `adapter` is a registry key implemented in Rust; new providers can use existing adapter keys without code changes, while new adapters still require Rust.
- Provider and model identity are string-backed catalog data. Built-in provider names such as `anthropic`, `openai`, and `gemini` remain valid, and custom provider IDs such as `proxy` are valid wherever the resolved catalog defines them.
- Provider `adapter` is a Rust-owned registry key. New providers can use existing adapters, especially `openai_compatible`, without code changes. New adapter behavior remains a Rust change.
- `api_id` is the model identifier sent to the provider API. When omitted, the catalog model ID is used as the wire model ID.
- `features.reasoning`, `features.effort`, and `controls.reasoning_effort` are separate: model capability, native effort support, and user-facing allowed values.
- `Speed::Standard` is implicit and must not appear in `controls.speed`. `controls.speed` lists additional native speed values such as `fast`; per-speed cost overrides must reference declared speeds.
- Credential refs are typed. Provider credentials use `credential:<id>` or `env:<NAME>`. Provider `extra_headers` values use `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. Literal secret strings in credential lists are rejected.
- Codex OAuth stays pinned to canonical `openai` + `openai_codex` + fixed ChatGPT Codex base URL. It is not a generic API key for arbitrary `base_url` routing.
```toml
[llm.providers.kimi]
display_name = "Kimi"
adapter = "openai_compatible"
base_url = "https://api.moonshot.ai/v1"
credentials = ["credential:kimi", "env:KIMI_API_KEY"]
priority = 60
enabled = true
aliases = ["moonshot"]
## Implemented Phase Ledger
[llm.models."kimi-k2.5"]
provider = "kimi"
api_id = "kimi-k2.5"
display_name = "Kimi K2.5"
family = "kimi"
knowledge_cutoff = 2025-01-01
default = true
enabled = true
aliases = ["kimi"]
estimated_output_tps = 50
- [x] **Phase 0:** groundwork and explicit bootstrap/default catalog policy.
- [x] **Phase 1:** settings schema, merge behavior, and provider `extra_headers`.
- [x] **Phases 2-4:** resolved catalog construction, OpenAPI provider string API, auth resolver changes, settings-defined provider registration, and catalog-aware validation.
- [x] **Phase 5:** server/workflow/CLI/hooks plumbing for settings-resolved catalogs.
- [x] **Phases 6-8:** typed controls, speed-aware request flow, per-speed billing, and built-in catalog cost/control data.
- [x] **Phase 9:** public docs, generated settings reference, changelog/migration note, and workspace policy tests for direct production `Catalog::builtin()` and `bootstrap_catalog` usage.
[llm.models."kimi-k2.5".limits]
context_window = 262144
max_output = 32768
## Verification
[llm.models."kimi-k2.5".features]
tools = true
vision = false
reasoning = true
effort = false
Phase 9 verification should include:
[llm.models."kimi-k2.5".costs]
input_cost_per_mtok = 0.60
output_cost_per_mtok = 2.50
cache_input_cost_per_mtok = 0.15
```
- `cargo nextest run -p fabro-dev --features dev --test it policy`
- `cargo dev docs check`
- `cargo nextest run -p fabro-model -p fabro-config -p fabro-auth -p fabro-llm`
- `cargo build --workspace`
- `cargo nextest run --workspace`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
- `api_id` is the model identifier sent to the provider API; when omitted, it defaults to the catalog model ID.
- `features.reasoning`, `features.effort`, and `controls.reasoning_effort` are separate. `features.reasoning` records whether the model has reasoning behavior at all and is used for catalog capability display plus fallback/model matching. `features.effort` records whether the model supports the provider's native effort parameter. `controls.reasoning_effort` is the user-facing allow-list for native effort values Fabro may accept for that model.
- Do not add a provider-level `profile` field in v1. The agent profile is inferred from the adapter registry entry, for example `anthropic -> anthropic`, `openai -> openai`, `gemini -> gemini`, and `openai_compatible -> openai`. New profile behavior is a Rust change.
- Do not add provider-level `cli_backend` in v1. Existing graph/workflow `cli_backend` behavior remains separate from provider catalog data. `codex_mode` remains credential-derived and is not configurable through provider settings.
- Add fixed, typed model controls. Supported control kinds and enum values are Rust-owned. Current v1 controls are `reasoning_effort = ["low", "medium", "high", "xhigh", "max"]` and non-default `speed = ["fast"]`. A model only declares values allowed by its adapter metadata; v1 does not expose non-native reasoning-effort fallback strategies as catalog data.
```toml
[llm.models."claude-opus-4-6".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."claude-opus-4-6".costs.speed.fast]
input_cost_per_mtok = 90.0
output_cost_per_mtok = 450.0
cache_input_cost_per_mtok = 9.0
```
- `Speed::Standard` is always available and is not listed in `controls.speed`. `controls.speed` enumerates additional speeds only, so `costs.speed.standard` is not a valid override.
- `controls.speed` and `costs.speed` have one invariant: every `costs.speed.<speed>` key must be declared in `controls.speed`. A declared non-standard speed without a price override is allowed and uses base costs. An override whose speed is not declared is a catalog build error. Built-in Anthropic fast-mode models must declare both `controls.speed = ["fast"]` and explicit `costs.speed.fast` rows so the current fast multiplier becomes data.
- Omitted control lists are not wildcards. If `controls.reasoning_effort` is omitted and `features.effort = true`, it resolves to the adapter's native reasoning-effort defaults. If `features.effort = false`, it resolves to an empty list. If `controls.speed` is omitted, it resolves to an empty list of additional speeds.
- Add `[run.model.controls]` for run defaults. Node and style values still win over run defaults.
```toml
[run.model.controls]
reasoning_effort = "high"
speed = "fast"
```
- Credential entries are a typed `CredentialRef` enum. Accepted forms are only `credential:<id>` and `env:<NAME>`; literal secret strings fail deserialization or validation and are never represented as a successful settings value.
- `credential:<id>` reads structured credentials from the existing `fabro-vault` crate. API-key credentials must match the provider ID they are attached to. `env:<NAME>` reads the process environment first, then falls back to an existing raw `fabro-vault` secret with the same name.
- `credential:openai_codex` is special. It is only valid for canonical provider ID `openai`, maps to vault ID `openai_codex`, sets `codex_mode = true`, and always uses `https://chatgpt.com/backend-api/codex`. It ignores `[llm.providers.openai].base_url` and cannot be used by aliases or custom providers.
- OpenAPI changes are breaking: provider schemas become `type: string`, `Model.provider` becomes a provider ID string, `Model.controls` is added, and `knowledge_cutoff` becomes `format: date`.
## Implementation Plan
- [x] **Settings schema and merge behavior** — landed in commit `feat(config): add [llm] settings layer for provider/model catalog`.
- [x] Add `LlmSettings`, `ProviderSettings`, `ModelSettings`, `ModelControls`, `ModelCostTable`, `CostRates`, and `CredentialRef` to `fabro-config`. (Names: `LlmLayer`, `ProviderSettings`, `ModelSettings`, `ModelControls`, `ModelCostTable`, `CostRates`, `CredentialRef`.)
- [ ] Store built-in providers and models in defaults settings data so production catalog construction starts from the same layered settings path as user/project/workflow overrides. **Deferred** — depends on the catalog-resolution step below; the schema is in place to receive defaults.
- [x] Preserve sparse field-merge semantics for `[llm.providers.<id>]` and `[llm.models.<id>]`. Arrays such as `credentials`, `aliases`, `controls.reasoning_effort`, and `controls.speed` replace as whole arrays. (Backed by `MergeMap<V>` per-key field-merge; arrays are `Option<Vec<...>>` with `or` combine semantics.)
- [x] Keep the targeted legacy `[llm]` migration error for old keys such as `provider` or `model`; accept only the new `[llm.providers]` and `[llm.models]` subtrees. (`LEGACY_LLM_KEYS` matched in `parse_settings` before the strict deserialize.)
- [x] Parse adapter keys as strings in `fabro-config`. Do not make `fabro-config` depend on `fabro-llm`. (Adapter is `Option<String>`; resolution happens against `fabro_model::adapter` metadata.)
- [x] Add provider-level `extra_headers` with typed literal/env/credential values.
- [x] Make `extra_headers` replace as a whole map across settings layers.
- [x] Keep gateway headers as schema/seam work only; runtime credential resolution and provider registration remain deferred to the resolved catalog/client phases.
- [~] **Catalog model** — partially landed. Remaining items are **deferred** because they require breaking changes across 80+ files and the OpenAPI regeneration step.
- [x] Add `ProviderId` and `ModelId` string newtypes where they improve type clarity across crates. (`fabro_model::ids`.)
- [ ] Replace product identity uses of `fabro_model::Provider` with `ProviderId`. **Deferred** — closed `Provider` enum still backs `Model.provider`, vault `ApiCredential.provider`, OpenAPI types, and 80+ call sites; replacement requires the OpenAPI step plus a full sweep.
- [x] Move `ReasoningEffort` to `fabro-model`. (Added at `fabro_model::reasoning::ReasoningEffort`; the existing `fabro_llm::types::ReasoningEffort` stays in place until the LLM seam is fully cut over so the rest of the workspace keeps compiling.)
- [x] Add code-owned adapter metadata beside the catalog, not in `fabro-config`. (`fabro_model::adapter`.)
- Add concrete metadata vocabulary types in the shared model/catalog layer so model validation and LLM factory registration share one contract:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentProfileKind {
Anthropic,
OpenAi,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiKeyHeaderPolicy {
Bearer,
Custom { name: &'static str },
}
pub struct AdapterMetadata {
pub key: &'static str,
pub default_profile: AgentProfileKind,
pub api_key_header: ApiKeyHeaderPolicy,
pub controls: AdapterControlCapabilities,
}
pub struct AdapterControlCapabilities {
pub native_reasoning_effort: &'static [ReasoningEffort],
pub additional_speeds: &'static [Speed],
}
// Implemented in fabro-auth, not fabro-model, to avoid a dependency cycle.
pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHeader {
match policy {
ApiKeyHeaderPolicy::Bearer => ApiKeyHeader::Bearer(key),
ApiKeyHeaderPolicy::Custom { name } => ApiKeyHeader::Custom {
name: name.to_string(),
value: key,
},
}
}
```
- `AgentProfileKind` is an internal dispatch key that `fabro-agent` maps to concrete `AgentProfile` implementations; it is not a settings field. `ApiKeyHeaderPolicy` describes how an API key becomes an `ApiKeyHeader` without carrying secret values.
- `native_reasoning_effort` is every reasoning-effort value that can be sent through the provider's native effort field. After omitted controls are filled from adapter defaults, resolved model `controls.reasoning_effort` must be a non-empty subset of `native_reasoning_effort` when `features.effort = true`; it must be omitted or empty when `features.effort = false`. V1 does not expose generic non-native effort fallback in catalog data.
- Model `controls.speed` must be a subset of adapter `additional_speeds`. `Speed::Standard` is implicit and must not appear in either list.
- [ ] Build `Catalog` from resolved settings and return catalog-build errors for malformed provider/model data. **Deferred** — this is the largest single piece and depends on the `Provider``ProviderId` swap above.
- [ ] Validate provider `adapter` strings against the adapter metadata while building the catalog. `fabro-llm` has the matching factory registry and tests must prove every metadata key has a factory. **Adapter registry parity test landed**; catalog-side validation deferred with the resolved `Catalog` builder.
- [ ] Build provider and model alias indexes after all layers merge and after disabled entries are filtered out of runtime lookup. **Deferred** with the resolved `Catalog` builder.
- [ ] Surface alias/catalog failures at catalog construction. **Deferred** with the resolved `Catalog` builder.
- [ ] Replace hardcoded provider precedence with provider `priority`. **Deferred** with the resolved `Catalog` builder.
- [ ] Retire `Catalog::builtin()` from production lookup paths. **Deferred** — the symbol still has 25 production call sites today; converting them requires the resolved `Catalog` to be reachable from server/workflow/CLI state.
- [ ] Put the bootstrap/defaults constructor behind an explicit module such as `fabro_model::bootstrap_catalog`. **Deferred** until the resolved catalog landing point exists.
- [x] Add a CI-enforced workspace test that scans for `bootstrap_catalog` references and allows only bootstrap/install/test-support paths. (`fabro-dev/tests/it/policy.rs::bootstrap_catalog_references_stay_in_allowlist`.)
- [ ] **OpenAPI and generated clients****Deferred**, gated on the `Provider``ProviderId` swap.
- [ ] Change provider fields in `docs/public/api-reference/fabro-api.yaml` from the closed `Provider` schema to strings or a shared `ProviderId` newtype.
- [ ] Remove `with_replacement("Provider", "fabro_model::Provider", &[])` from `lib/crates/fabro-api/build.rs`.
- [ ] Delete or replace `lib/crates/fabro-api/tests/provider_round_trip.rs`; add JSON parity coverage for `ProviderId` if that type is reused by `fabro-api`.
- [ ] Regenerate Rust API types with `cargo build -p fabro-api`.
- [ ] Regenerate the TypeScript API client after the OpenAPI change.
- [ ] **Credentials and auth****Deferred**, gated on the `Provider``ProviderId` swap. The `CredentialRef` type and its redaction-safe `Display` impl are landed in `fabro-config`.
- [ ] Change `AuthCredential`, `ApiCredential`, resolver errors, and credential lookup helpers from closed `Provider` to `ProviderId`.
- [ ] Preserve existing vault JSON by deserializing old provider strings as provider IDs.
- [ ] Keep `credential_id_for` compatibility.
- [ ] Resolve provider `credentials` in list order with `env:` and `credential:` semantics from the plan.
- [ ] Keep Codex OAuth pinned to canonical `openai` + `openai_codex` + fixed ChatGPT Codex base URL.
- [ ] Define `fabro auth list` behavior for absent or disabled providers.
- [x] New credential-ref Display/Debug/error paths redact secret values. (`CredentialRef::Display` writes `credential:<id>` / `env:<NAME>` only; the parse-error message never echoes the input string.)
- [~] **LLM client and adapter registry** — adapter factory registry landed; client wiring deferred.
- [x] Introduce an adapter factory registry in `fabro-llm` keyed by the same strings as catalog adapter metadata. (`fabro_llm::adapter_registry`; tests enforce metadata↔factory parity.)
- [x] Keep factory behavior in `fabro-llm`; keep static metadata needed by `fabro-model` and `fabro-auth` in the shared catalog/model layer to avoid dependency cycles. (Metadata in `fabro_model::adapter`; factories in `fabro_llm::adapter_registry`.)
- [ ] Change `Client::from_source` and `Client::from_credentials` call paths so provider settings and the resolved catalog are available before adapter registration. **Deferred** — depends on resolved `Catalog`.
- [ ] Register adapters by provider ID from the resolved catalog. **Deferred**.
- [ ] Keep install/API-key validation working by using the bootstrap/defaults catalog. **Deferred**.
- [ ] **Validation****Deferred**, depends on resolved `Catalog`.
- [~] **Workflow, server, agent, and hooks plumbing**`[run.model.controls]` schema + resolution landed.
- [x] Add `[run.model.controls]` schema with `reasoning_effort` and `speed` fields. (`RunModelControlsLayer` in `fabro-config`; `RunModelControls` in `fabro-types`; resolved through `WorkflowSettingsBuilder`.)
- [ ] Store `Arc<Catalog>` in server/workflow state. **Deferred**.
- [ ] Replace 25 production `Catalog::builtin()` call sites with state-injected catalog. **Deferred**.
- [ ] Infer agent profile from adapter registry entry. **Deferred** — adapter metadata exposes `default_profile`; consumers still need wiring.
- [~] **Controls and request validation** — schema + storage landed; runtime validation deferred.
- [x] Add model control allow-lists to catalog *settings* schema (`ModelControls.reasoning_effort`, `ModelControls.speed` as `Vec<String>` allow-lists; concrete enum validation happens at catalog-build time).
- [ ] Change `Request.speed` and `GenerateParams.speed` from `Option<String>` to `Option<Speed>`. **Deferred** — the existing `Option<String>` API stays in place until catalog wiring is ready.
- [ ] Validate model-declared controls against adapter capabilities at catalog build time. **Deferred** with `Catalog` builder.
- [ ] Reject explicit unsupported controls at request build time. **Deferred** with `Catalog` builder.
- [ ] **Billing****Deferred**, depends on the resolved `Catalog` migration. The `ModelCostTable` settings schema (base `CostRates` + per-speed `BTreeMap<String, CostRates>`) is in place to receive the per-speed pricing rows.
## Test Plan
- `fabro-config`: parse and merge `[llm]`; reject literal credential refs; preserve the legacy `[llm] provider/model` migration hint; cover field-merge and whole-array replacement behavior.
- `fabro-model`: dynamic catalog lookup, adapter key validation, enabled-only alias collision behavior, duplicate-alias failure surfaces, defaults, provider `priority`, disabled entries, `NaiveDate` knowledge cutoff, model controls, adapter capability validation, absent-control defaults, non-empty `features.effort` controls, speed subset validation, and per-speed pricing.
- `fabro-auth`: existing vault credential JSON still parses; `credential:` and `env:` resolution order works; structured credential/provider mismatches fail; Codex OAuth remains restricted to canonical `openai` and fixed ChatGPT Codex base URL even when `[llm.providers.openai].base_url` is overridden.
- `fabro-llm`: built-in Kimi/Zai/Minimax/Inception register through `openai_compatible` settings without provider-specific branches; every catalog adapter metadata key has a production factory and every production factory is reachable from a metadata key; `Request.speed` is typed as `Option<Speed>` internally; request validation rejects explicit unsupported controls and omits legacy defaults for unsupported models.
- `fabro-validate`: built-in rules no longer call `Catalog::builtin()`; catalog-bound model/provider-known rules work through `extra_rules`.
- `fabro-api`: OpenAPI provider schema no longer replaces with `fabro_model::Provider`; provider string/`ProviderId` JSON parity is covered; TypeScript client generation reflects string providers.
- `fabro-server`/`fabro-workflow`/`fabro-cli`: `/models?provider=<id>` works with string IDs; project/workflow TOML can add a custom provider/model for a run; install/API-key validation uses bootstrap defaults; CLI model commands and server-returned models use the resolved catalog.
- Workspace policy test: CI enforces the `bootstrap_catalog` reference allowlist across the workspace so request-serving modules cannot call bootstrap/default constructors.
- Verification commands:
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-config -p fabro-model -p fabro-auth -p fabro-llm -p fabro-validate -p fabro-workflow -p fabro-server -p fabro-api`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
## Assumptions And Deferred Work
- All settings layers are trusted execution configuration. Provider routing may attach server credentials to outbound HTTP, so credential-specific invariants still matter even though project/workflow TOML is trusted.
- Field-merge for provider/model tables is intentional. Whole-array replacement for controls can mask future built-in values; more granular array merge operations are deferred.
- V1 does not support custom auth schemes, data-driven profile templates, provider-level CLI backend routing, data-driven adapter implementations, or new request control kinds.
- Adding a new value to an existing Rust-owned control enum, such as a new speed value beyond `standard` and `fast`, remains a Rust change.
- Existing imprecise knowledge cutoff labels migrate to exact normalized dates, e.g. `May 2025` becomes `2025-05-01`; presentation can render lower precision.
Run `cd apps/fabro-web && bun run typecheck` only when API schema or generated TypeScript client files change. Phase 9 does not plan schema/client changes.

View file

@ -459,7 +459,11 @@ pub async fn run_with_args_and_source(
mcp_servers: Vec<McpServerSettings>,
) -> anyhow::Result<()> {
let provider = parse_provider(&args)?;
let client = Client::from_source(llm_source.as_ref())
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.context("failed to build standalone agent LLM catalog")?,
);
let client = Client::from_source(llm_source.as_ref(), Arc::clone(&catalog))
.await
.context("Failed to create LLM client")?;
ensure_provider_registered(&client, provider)?;

View file

@ -302,34 +302,17 @@ impl Session {
}
}
/// Build a session from a credential source. Resolves the LLM client
/// once at construction and caches it for the session's lifetime.
/// Build a session from a credential source and catalog. Resolves the LLM
/// client once at construction and caches it for the session's lifetime.
/// Sessions are bounded (≤ 1 hour); cached client is fine within that
/// window. For longer-lived contexts (workflow runs) hold a source,
/// not a session.
/// window. For longer-lived contexts (workflow runs) hold a source and
/// catalog, not a session.
///
/// # Errors
///
/// Returns an error if `Client::from_source` fails (e.g. vault unreachable,
/// OAuth refresh failed).
pub async fn from_source(
source: &dyn CredentialSource,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
config: SessionOptions,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
) -> Result<Self, LlmError> {
let client = Client::from_source(source).await?;
Ok(Self::new(
client,
provider_profile,
sandbox,
config,
subagent_manager,
))
}
pub async fn from_source_with_catalog(
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
provider_profile: Arc<dyn AgentProfile>,
@ -337,7 +320,7 @@ impl Session {
config: SessionOptions,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
) -> Result<Self, LlmError> {
let client = Client::from_source_with_catalog(source, catalog).await?;
let client = Client::from_source(source, catalog).await?;
Ok(Self::new(
client,
provider_profile,

View file

@ -17,7 +17,8 @@ use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::provider::{Provider, ProviderAdapter};
use fabro_llm::providers::OpenAiAdapter;
use fabro_model::ModelHandle;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ModelHandle};
use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai};
use tokio::sync::Mutex as AsyncMutex;
@ -150,7 +151,11 @@ async fn make_client(provider: Provider, twin: Option<&OpenAiTwinOptions>) -> Cl
}
let source = EnvCredentialSource::new();
Client::from_source(&source)
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
);
Client::from_source(&source, catalog)
.await
.expect("Client::from_source failed")
}

View file

@ -11,17 +11,7 @@ pub struct ResolvedCredentials {
#[async_trait]
pub trait CredentialSource: Send + Sync {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials>;
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials>;
async fn configured_providers(&self) -> Vec<ProviderId>;
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let _ = catalog;
self.resolve().await
}
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
let _ = catalog;
self.configured_providers().await
}
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId>;
}

View file

@ -2,9 +2,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{
Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter, bootstrap_catalog,
};
use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter};
use fabro_static::EnvVars;
use crate::credential_source::{CredentialSource, ResolvedCredentials};
@ -136,11 +134,7 @@ impl Default for EnvCredentialSource {
#[async_trait]
impl CredentialSource for EnvCredentialSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
self.resolve_for_catalog(bootstrap_catalog::catalog()).await
}
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let mut credentials = Vec::new();
let mut auth_issues = Vec::new();
@ -159,12 +153,7 @@ impl CredentialSource for EnvCredentialSource {
})
}
async fn configured_providers(&self) -> Vec<ProviderId> {
self.configured_providers_for_catalog(bootstrap_catalog::catalog())
.await
}
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
.iter()
@ -208,11 +197,16 @@ mod tests {
Catalog::from_builtin_with_overrides(&settings).unwrap()
}
fn default_catalog() -> Catalog {
catalog_with("")
}
#[tokio::test]
async fn configured_providers_reads_injected_env() {
let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]);
let catalog = default_catalog();
assert_eq!(source.configured_providers().await, vec![
assert_eq!(source.configured_providers(&catalog).await, vec![
Provider::Anthropic.id()
]);
}
@ -220,8 +214,9 @@ mod tests {
#[tokio::test]
async fn resolve_returns_empty_when_no_keys_are_configured() {
let source = test_source(&[]);
let catalog = default_catalog();
let resolved = source.resolve().await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
assert!(resolved.credentials.is_empty());
assert!(resolved.auth_issues.is_empty());
@ -234,8 +229,9 @@ mod tests {
("CHATGPT_ACCOUNT_ID", "acct_123"),
("OPENAI_PROJECT_ID", "project_123"),
]);
let catalog = default_catalog();
let resolved = source.resolve().await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
let credential = resolved.credentials.first().unwrap();
assert_eq!(credential.provider, Provider::OpenAi.id());
@ -254,8 +250,9 @@ mod tests {
#[tokio::test]
async fn resolve_uses_catalog_credentials_and_base_url_for_openai_compatible_providers() {
let source = test_source(&[("KIMI_API_KEY", "kimi-key")]);
let catalog = default_catalog();
let resolved = source.resolve().await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
let credential = resolved.credentials.first().unwrap();
assert_eq!(credential.provider, Provider::Kimi.id());
@ -266,7 +263,7 @@ mod tests {
}
#[tokio::test]
async fn resolve_for_catalog_registers_custom_env_backed_provider() {
async fn resolve_registers_custom_env_backed_provider() {
let catalog = catalog_with(
r#"
[providers.venice]
@ -293,7 +290,7 @@ effort = false
);
let source = test_source(&[("VENICE_API_KEY", "venice-key")]);
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
let credential = resolved
.credentials
.iter()
@ -311,7 +308,7 @@ effort = false
}
#[tokio::test]
async fn resolve_for_catalog_registers_header_only_provider() {
async fn resolve_registers_header_only_provider() {
let catalog = catalog_with(
r#"
[providers.portkey]
@ -341,7 +338,7 @@ effort = true
);
let source = test_source(&[("PORTKEY_API_KEY", "pk-live")]);
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
let credential = resolved
.credentials
.iter()
@ -360,7 +357,7 @@ effort = true
}
#[tokio::test]
async fn resolve_for_catalog_reports_missing_required_header() {
async fn resolve_reports_missing_required_header() {
let catalog = catalog_with(
r#"
[providers.portkey]
@ -389,7 +386,7 @@ effort = true
);
let source = test_source(&[]);
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
assert!(
!resolved

View file

@ -4,7 +4,6 @@ use std::sync::Arc;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{
ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter,
bootstrap_catalog,
};
use fabro_static::EnvVars;
use fabro_vault::Vault;
@ -43,33 +42,10 @@ pub struct ApiCredential {
}
impl ApiCredential {
/// Build an `ApiCredential` from just an API key. Picks the right
/// auth header kind for the provider (Anthropic uses `x-api-key`;
/// everyone else uses `Authorization: Bearer`). All other fields
/// default to empty.
#[must_use]
pub fn from_api_key(provider: impl Into<ProviderId>, key: String) -> Self {
let provider = provider.into();
let auth_header = default_auth_header_for_provider(&provider, key);
Self {
provider,
auth_header: Some(auth_header),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}
}
/// Build an `ApiCredential` from an API key using the supplied catalog for
/// auth header policy and provider base URL.
#[must_use]
pub fn from_api_key_for_catalog(
provider: impl Into<ProviderId>,
key: String,
catalog: &Catalog,
) -> Self {
pub fn from_api_key(provider: impl Into<ProviderId>, key: String, catalog: &Catalog) -> Self {
let provider_id = provider.into();
let (auth_header, base_url) = match catalog.provider(&provider_id) {
Some(provider) => (
@ -102,16 +78,10 @@ pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHe
}
fn default_auth_header_for_provider(provider: &ProviderId, key: String) -> ApiKeyHeader {
let policy = bootstrap_catalog::catalog()
.provider(provider)
.and_then(|provider| adapter::get(&provider.adapter))
.map_or_else(
|| match Provider::from_id(provider) {
Some(Provider::Anthropic) => ApiKeyHeaderPolicy::Custom { name: "x-api-key" },
_ => ApiKeyHeaderPolicy::Bearer,
},
|adapter| adapter.api_key_header,
);
let policy = match Provider::from_id(provider) {
Some(Provider::Anthropic) => ApiKeyHeaderPolicy::Custom { name: "x-api-key" },
_ => ApiKeyHeaderPolicy::Bearer,
};
build_api_key_header(policy, key)
}
@ -188,11 +158,15 @@ impl CredentialResolver {
&self,
provider: impl Into<ProviderId>,
usage: CredentialUsage,
catalog: &Catalog,
) -> Result<ResolvedCredential, ResolveError> {
let provider = provider.into();
let provider_id = provider.into();
let Some(catalog_provider) = catalog.provider(&provider_id) else {
return Err(ResolveError::NotConfigured(provider_id));
};
let initial_credential = {
let vault = self.vault.read().await;
self.find_credential(&vault, &provider, usage)?
self.find_credential(&vault, catalog_provider, usage)?
};
let credential = if initial_credential.needs_refresh() {
@ -200,18 +174,18 @@ impl CredentialResolver {
unreachable!("only OAuth credentials can need refresh");
};
if tokens.refresh_token.is_none() {
return Err(ResolveError::RefreshTokenMissing(provider.clone()));
return Err(ResolveError::RefreshTokenMissing(provider_id.clone()));
}
let refreshed = refresh_oauth_credential(&initial_credential)
.await
.map_err(|source| ResolveError::RefreshFailed {
provider: provider.clone(),
provider: provider_id.clone(),
source,
})?;
let credential_id =
credential_id_for(&refreshed).map_err(|message| ResolveError::RefreshFailed {
provider: provider.clone(),
provider: provider_id.clone(),
source: anyhow::anyhow!(message),
})?;
let refreshed_for_store = refreshed.clone();
@ -224,11 +198,11 @@ impl CredentialResolver {
})
.await
.map_err(|join_err| ResolveError::RefreshFailed {
provider: provider.clone(),
provider: provider_id.clone(),
source: anyhow::Error::from(join_err),
})?
.map_err(|source| ResolveError::RefreshFailed {
provider: provider.clone(),
provider: provider_id.clone(),
source,
})?;
refreshed
@ -239,24 +213,16 @@ impl CredentialResolver {
let vault = self.vault.read().await;
match usage {
CredentialUsage::ApiRequest => self
.to_api_credential(&vault, &credential)
.to_api_credential(&vault, &credential, catalog)
.map(ResolvedCredential::Api),
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
Self::to_cli_credential(&credential, kind),
Self::to_cli_credential(&credential, kind, catalog),
)),
}
}
#[must_use]
pub fn configured_providers(&self, vault: &Vault) -> Vec<ProviderId> {
self.configured_providers_for_catalog(vault, bootstrap_catalog::catalog())
}
pub fn configured_providers_for_catalog(
&self,
vault: &Vault,
catalog: &Catalog,
) -> Vec<ProviderId> {
pub fn configured_providers(&self, vault: &Vault, catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
.iter()
@ -266,36 +232,6 @@ impl CredentialResolver {
}
fn find_credential(
&self,
vault: &Vault,
provider: &ProviderId,
usage: CredentialUsage,
) -> Result<AuthCredential, ResolveError> {
if provider == &Provider::OpenAi.id()
&& usage == CredentialUsage::CliAgent(CliAgentKind::Codex)
{
for credential_id in ["openai_codex", "openai"] {
if let Some(credential) = vault_get_credential(vault, credential_id) {
return Ok(credential);
}
}
}
if let Some(catalog_provider) = bootstrap_catalog::catalog().provider(provider) {
for credential_ref in &catalog_provider.credentials {
if let Some(credential) = self.credential_from_ref(vault, provider, credential_ref)
{
return Ok(credential);
}
}
} else if let Some(credential) = vault_get_credential(vault, provider.as_str()) {
return Ok(credential);
}
Err(ResolveError::NotConfigured(provider.clone()))
}
fn find_credential_for_catalog(
&self,
vault: &Vault,
provider: &CatalogProvider,
@ -416,14 +352,6 @@ impl CredentialResolver {
&self,
vault: &Vault,
credential: &AuthCredential,
) -> Result<ApiCredential, ResolveError> {
self.to_api_credential_for_catalog(vault, credential, bootstrap_catalog::catalog())
}
fn to_api_credential_for_catalog(
&self,
vault: &Vault,
credential: &AuthCredential,
catalog: &Catalog,
) -> Result<ApiCredential, ResolveError> {
let base_url = self.provider_base_url_for_catalog(vault, &credential.provider, catalog);
@ -472,51 +400,7 @@ impl CredentialResolver {
}
}
pub async fn resolve_for_catalog(
&self,
provider: impl Into<ProviderId>,
usage: CredentialUsage,
catalog: &Catalog,
) -> Result<ResolvedCredential, ResolveError> {
let provider_id = provider.into();
let Some(catalog_provider) = catalog.provider(&provider_id) else {
return Err(ResolveError::NotConfigured(provider_id));
};
let initial_credential = {
let vault = self.vault.read().await;
self.find_credential_for_catalog(&vault, catalog_provider, usage)?
};
let credential = if initial_credential.needs_refresh() {
let AuthDetails::CodexOAuth { tokens, .. } = &initial_credential.details else {
unreachable!("only OAuth credentials can need refresh");
};
if tokens.refresh_token.is_none() {
return Err(ResolveError::RefreshTokenMissing(provider_id.clone()));
}
refresh_oauth_credential(&initial_credential)
.await
.map_err(|source| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source,
})?
} else {
initial_credential
};
let vault = self.vault.read().await;
match usage {
CredentialUsage::ApiRequest => self
.to_api_credential_for_catalog(&vault, &credential, catalog)
.map(ResolvedCredential::Api),
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
Self::to_cli_credential(&credential, kind),
)),
}
}
pub async fn header_only_api_credential_for_catalog(
pub async fn header_only_api_credential(
&self,
provider: &CatalogProvider,
catalog: &Catalog,
@ -538,7 +422,11 @@ impl CredentialResolver {
}))
}
fn to_cli_credential(credential: &AuthCredential, kind: CliAgentKind) -> CliCredential {
fn to_cli_credential(
credential: &AuthCredential,
kind: CliAgentKind,
catalog: &Catalog,
) -> CliCredential {
let mut env_vars = HashMap::new();
let provider = Provider::from_id(&credential.provider);
let login_command = match (provider, &credential.details, kind) {
@ -563,7 +451,7 @@ impl CredentialResolver {
Some(codex_login_command(&tokens.access_token))
}
(_, AuthDetails::ApiKey { key }, _) => {
if let Some(name) = primary_api_key_env_var(&credential.provider) {
if let Some(name) = primary_api_key_env_var(&credential.provider, catalog) {
env_vars.insert(name.to_string(), key.clone());
}
None
@ -586,17 +474,18 @@ impl CredentialResolver {
pub async fn configured_providers_from_process_env(
vault: Option<&Arc<AsyncRwLock<Vault>>>,
catalog: &Catalog,
) -> Vec<ProviderId> {
match vault {
Some(vault_arc) => {
let resolver = CredentialResolver::new(Arc::clone(vault_arc));
let guard = vault_arc.read().await;
resolver.configured_providers(&guard)
resolver.configured_providers(&guard, catalog)
}
None => bootstrap_catalog::catalog()
None => catalog
.providers()
.iter()
.filter(|provider| provider_has_process_env_api_key(&provider.id))
.filter(|provider| provider_has_process_env_api_key(provider))
.map(|provider| provider.id.clone())
.collect(),
}
@ -606,18 +495,17 @@ pub async fn configured_providers_from_process_env(
clippy::disallowed_methods,
reason = "Provider discovery intentionally checks documented API-key env names."
)]
fn provider_has_process_env_api_key(provider: &ProviderId) -> bool {
bootstrap_catalog::catalog()
.provider(provider)
.is_some_and(|catalog_provider| {
catalog_provider.credentials.iter().any(|credential_ref| {
matches!(credential_ref, CredentialRef::Env(name) if std::env::var(name).is_ok())
})
fn provider_has_process_env_api_key(provider: &CatalogProvider) -> bool {
provider
.credentials
.iter()
.any(|credential_ref| {
matches!(credential_ref, CredentialRef::Env(name) if std::env::var(name).is_ok())
})
}
fn primary_api_key_env_var(provider: &ProviderId) -> Option<&'static str> {
bootstrap_catalog::catalog()
fn primary_api_key_env_var<'a>(provider: &ProviderId, catalog: &'a Catalog) -> Option<&'a str> {
catalog
.provider(provider)?
.credentials
.iter()
@ -689,6 +577,10 @@ mod tests {
Catalog::from_builtin_with_overrides(&settings).unwrap()
}
fn default_catalog() -> Catalog {
catalog_with("")
}
#[tokio::test]
async fn resolve_openai_api_request_prefers_typed_credential() {
let dir = tempfile::tempdir().unwrap();
@ -700,9 +592,10 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| Some("env-key".to_string())));
let catalog = default_catalog();
let resolved = resolver
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest)
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest, &catalog)
.await
.unwrap();
@ -729,9 +622,10 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let resolved = resolver
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest)
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest, &catalog)
.await
.unwrap();
@ -754,9 +648,10 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let err = resolver
.resolve(Provider::Anthropic, CredentialUsage::ApiRequest)
.resolve(Provider::Anthropic, CredentialUsage::ApiRequest, &catalog)
.await
.unwrap_err();
@ -777,9 +672,10 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let ResolvedCredential::Api(api) = resolver
.resolve(Provider::Anthropic, CredentialUsage::ApiRequest)
.resolve(Provider::Anthropic, CredentialUsage::ApiRequest, &catalog)
.await
.unwrap()
else {
@ -797,6 +693,30 @@ mod tests {
#[tokio::test]
async fn openai_compatible_resolves_with_openai_base_url_from_vault() {
let catalog = catalog_with(
r#"
[providers.openai_compatible]
display_name = "OpenAI Compatible"
adapter = "openai_compatible"
base_url = "https://default.example.com/v1"
credentials = ["credential:openai_compatible"]
[models."compat-model"]
provider = "openai_compatible"
display_name = "Compat Model"
family = "openai"
default = true
[models."compat-model".limits]
context_window = 128000
[models."compat-model".features]
tools = true
vision = false
reasoning = false
effort = false
"#,
);
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(
@ -814,9 +734,12 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let resolved = resolver
.resolve(Provider::OpenAiCompatible, CredentialUsage::ApiRequest)
.resolve(
Provider::OpenAiCompatible,
CredentialUsage::ApiRequest,
&catalog,
)
.await
.unwrap();
@ -847,11 +770,13 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let ResolvedCredential::Cli(cli) = resolver
.resolve(
Provider::OpenAi,
CredentialUsage::CliAgent(CliAgentKind::Codex),
&catalog,
)
.await
.unwrap()
@ -885,11 +810,13 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let ResolvedCredential::Cli(cli) = resolver
.resolve(
Provider::OpenAi,
CredentialUsage::CliAgent(CliAgentKind::Codex),
&catalog,
)
.await
.unwrap()
@ -935,11 +862,13 @@ mod tests {
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let ResolvedCredential::Cli(cli) = resolver
.resolve(
Provider::OpenAi,
CredentialUsage::CliAgent(CliAgentKind::Codex),
&catalog,
)
.await
.unwrap()
@ -994,9 +923,10 @@ mod tests {
vault,
Arc::new(|name| (name == "OPENAI_ORG_ID").then(|| "env-org".to_string())),
);
let catalog = default_catalog();
let ResolvedCredential::Api(api) = resolver
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest)
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest, &catalog)
.await
.unwrap()
else {
@ -1018,14 +948,15 @@ mod tests {
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let vault = resolver.vault.read().await;
let catalog = default_catalog();
assert_eq!(resolver.configured_providers(&vault), vec![
assert_eq!(resolver.configured_providers(&vault, &catalog), vec![
Provider::OpenAi.id()
]);
}
#[tokio::test]
async fn resolve_for_catalog_uses_custom_vault_backed_provider() {
async fn resolve_uses_custom_vault_backed_provider() {
let catalog = catalog_with(
r#"
[providers.venice]
@ -1062,7 +993,7 @@ effort = false
let resolver = test_resolver(vault, Arc::new(|_| None));
let resolved = resolver
.resolve_for_catalog(
.resolve(
ProviderId::new("venice"),
CredentialUsage::ApiRequest,
&catalog,
@ -1093,8 +1024,9 @@ effort = false
Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())),
);
let vault = resolver.vault.read().await;
let catalog = default_catalog();
assert_eq!(resolver.configured_providers(&vault), vec![
assert_eq!(resolver.configured_providers(&vault, &catalog), vec![
Provider::OpenAi.id()
]);
}
@ -1136,11 +1068,13 @@ effort = false
.unwrap();
let vault = Arc::new(AsyncRwLock::new(vault));
let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None));
let catalog = default_catalog();
let ResolvedCredential::Cli(cli) = resolver
.resolve(
Provider::OpenAi,
CredentialUsage::CliAgent(CliAgentKind::Codex),
&catalog,
)
.await
.unwrap()
@ -1183,11 +1117,13 @@ effort = false
tokens.refresh_token = None;
vault_set_credential(&mut vault, "openai_codex", &credential).unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
let err = resolver
.resolve(
Provider::OpenAi,
CredentialUsage::CliAgent(CliAgentKind::Codex),
&catalog,
)
.await
.unwrap_err();

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use fabro_model::{Catalog, ProviderId, bootstrap_catalog};
use fabro_model::{Catalog, ProviderId};
use fabro_vault::Vault;
use tokio::sync::RwLock as AsyncRwLock;
@ -41,18 +41,14 @@ impl std::fmt::Debug for VaultCredentialSource {
#[async_trait]
impl CredentialSource for VaultCredentialSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
self.resolve_for_catalog(bootstrap_catalog::catalog()).await
}
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let mut credentials = Vec::new();
let mut auth_issues = Vec::new();
for provider in catalog.providers() {
match self
.resolver
.resolve_for_catalog(provider.id.clone(), CredentialUsage::ApiRequest, catalog)
.resolve(provider.id.clone(), CredentialUsage::ApiRequest, catalog)
.await
{
Ok(ResolvedCredential::Api(credential)) => credentials.push(credential),
@ -60,7 +56,7 @@ impl CredentialSource for VaultCredentialSource {
Err(ResolveError::NotConfigured(_)) => {
match self
.resolver
.header_only_api_credential_for_catalog(provider, catalog)
.header_only_api_credential(provider, catalog)
.await
{
Ok(Some(credential)) => credentials.push(credential),
@ -78,15 +74,9 @@ impl CredentialSource for VaultCredentialSource {
})
}
async fn configured_providers(&self) -> Vec<ProviderId> {
self.configured_providers_for_catalog(bootstrap_catalog::catalog())
.await
}
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
let vault = self.vault.read().await;
self.resolver
.configured_providers_for_catalog(&vault, catalog)
self.resolver.configured_providers(&vault, catalog)
}
}
@ -95,7 +85,8 @@ mod tests {
use std::sync::Arc;
use chrono::{Duration, Utc};
use fabro_model::Provider;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, Provider};
use fabro_vault::{SecretType, Vault};
use tokio::sync::RwLock as AsyncRwLock;
@ -134,6 +125,10 @@ mod tests {
}
}
fn default_catalog() -> Catalog {
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()
}
#[tokio::test]
async fn resolve_returns_credentials_and_auth_issues() {
let dir = tempfile::tempdir().unwrap();
@ -158,8 +153,9 @@ mod tests {
let source =
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
let catalog = default_catalog();
let resolved = source.resolve().await.unwrap();
let resolved = source.resolve(&catalog).await.unwrap();
assert_eq!(resolved.credentials.len(), 1);
assert_eq!(resolved.credentials[0].provider, Provider::Anthropic.id());
@ -197,8 +193,9 @@ mod tests {
.unwrap();
let source =
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
let catalog = default_catalog();
assert_eq!(source.configured_providers().await, vec![
assert_eq!(source.configured_providers(&catalog).await, vec![
Provider::Anthropic.id(),
Provider::OpenAi.id()
]);

View file

@ -90,8 +90,8 @@ pub(crate) async fn validate_api_key(
api_key: &str,
catalog: Arc<Catalog>,
) -> Result<()> {
let client = LlmClient::from_credentials_with_catalog(
vec![ApiCredential::from_api_key_for_catalog(
let client = LlmClient::from_credentials(
vec![ApiCredential::from_api_key(
provider,
api_key.to_string(),
catalog.as_ref(),

View file

@ -65,6 +65,7 @@ impl Section {
fn render_options_reference() -> String {
let mut output = String::new();
render_manual_cli_target(&mut output);
render_manual_llm_catalog(&mut output);
for section in metadata_sections() {
render_section(&mut output, &section);
@ -209,6 +210,140 @@ url = "https://fabro.example.com/api/v1"
);
}
fn render_manual_llm_catalog(output: &mut String) {
output.push_str(
r#"## `[llm.providers.<id>]`
Define or override an LLM provider. Provider IDs are strings, so custom
providers can be added when they use an adapter Fabro already supports.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
base_url = "https://llm-gateway.example.com/v1"
credentials = ["env:ACME_GATEWAY_API_KEY"]
priority = 50
enabled = true
aliases = ["gateway"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
x-team-secret = { credential = "gateway_team_secret" }
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `display_name` | string | provider ID | Human-readable provider name. |
| `adapter` | string | inferred for built-ins | Adapter registry key, such as `"anthropic"`, `"openai"`, `"gemini"`, or `"openai_compatible"`. Custom providers normally use `"openai_compatible"`. |
| `base_url` | string | adapter default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `credentials` | array<string> | built-in env refs | Ordered credential refs. Accepted string forms are `credential:<id>` and `env:<NAME>`. Literal secret strings are rejected. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
## `[llm.models.<id>]`
Define or override a model in the catalog. The table key is the canonical
model ID Fabro users reference; `api_id` is the model string sent to the
provider API.
```toml title="settings.toml"
[llm.models."team-code-large"]
provider = "proxy"
api_id = "provider-wire-model-name"
display_name = "Team Code Large"
family = "team-code"
default = true
enabled = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.models."team-code-large".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
effort = true
[llm.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `provider` | string | None | Provider ID this model belongs to. |
| `api_id` | string | model ID | Identifier sent to the provider API. |
| `display_name` | string | model ID | Human-readable model name. |
| `family` | string | model ID | Family label used for catalog display and matching. |
| `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. |
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional model names accepted by routing and fallback config. |
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
## `[llm.models.<id>.limits]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `context_window` | integer | None | Maximum context window size in tokens. |
| `max_output` | integer | None | Maximum output tokens, if known. |
## `[llm.models.<id>.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_effort` | `"levels"` \| `"none"` | `"none"` | How Fabro may expose reasoning effort for this model. |
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `effort` | boolean | `false` | Whether the provider exposes a native effort parameter. |
## `[llm.models.<id>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | adapter defaults | User-facing reasoning effort values Fabro may send for this model. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.models.<id>.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.models.<id>.costs.speed.<speed>]`
Per-speed cost overrides use the same keys as `[llm.models.<id>.costs]`.
Each `<speed>` key must be declared in `[llm.models.<id>.controls].speed`.
The `standard` speed is implicit and always uses the base cost table.
"#,
);
}
fn render_manual_mcp(output: &mut String) {
output.push_str(
r#"## `[run.agent.mcps.<name>]`

View file

@ -37,12 +37,53 @@ const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
"/tests/policy.rs",
];
/// Production runtime code should build catalogs from resolved settings and
/// thread the resulting `Arc<Catalog>` 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/crates/fabro-model/",
// Tests and test support may use built-ins as fixtures.
"/tests/",
"/tests/it/",
"test_support",
"/tests/policy.rs",
];
#[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/crates/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<Catalog>`. If this is intentional test/bootstrap code, add the path fragment to CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS in lib/crates/fabro-dev/tests/it/policy.rs.",
format_violations(violations),
);
}
#[expect(
clippy::disallowed_methods,
reason = "policy test reads source files synchronously with std::fs"
)]
fn bootstrap_catalog_references_stay_in_allowlist() {
fn source_symbol_violations(
symbol: &str,
allowed_path_fragments: &[&str],
) -> Vec<(String, usize, String)> {
let root = workspace_root();
let lib_root = root.join("lib");
let mut violations: Vec<(String, usize, String)> = Vec::new();
@ -66,37 +107,106 @@ fn bootstrap_catalog_references_stay_in_allowlist() {
};
// Cheap early-out: avoids per-line work for the ~99% of files with no
// reference to the symbol.
if !contents.contains("bootstrap_catalog") {
if !contents.contains(symbol) {
continue;
}
let rel = path.strip_prefix(&root).unwrap_or(path);
let rel_str = rel.to_string_lossy().replace('\\', "/");
let path_allowed = BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS
let path_allowed = allowed_path_fragments
.iter()
.any(|frag| rel_str.contains(frag));
if path_allowed {
continue;
}
let mut pending_cfg_test = false;
let mut cfg_test_depth = None;
let mut brace_depth = 0usize;
for (idx, line) in contents.lines().enumerate() {
if !line.contains("bootstrap_catalog") {
let trimmed = line.trim_start();
let starts_cfg_test_module =
pending_cfg_test && trimmed.contains("mod tests") && trimmed.contains('{');
let in_cfg_test_module = cfg_test_depth.is_some() || starts_cfg_test_module;
if !line.contains(symbol) {
update_test_module_state(
trimmed,
&mut pending_cfg_test,
&mut cfg_test_depth,
&mut brace_depth,
starts_cfg_test_module,
);
continue;
}
// Skip comments referencing the symbol in prose.
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
update_test_module_state(
trimmed,
&mut pending_cfg_test,
&mut cfg_test_depth,
&mut brace_depth,
starts_cfg_test_module,
);
continue;
}
if in_cfg_test_module {
update_test_module_state(
trimmed,
&mut pending_cfg_test,
&mut cfg_test_depth,
&mut brace_depth,
starts_cfg_test_module,
);
continue;
}
violations.push((rel_str.clone(), idx + 1, line.to_string()));
update_test_module_state(
trimmed,
&mut pending_cfg_test,
&mut cfg_test_depth,
&mut brace_depth,
starts_cfg_test_module,
);
}
}
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/crates/fabro-dev/tests/it/policy.rs.",
violations
.into_iter()
.map(|(p, l, s)| format!(" {p}:{l}: {}", s.trim()))
.collect::<Vec<_>>()
.join("\n"),
);
violations
}
fn update_test_module_state(
trimmed: &str,
pending_cfg_test: &mut bool,
cfg_test_depth: &mut Option<usize>,
brace_depth: &mut usize,
starts_cfg_test_module: bool,
) {
let depth_before = *brace_depth;
let open_count = trimmed.chars().filter(|c| *c == '{').count();
let close_count = trimmed.chars().filter(|c| *c == '}').count();
*brace_depth = brace_depth.saturating_add(open_count);
*brace_depth = brace_depth.saturating_sub(close_count);
if starts_cfg_test_module {
*cfg_test_depth = Some(
depth_before
.saturating_add(open_count)
.saturating_sub(close_count),
);
}
if cfg_test_depth.is_some_and(|depth| *brace_depth < depth) {
*cfg_test_depth = None;
}
if trimmed.starts_with("#[cfg(test)]") {
*pending_cfg_test = true;
} else if !trimmed.is_empty() && !trimmed.starts_with("#[") {
*pending_cfg_test = false;
}
}
fn format_violations(violations: Vec<(String, usize, String)>) -> String {
violations
.into_iter()
.map(|(p, l, s)| format!(" {p}:{l}: {}", s.trim()))
.collect::<Vec<_>>()
.join("\n")
}

View file

@ -307,7 +307,7 @@ impl HookExecutorImpl {
let user_msg = Self::build_hook_user_message(&prompt, context);
Self::execute_llm_with_timeout(definition.timeout(), "prompt", || async move {
let client = match LlmClient::from_source_with_catalog(llm_source, catalog).await {
let client = match LlmClient::from_source(llm_source, catalog).await {
Ok(client) => Arc::new(client),
Err(e) => {
tracing::warn!(error = %e, "prompt hook client creation failed, proceeding");
@ -371,7 +371,7 @@ impl HookExecutorImpl {
let user_msg = Self::build_hook_user_message(&prompt, context);
Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move {
let client = match LlmClient::from_source_with_catalog(llm_source, catalog).await {
let client = match LlmClient::from_source(llm_source, catalog).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "agent hook client creation failed, proceeding");

View file

@ -30,9 +30,13 @@ All adapters support streaming, tool calling, structured output (`response_forma
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use std::sync::Arc;
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?;
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let request = Request {
model: "claude-sonnet-4-5".to_string(),
@ -60,9 +64,13 @@ println!("{}", response.text());
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;
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?;
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let result = generate(
GenerateParams::new("claude-sonnet-4-5", client.clone())
.prompt("Explain monads in one sentence")
@ -80,10 +88,13 @@ use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::generate::{generate, GenerateParams};
use fabro_llm::tools::Tool;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use std::sync::Arc;
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?;
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let weather_tool = Tool::active(
"get_weather",
"Get the current weather for a city",
@ -114,10 +125,14 @@ let result = generate(
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, StreamEvent};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::Catalog;
use futures::StreamExt;
use std::sync::Arc;
let source = EnvCredentialSource::new();
let client = Client::from_source(&source).await?;
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let request = Request {
model: "claude-sonnet-4-5".to_string(),
messages: vec![Message::user("Tell me a joke")],

View file

@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::sync::Arc;
use fabro_auth::{ApiCredential, ApiKeyHeader, CredentialSource};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ProviderId};
use tracing::debug;
@ -43,27 +42,18 @@ impl Client {
///
/// Returns `Error` if the source cannot resolve credentials or any provider
/// adapter fails to initialize.
pub async fn from_source(source: &dyn CredentialSource) -> Result<Self, Error> {
let resolved = source.resolve().await.map_err(|err| Error::Configuration {
message: format!("Failed to resolve LLM credentials: {err}"),
source: None,
})?;
Self::from_credentials(resolved.credentials).await
}
pub async fn from_source_with_catalog(
pub async fn from_source(
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> Result<Self, Error> {
let resolved =
source
.resolve_for_catalog(&catalog)
.await
.map_err(|err| Error::Configuration {
message: format!("Failed to resolve LLM credentials: {err}"),
source: None,
})?;
Self::from_credentials_with_catalog(resolved.credentials, catalog).await
let resolved = source
.resolve(&catalog)
.await
.map_err(|err| Error::Configuration {
message: format!("Failed to resolve LLM credentials: {err}"),
source: None,
})?;
Self::from_credentials(resolved.credentials, catalog).await
}
/// Create a Client from typed provider credentials.
@ -71,19 +61,7 @@ impl Client {
/// # Errors
///
/// Returns `Error` if any provider adapter fails to initialize.
pub async fn from_credentials(credentials: Vec<ApiCredential>) -> Result<Self, Error> {
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).map_err(
|err| Error::Configuration {
message: "Failed to build bootstrap LLM catalog".to_string(),
source: Some(Arc::new(err)),
},
)?,
);
Self::from_credentials_with_catalog(credentials, catalog).await
}
pub async fn from_credentials_with_catalog(
pub async fn from_credentials(
credentials: Vec<ApiCredential>,
catalog: Arc<Catalog>,
) -> Result<Self, Error> {
@ -474,14 +452,16 @@ mod tests {
#[async_trait]
impl CredentialSource for StubSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let _ = catalog;
Ok(ResolvedCredentials {
credentials: self.credentials.clone(),
auth_issues: Vec::new(),
})
}
async fn configured_providers(&self) -> Vec<fabro_model::ProviderId> {
async fn configured_providers(&self, catalog: &Catalog) -> Vec<fabro_model::ProviderId> {
let _ = catalog;
self.credentials
.iter()
.map(|credential| credential.provider.clone())
@ -671,29 +651,33 @@ mod tests {
#[tokio::test]
async fn from_credentials_registers_multiple_providers() {
let client = Client::from_credentials(vec![
ApiCredential {
provider: fabro_model::Provider::Anthropic.id(),
auth_header: Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
}),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
},
ApiCredential {
provider: fabro_model::Provider::OpenAi.id(),
auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
},
])
let catalog = catalog_with("");
let client = Client::from_credentials(
vec![
ApiCredential {
provider: fabro_model::Provider::Anthropic.id(),
auth_header: Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
}),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
},
ApiCredential {
provider: fabro_model::Provider::OpenAi.id(),
auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
},
],
catalog,
)
.await
.unwrap();
@ -705,15 +689,19 @@ mod tests {
#[tokio::test]
async fn from_credentials_supports_openai_compatible_provider_constants() {
let client = Client::from_credentials(vec![ApiCredential {
provider: fabro_model::Provider::Kimi.id(),
auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}])
let catalog = catalog_with("");
let client = Client::from_credentials(
vec![ApiCredential {
provider: fabro_model::Provider::Kimi.id(),
auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}],
catalog,
)
.await
.unwrap();
@ -723,15 +711,19 @@ mod tests {
#[tokio::test]
async fn from_credentials_rejects_custom_provider_id_without_adapter() {
let result = Client::from_credentials(vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}])
let catalog = catalog_with("");
let result = Client::from_credentials(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}],
catalog,
)
.await;
let Err(err) = result else {
panic!("custom provider credentials should fail without a registered adapter");
@ -762,14 +754,15 @@ mod tests {
project_id: None,
}],
};
let catalog = catalog_with("");
let client = Client::from_source(&source).await.unwrap();
let client = Client::from_source(&source, catalog).await.unwrap();
assert_eq!(client.provider_names(), vec!["anthropic"]);
}
#[tokio::test]
async fn from_credentials_with_catalog_registers_custom_openai_compatible_provider() {
async fn from_credentials_registers_custom_openai_compatible_provider() {
let catalog = catalog_with(
r#"
[providers.venice]
@ -796,7 +789,7 @@ effort = false
"#,
);
let client = Client::from_credentials_with_catalog(
let client = Client::from_credentials(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
@ -844,7 +837,7 @@ effort = false
"#,
);
let client = Client::from_credentials_with_catalog(
let client = Client::from_credentials(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
@ -867,7 +860,7 @@ effort = false
}
#[tokio::test]
async fn from_credentials_with_catalog_registers_header_only_provider() {
async fn from_credentials_registers_header_only_provider() {
let catalog = catalog_with(
r#"
[providers.portkey]
@ -895,7 +888,7 @@ effort = true
"#,
);
let client = Client::from_credentials_with_catalog(
let client = Client::from_credentials(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("portkey"),
auth_header: None,
@ -921,8 +914,9 @@ effort = true
let source = StubSource {
credentials: Vec::new(),
};
let catalog = catalog_with("");
let client = Client::from_source(&source).await.unwrap();
let client = Client::from_source(&source, catalog).await.unwrap();
assert!(client.provider_names().is_empty());
}

View file

@ -5,10 +5,15 @@ use strum::{Display, EnumString, IntoStaticStr};
use crate::ids::ProviderId;
// ---------------------------------------------------------------------------
// Provider enum — compile-time safe provider identity
// Provider enum - built-in provider compatibility
// ---------------------------------------------------------------------------
/// Known LLM provider variants.
/// Known built-in LLM providers.
///
/// Open-ended product identity is [`ProviderId`], because settings can define
/// additional provider IDs. This enum remains for built-in compatibility
/// paths: install/auth flows, legacy env var mappings, adapter defaults, and
/// tests that intentionally iterate the shipped providers.
#[derive(
Debug,
Clone,

View file

@ -477,7 +477,7 @@ async fn build_preflight_report(
let catalog = state.catalog();
let configured_providers = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.configured_providers(catalog.as_ref())
.await;
let materialized = materialize_run(
prepared.settings.clone(),

View file

@ -870,10 +870,10 @@ async fn resolve_llm_client_from_source(
catalog: Arc<Catalog>,
) -> anyhow::Result<LlmClientResult> {
let resolved = source
.resolve_for_catalog(catalog.as_ref())
.resolve(catalog.as_ref())
.await
.context("resolving LLM credentials")?;
let client = LlmClient::from_credentials_with_catalog(resolved.credentials, catalog)
let client = LlmClient::from_credentials(resolved.credentials, catalog)
.await
.context("creating LLM client")?;

View file

@ -43,7 +43,7 @@ async fn list_models(
let catalog = state.catalog();
let configured: HashSet<ProviderId> = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.configured_providers(catalog.as_ref())
.await
.into_iter()
.collect();

View file

@ -250,7 +250,7 @@ async fn create_run_pull_request(
let catalog = state.catalog();
let configured = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.configured_providers(catalog.as_ref())
.await;
catalog.default_for_configured_ids(&configured).id.clone()
};

View file

@ -405,7 +405,7 @@ async fn create_run(
let catalog = state.catalog();
let configured_providers = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.configured_providers(catalog.as_ref())
.await;
let mut create_input =
run_manifest::create_run_input(prepared.clone(), configured_providers, web_url.clone());
@ -423,7 +423,7 @@ async fn create_run(
.into_response();
}
};
let created = match Box::pin(operations::create_with_catalog(
let created = match Box::pin(operations::create(
state.store.as_ref(),
create_input,
storage_root,

View file

@ -1222,12 +1222,20 @@ struct FailingCredentialSource;
#[async_trait::async_trait]
impl CredentialSource for FailingCredentialSource {
async fn resolve(&self) -> anyhow::Result<fabro_auth::ResolvedCredentials> {
async fn resolve(
&self,
catalog: &fabro_model::Catalog,
) -> anyhow::Result<fabro_auth::ResolvedCredentials> {
let _ = catalog;
Err(anyhow::Error::new(std::io::Error::other("credential leaf"))
.context("credential source context"))
}
async fn configured_providers(&self) -> Vec<fabro_model::ProviderId> {
async fn configured_providers(
&self,
catalog: &fabro_model::Catalog,
) -> Vec<fabro_model::ProviderId> {
let _ = catalog;
Vec::new()
}
}
@ -1276,7 +1284,7 @@ async fn llm_source_configured_providers_reads_openai_codex_from_vault() {
assert_eq!(
state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.configured_providers(catalog.as_ref())
.await,
vec![Provider::OpenAi.id()]
);

View file

@ -10,7 +10,8 @@ use fabro_acp::{
use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider};
use fabro_auth::CredentialResolver;
use fabro_graphviz::graph::Node;
use fabro_model::Provider;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, Provider};
use fabro_util::time::elapsed_ms;
use tokio_util::sync::CancellationToken;
@ -27,6 +28,7 @@ pub struct AgentAcpBackend {
tool_env: Option<Arc<dyn ToolEnvProvider>>,
github_token_refresh_managed: bool,
resolver: Option<CredentialResolver>,
catalog: Arc<Catalog>,
}
impl AgentAcpBackend {
@ -38,6 +40,7 @@ impl AgentAcpBackend {
tool_env: None,
github_token_refresh_managed: false,
resolver: Some(resolver),
catalog: default_catalog(),
}
}
@ -49,6 +52,7 @@ impl AgentAcpBackend {
tool_env: None,
github_token_refresh_managed: false,
resolver: None,
catalog: default_catalog(),
}
}
@ -69,6 +73,12 @@ impl AgentAcpBackend {
self
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = catalog;
self
}
async fn run_turn(
&self,
node: &Node,
@ -90,6 +100,7 @@ impl AgentAcpBackend {
let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest {
provider,
cli: AgentCli::for_provider(provider),
catalog: self.catalog.as_ref(),
resolver: self.resolver.as_ref(),
tool_env: self.tool_env.as_ref(),
github_token_refresh_managed: self.github_token_refresh_managed,
@ -198,6 +209,13 @@ impl AgentAcpBackend {
}
}
fn default_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
#[async_trait]
impl CodergenBackend for AgentAcpBackend {
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {

View file

@ -568,7 +568,7 @@ impl AgentApiBackend {
) -> Result<Session, Error> {
let controls =
effective_request_controls(catalog.as_ref(), run_model_controls, model, node)?;
let client = Client::from_source_with_catalog(source, Arc::clone(&catalog))
let client = Client::from_source(source, Arc::clone(&catalog))
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", e))?;
@ -712,10 +712,9 @@ impl CodergenBackend for AgentApiBackend {
let emitter = request.emitter;
let stage_scope = request.stage_scope;
let client =
Client::from_source_with_catalog(self.source.as_ref(), Arc::clone(&self.catalog))
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", e))?;
let client = Client::from_source(self.source.as_ref(), Arc::clone(&self.catalog))
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", e))?;
let model = node.model().unwrap_or(&self.model);
let provider = self.resolve_provider_context(model, node.provider())?;
@ -1602,10 +1601,9 @@ effort = false
SteeringHub::for_tests(),
);
let client =
Client::from_source_with_catalog(backend.source.as_ref(), Arc::clone(&backend.catalog))
.await
.unwrap();
let client = Client::from_source(backend.source.as_ref(), Arc::clone(&backend.catalog))
.await
.unwrap();
assert_eq!(client.provider_names(), vec!["anthropic"]);
}

View file

@ -463,6 +463,7 @@ impl CodergenBackend for AgentCliBackend {
let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest {
provider,
cli,
catalog: self.catalog.as_ref(),
resolver: self.resolver.as_ref(),
tool_env: self.tool_env.as_ref(),
github_token_refresh_managed: self.github_token_refresh_managed,

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use fabro_agent::{Sandbox, ToolEnvProvider};
use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential};
use fabro_model::Provider;
use fabro_model::{Catalog, CredentialRef, Provider};
use tokio_util::sync::CancellationToken;
use super::cli::{AgentCli, process_env_var};
@ -13,6 +13,7 @@ use crate::event::{Emitter, RunNoticeCode, RunNoticeLevel};
pub(crate) struct AgentLaunchEnvRequest<'a> {
pub provider: Provider,
pub cli: AgentCli,
pub catalog: &'a Catalog,
pub resolver: Option<&'a CredentialResolver>,
pub tool_env: Option<&'a Arc<dyn ToolEnvProvider>>,
pub github_token_refresh_managed: bool,
@ -33,7 +34,11 @@ pub(crate) async fn resolve_agent_launch_env(
let mut launch_env = if let Some(resolver) = request.resolver {
let resolved = resolver
.resolve(request.provider, CredentialUsage::CliAgent(cli_agent))
.resolve(
request.provider,
CredentialUsage::CliAgent(cli_agent),
request.catalog,
)
.await
.map_err(|err| {
Error::handler_with_source(
@ -74,9 +79,21 @@ pub(crate) async fn resolve_agent_launch_env(
cli_credential.env_vars
} else {
let mut env = HashMap::new();
for name in request.provider.api_key_env_vars() {
if let Some(value) = process_env_var(name) {
env.insert((*name).to_string(), value);
let provider_id = request.provider.id();
if let Some(provider) = request.catalog.provider(&provider_id) {
for credential_ref in &provider.credentials {
let CredentialRef::Env(name) = credential_ref else {
continue;
};
if let Some(value) = process_env_var(name) {
env.insert(name.clone(), value);
}
}
} else {
for name in request.provider.api_key_env_vars() {
if let Some(value) = process_env_var(name) {
env.insert((*name).to_string(), value);
}
}
}
env

View file

@ -10,6 +10,7 @@ use std::sync::Arc;
use fabro_config::Storage;
use fabro_graphviz::graph::{AttrValue, Graph};
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::SandboxProvider;
@ -78,25 +79,12 @@ struct PersistCreateOptions {
catalog: Arc<Catalog>,
}
/// Resolve workflow inputs, normalize settings, and persist a run directory.
/// Resolve workflow inputs, normalize settings using the caller-provided
/// catalog, and persist a run directory.
pub async fn create(
store: &Database,
request: CreateRunInput,
storage_root: PathBuf,
) -> Result<CreatedRun, Error> {
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.map_err(|err| Error::engine(format!("building default LLM catalog: {err}")))?,
);
Box::pin(create_with_catalog(store, request, storage_root, catalog)).await
}
/// Resolve workflow inputs, normalize settings using a caller-provided catalog,
/// and persist a run directory.
pub async fn create_with_catalog(
store: &Database,
request: CreateRunInput,
storage_root: PathBuf,
catalog: Arc<Catalog>,
) -> Result<CreatedRun, Error> {
let resolved = resolve_workflow(ResolveWorkflowInput {
@ -890,6 +878,7 @@ mod tests {
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap_err();
@ -934,6 +923,7 @@ mod tests {
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap_err();
@ -1000,6 +990,7 @@ mod tests {
web_url: None,
},
storage_root.clone(),
test_catalog(),
)
.await
.unwrap();
@ -1109,6 +1100,7 @@ mod tests {
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap();
@ -1152,6 +1144,7 @@ mod tests {
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap();
@ -1217,6 +1210,7 @@ mod tests {
web_url: None,
},
storage_dir.clone(),
test_catalog(),
)
.await
.unwrap();
@ -1275,6 +1269,7 @@ mod tests {
web_url: None,
},
storage_dir,
test_catalog(),
)
.await
.unwrap();

View file

@ -13,9 +13,7 @@ pub use archive::{
ArchiveOutcome, UnarchiveOutcome, archive, archived_rejection_message, ensure_not_archived,
unarchive,
};
pub use create::{
CreateRunInput, CreatedRun, RenderMode, create, create_with_catalog, make_run_dir,
};
pub use create::{CreateRunInput, CreatedRun, RenderMode, create, make_run_dir};
pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run};
pub use resume::resume;
pub use rewind::{RewindInput, RewindOutcome, rewind};

View file

@ -488,7 +488,7 @@ async fn configured_providers_for_start(
)),
None => Arc::new(EnvCredentialSource::new()),
};
source.configured_providers_for_catalog(catalog).await
source.configured_providers(catalog).await
}
fn profile_provider_for_custom_provider(profile_kind: AgentProfileKind, adapter: &str) -> Provider {
@ -1126,6 +1126,13 @@ mod tests {
.expect("settings should resolve")
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
#[test]
fn runtime_clone_config_uses_run_level_clone_policy() {
let settings = settings_from_run_layer(RunLayer {
@ -1205,6 +1212,7 @@ mod tests {
web_url: None,
},
storage_root.to_path_buf(),
test_catalog(),
)
.await
.unwrap();
@ -1239,10 +1247,7 @@ mod tests {
github_app: None,
github_permissions: HashMap::new(),
vault: None,
catalog: Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
catalog: test_catalog(),
on_node: None,
registry_override: Some(registry),
}
@ -1398,6 +1403,7 @@ mod tests {
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap();

View file

@ -191,6 +191,7 @@ async fn build_registry(
|| AgentAcpBackend::new_from_env(model.clone(), provider),
|resolver| AgentAcpBackend::new(model.clone(), provider, resolver),
)
.with_catalog(Arc::clone(&catalog_for_api))
.with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed);
Some(Box::new(BackendRouter::new(Box::new(api), cli, acp)))
}))
@ -200,7 +201,7 @@ async fn build_registry(
return Ok((build_llm_registry(), false));
}
match llm_source.resolve_for_catalog(catalog.as_ref()).await {
match llm_source.resolve(catalog.as_ref()).await {
Ok(result) if result.credentials.is_empty() => {
if graph_needs_llm {
let detail = (!result.auth_issues.is_empty()).then(|| {
@ -1009,7 +1010,7 @@ mod tests {
.engine
.run
.llm_source
.resolve()
.resolve(&initialized.engine.run.catalog)
.await
.unwrap()
.credentials

View file

@ -351,7 +351,7 @@ pub async fn build_pr_content(
conclusion: Option<&Conclusion>,
run_state: Option<&RunProjection>,
) -> Result<PrContent, String> {
let client = Client::from_source_with_catalog(llm_source, Arc::clone(&catalog))
let client = Client::from_source(llm_source, Arc::clone(&catalog))
.await
.map_err(|e| format!("Failed to create LLM client: {e}"))?;

View file

@ -190,14 +190,16 @@ impl EngineServices {
#[async_trait::async_trait]
impl CredentialSource for StubCredentialSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
async fn resolve(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let _ = catalog;
Ok(ResolvedCredentials {
credentials: Vec::new(),
auth_issues: Vec::new(),
})
}
async fn configured_providers(&self) -> Vec<ProviderId> {
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
let _ = catalog;
Vec::new()
}
}
@ -297,7 +299,7 @@ mod tests {
services
.run
.llm_source
.configured_providers()
.configured_providers(&services.run.catalog)
.await
.is_empty()
);

View file

@ -6398,9 +6398,11 @@ mod real_llm {
fabro_test::require_env("ANTHROPIC_API_KEY")?;
let source = fabro_auth::EnvCredentialSource::new();
Some(Arc::new(Client::from_source(&source).await.expect(
"unified-llm client should initialize from env source",
)))
Some(Arc::new(
Client::from_source(&source, super::default_catalog())
.await
.expect("unified-llm client should initialize from env source"),
))
}
fn make_llm_backend(client: Arc<Client>) -> Box<LlmCodergenBackend> {