From 34d83db801629b95164ef378bf8891b03375b0f6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Tue, 12 May 2026 12:42:49 -0700 Subject: [PATCH] feat(model): support open provider catalog data (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR moves Fabro’s provider/model catalog toward settings-driven provider identity by replacing the closed provider schema at the API/auth/model boundary with `ProviderId`, then loading built-in provider and model metadata from embedded per-provider TOML files. The immediate result is that built-ins now use the same settings-shaped catalog data that custom providers will use later, while request-serving paths still keep the existing bootstrap/default catalog behavior until the resolved-catalog plumbing lands. ## Changes - Replaces API-facing provider enum usage with string-backed `ProviderId`, including OpenAPI/progenitor replacements and regenerated TypeScript client models. - Routes model, auth, billing, CLI, server, and workflow call sites through provider IDs where they cross product identity boundaries. - Builds `Catalog` from settings-shaped provider/model data with validation for adapter keys, OpenAI-compatible `base_url`, duplicate aliases, provider defaults, disabled entries, model controls, and per-speed cost rows. - Replaces `catalog.json` with embedded provider TOML files under `lib/crates/fabro-model/src/catalog/providers/`. - Adds an explicit `fabro_model::bootstrap_catalog` hatch for setup/install paths and extends the dev policy test to keep bootstrap access contained. - Preserves public training and knowledge-cutoff labels in LLM model settings while still accepting bare TOML dates. ## Verification - `cargo nextest run -p fabro-model -p fabro-config -p fabro-api` β€” 416 passed - `cargo nextest run -p fabro-dev --features dev bootstrap_catalog_references_stay_in_allowlist` β€” 1 passed - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - `cargo build --workspace` - `git diff --check` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) πŸ€– Generated with GPT-5 via [Codex](https://openai.com/codex) --- Cargo.lock | 4 + docs/public/api-reference/fabro-api.yaml | 20 +- lib/crates/fabro-agent/src/cli.rs | 6 +- lib/crates/fabro-agent/src/session.rs | 2 +- lib/crates/fabro-agent/src/tools.rs | 6 +- lib/crates/fabro-agent/src/types.rs | 2 +- lib/crates/fabro-agent/tests/it/guardrails.rs | 2 +- .../fabro-agent/tests/it/parity_matrix.rs | 6 +- lib/crates/fabro-api/build.rs | 2 +- .../fabro-api/tests/model_round_trip.rs | 2 +- .../fabro-api/tests/provider_round_trip.rs | 52 +- lib/crates/fabro-auth/src/credential.rs | 50 +- .../fabro-auth/src/credential_source.rs | 6 +- lib/crates/fabro-auth/src/env_source.rs | 117 +- lib/crates/fabro-auth/src/lib.rs | 3 +- lib/crates/fabro-auth/src/refresh.rs | 2 +- lib/crates/fabro-auth/src/resolve.rs | 317 +-- .../fabro-auth/src/strategies/api_key.rs | 2 +- .../fabro-auth/src/strategies/codex_device.rs | 2 +- lib/crates/fabro-auth/src/vault_ext.rs | 10 +- lib/crates/fabro-auth/src/vault_source.rs | 28 +- lib/crates/fabro-cli/src/commands/install.rs | 2 +- lib/crates/fabro-cli/src/commands/model.rs | 39 +- .../src/commands/run/run_progress/mod.rs | 2 +- .../fabro-cli/src/commands/run/runner.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/doctor.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/model.rs | 66 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 2 +- lib/crates/fabro-cli/tests/it/workflow/acp.rs | 2 +- .../fabro-cli/tests/it/workflow/hooks.rs | 2 +- lib/crates/fabro-config/Cargo.toml | 1 + lib/crates/fabro-config/src/layers/combine.rs | 2 - lib/crates/fabro-config/src/layers/llm.rs | 312 +-- lib/crates/fabro-dev/tests/it/policy.rs | 2 + lib/crates/fabro-llm/src/client.rs | 180 +- lib/crates/fabro-llm/src/model_test.rs | 12 +- lib/crates/fabro-model/Cargo.toml | 3 + lib/crates/fabro-model/src/billing.rs | 20 +- .../fabro-model/src/bootstrap_catalog.rs | 22 + lib/crates/fabro-model/src/catalog.json | 400 ---- lib/crates/fabro-model/src/catalog.rs | 1727 ++++++++++++++++- .../src/catalog/providers/anthropic.toml | 127 ++ .../src/catalog/providers/gemini.toml | 106 + .../src/catalog/providers/inception.toml | 30 + .../src/catalog/providers/kimi.toml | 30 + .../src/catalog/providers/minimax.toml | 28 + .../src/catalog/providers/openai.toml | 251 +++ .../src/catalog/providers/zai.toml | 28 + lib/crates/fabro-model/src/ids.rs | 57 +- lib/crates/fabro-model/src/lib.rs | 5 +- lib/crates/fabro-model/src/model_ref.rs | 23 +- lib/crates/fabro-model/src/provider.rs | 50 + lib/crates/fabro-model/src/types.rs | 16 +- lib/crates/fabro-server/src/demo/mod.rs | 4 +- lib/crates/fabro-server/src/diagnostics.rs | 12 +- lib/crates/fabro-server/src/install.rs | 2 +- lib/crates/fabro-server/src/run_manifest.rs | 146 +- lib/crates/fabro-server/src/server.rs | 2 +- .../src/server/handler/completions.rs | 11 +- .../fabro-server/src/server/handler/models.rs | 28 +- .../src/server/handler/pull_requests.rs | 2 +- lib/crates/fabro-server/src/server/tests.rs | 52 +- lib/crates/fabro-server/src/server_secrets.rs | 4 +- .../fabro-workflow/src/event/convert.rs | 12 +- .../fabro-workflow/src/handler/llm/api.rs | 2 +- .../fabro-workflow/src/operations/create.rs | 6 +- .../fabro-workflow/src/operations/start.rs | 19 +- lib/crates/fabro-workflow/src/outcome.rs | 5 +- .../fabro-workflow/src/pipeline/initialize.rs | 6 +- .../src/pipeline/pull_request.rs | 2 +- .../fabro-workflow/src/run_materialization.rs | 10 +- lib/crates/fabro-workflow/src/services.rs | 4 +- .../fabro-workflow/tests/it/integration.rs | 2 +- .../fabro-workflow/tests/materialize_run.rs | 2 +- .../src/.openapi-generator/FILES | 1 - .../fabro-api-client/src/api/models-api.ts | 19 +- .../src/models/billing-model-ref.ts | 8 +- .../fabro-api-client/src/models/index.ts | 1 - .../fabro-api-client/src/models/model.ts | 9 +- .../fabro-api-client/src/models/provider.ts | 35 - 80 files changed, 3221 insertions(+), 1377 deletions(-) create mode 100644 lib/crates/fabro-model/src/bootstrap_catalog.rs delete mode 100644 lib/crates/fabro-model/src/catalog.json create mode 100644 lib/crates/fabro-model/src/catalog/providers/anthropic.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/gemini.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/inception.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/kimi.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/minimax.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/openai.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/zai.toml delete mode 100644 lib/packages/fabro-api-client/src/models/provider.ts diff --git a/Cargo.lock b/Cargo.lock index c7ab02c9a..54c39c172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,7 @@ dependencies = [ "clap", "dirs", "fabro-macros", + "fabro-model", "fabro-options-metadata", "fabro-proc", "fabro-static", @@ -2131,9 +2132,12 @@ version = "0.231.0-nightly.3" dependencies = [ "fabro-static", "insta", + "rust-embed", "serde", "serde_json", "strum", + "thiserror 2.0.18", + "toml 0.8.23", ] [[package]] diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index c4ee7d38b..6c1eda3ba 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -3731,9 +3731,9 @@ components: name: provider in: query required: false - description: Filter models by provider name. Invalid values return `400`. + description: Filter models by provider ID. Unknown provider IDs return an empty result set. schema: - $ref: "#/components/schemas/Provider" + $ref: "#/components/schemas/ProviderId" example: anthropic ModelQueryFilter: @@ -4377,18 +4377,10 @@ components: meta: $ref: "#/components/schemas/PaginationMeta" - Provider: + ProviderId: description: LLM provider identifier. type: string - enum: - - anthropic - - openai - - gemini - - kimi - - zai - - minimax - - inception - - openai_compatible + example: anthropic ModelLimits: description: Token limits for a model. @@ -4477,7 +4469,7 @@ components: description: Unique model identifier. example: "claude-opus-4-6" provider: - $ref: "#/components/schemas/Provider" + $ref: "#/components/schemas/ProviderId" family: type: string description: Model family grouping. @@ -6933,7 +6925,7 @@ components: - model_id properties: provider: - $ref: "#/components/schemas/Provider" + $ref: "#/components/schemas/ProviderId" model_id: type: string speed: diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 3b216d284..92486776f 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -206,8 +206,8 @@ fn build_tool_approval( fn summarizer_model_id(provider: Provider) -> ModelHandle { ModelHandle::ByName { - provider, - model: match provider { + provider: provider.id(), + model: match provider { Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini", Provider::Gemini => "gemini-2.0-flash", Provider::Anthropic => "claude-haiku-4-5", @@ -484,7 +484,7 @@ pub async fn run_with_args_and_client( model } else { Catalog::builtin() - .default_for_provider(provider) + .default_for_provider(&provider.id()) .map(|model| model.id.clone()) .ok_or_else(|| { anyhow::anyhow!( diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 143898983..03ce3f866 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -1327,7 +1327,7 @@ impl Session { .as_deref() .and_then(|value| value.parse::().ok()); let model = ModelRef { - provider: self.provider_profile.provider(), + provider: self.provider_profile.provider().id(), model_id: if response.model.is_empty() { self.provider_profile.model().to_string() } else { diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/crates/fabro-agent/src/tools.rs index ea72fdde8..719799fd6 100644 --- a/lib/crates/fabro-agent/src/tools.rs +++ b/lib/crates/fabro-agent/src/tools.rs @@ -1410,7 +1410,7 @@ mod tests { let summarizer = WebFetchSummarizer { client, model_id: ModelHandle::ByName { - provider: fabro_model::Provider::Anthropic, + provider: fabro_model::Provider::Anthropic.id(), model: "mock-model".to_string(), }, }; @@ -1503,7 +1503,7 @@ mod tests { let mut providers = HashMap::new(); providers.insert("other_provider".to_string(), default_provider); - // Register under "anthropic" so ModelRef { provider: Anthropic, .. } routes + // Register under "anthropic" so ModelRef { provider: "anthropic", .. } routes // here providers.insert("anthropic".to_string(), target_provider); let client = Client::new(providers, Some("other_provider".into()), vec![]); @@ -1511,7 +1511,7 @@ mod tests { let summarizer = WebFetchSummarizer { client, model_id: ModelHandle::ByName { - provider: fabro_model::Provider::Anthropic, + provider: fabro_model::Provider::Anthropic.id(), model: "target-model".to_string(), }, }; diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index ad9103060..925b3c12a 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -671,7 +671,7 @@ mod tests { let event = AgentEvent::AssistantMessage { text: "Hello".into(), model: ModelRef { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model_id: "test-model".into(), speed: None, }, diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/crates/fabro-agent/tests/it/guardrails.rs index 5c2c89723..b53b0d41d 100644 --- a/lib/crates/fabro-agent/tests/it/guardrails.rs +++ b/lib/crates/fabro-agent/tests/it/guardrails.rs @@ -5,7 +5,7 @@ use fabro_model::{Catalog, Provider}; fn profile_context_window_matches_catalog_for_default_models() { for &provider in Provider::ALL { let catalog_info = Catalog::builtin() - .default_for_provider(provider) + .default_for_provider(&provider.id()) .cloned() .unwrap_or_else(|| panic!("no default model for {provider:?} in catalog")); let model = &catalog_info.id; diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/crates/fabro-agent/tests/it/parity_matrix.rs index ce766c70a..6d8154c3d 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/it/parity_matrix.rs @@ -35,15 +35,15 @@ fn summarizer_model_id(provider: Provider) -> ModelHandle { | Provider::Minimax | Provider::Inception | Provider::OpenAiCompatible => ModelHandle::ByName { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model: "gpt-5.4-mini".to_string(), }, Provider::Gemini => ModelHandle::ByName { - provider: Provider::Gemini, + provider: Provider::Gemini.id(), model: "gemini-3-flash-preview".to_string(), }, Provider::Anthropic => ModelHandle::ByName { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), model: "claude-haiku-4-5".to_string(), }, } diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index abaf2dadd..e81126d4e 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -356,7 +356,7 @@ fn main() { &[], ), ("BilledTokenCounts", "fabro_types::BilledTokenCounts", &[]), - ("Provider", "fabro_model::Provider", &[]), + ("ProviderId", "fabro_model::ProviderId", &[]), ("Model", "fabro_model::Model", &[]), ("ModelLimits", "fabro_model::ModelLimits", &[]), ("ModelFeatures", "fabro_model::ModelFeatures", &[]), diff --git a/lib/crates/fabro-api/tests/model_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs index 314f35103..4173131ed 100644 --- a/lib/crates/fabro-api/tests/model_round_trip.rs +++ b/lib/crates/fabro-api/tests/model_round_trip.rs @@ -12,7 +12,7 @@ fn model_reuses_canonical_type() { fn model_json_matches_openapi_shape() { let model = Model { id: "claude-opus-4-7".to_string(), - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), family: "claude-4".to_string(), display_name: "Claude Opus 4.7".to_string(), limits: ModelLimits { diff --git a/lib/crates/fabro-api/tests/provider_round_trip.rs b/lib/crates/fabro-api/tests/provider_round_trip.rs index 262f23bb4..a439b9e37 100644 --- a/lib/crates/fabro-api/tests/provider_round_trip.rs +++ b/lib/crates/fabro-api/tests/provider_round_trip.rs @@ -1,33 +1,61 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::Provider as ApiProvider; -use fabro_model::Provider; +use fabro_api::types::Model as ApiModel; +use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId}; use serde_json::json; #[test] -fn provider_reuses_canonical_type() { - assert_same_type::(); +fn provider_id_reuses_canonical_model_field_type() { + assert_same_type::(); } #[test] -fn provider_json_matches_openapi_shape() { +fn provider_id_json_matches_openapi_shape_through_model() { assert_eq!( - serde_json::to_value(Provider::Anthropic).unwrap(), + serde_json::to_value(Provider::Anthropic.id()).unwrap(), json!("anthropic") ); assert_eq!( - serde_json::to_value(Provider::OpenAi).unwrap(), + serde_json::to_value(Provider::OpenAi.id()).unwrap(), json!("openai") ); assert_eq!( - serde_json::to_value(Provider::OpenAiCompatible).unwrap(), + serde_json::to_value(Provider::OpenAiCompatible.id()).unwrap(), json!("openai_compatible") ); - assert_eq!( - serde_json::from_value::(json!("inception")).unwrap(), - Provider::Inception - ); + let model = Model { + id: "venice-custom".to_string(), + provider: ProviderId::new("venice"), + family: "venice".to_string(), + display_name: "Venice Custom".to_string(), + limits: ModelLimits { + context_window: 128_000, + max_output: None, + }, + training: None, + knowledge_cutoff: None, + features: ModelFeatures { + tools: false, + vision: false, + reasoning: false, + effort: false, + }, + costs: ModelCosts { + input_cost_per_mtok: None, + output_cost_per_mtok: None, + cache_input_cost_per_mtok: None, + }, + estimated_output_tps: None, + aliases: Vec::new(), + default: false, + configured: true, + }; + + let json = serde_json::to_value(&model).unwrap(); + assert_eq!(json["provider"], "venice"); + let round_trip: ApiModel = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip.provider, ProviderId::new("venice")); } fn assert_same_type() { diff --git a/lib/crates/fabro-auth/src/credential.rs b/lib/crates/fabro-auth/src/credential.rs index f881debc5..d64b25ac8 100644 --- a/lib/crates/fabro-auth/src/credential.rs +++ b/lib/crates/fabro-auth/src/credential.rs @@ -1,11 +1,11 @@ use chrono::{DateTime, Duration, Utc}; -use fabro_model::Provider; +use fabro_model::{Provider, ProviderId}; use fabro_redact::redact_string; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AuthCredential { - pub provider: Provider, + pub provider: ProviderId, #[serde(flatten)] pub details: AuthDetails, } @@ -90,14 +90,15 @@ impl std::fmt::Debug for ApiKeyHeader { } pub fn credential_id_for(credential: &AuthCredential) -> Result { - match (&credential.provider, &credential.details) { - (Provider::OpenAi, AuthDetails::ApiKey { .. }) => Ok("openai".to_string()), - (Provider::OpenAi, AuthDetails::CodexOAuth { .. }) => Ok("openai_codex".to_string()), - (_, AuthDetails::CodexOAuth { .. }) => Err(format!( + match &credential.details { + AuthDetails::ApiKey { .. } => Ok(credential.provider.to_string()), + AuthDetails::CodexOAuth { .. } if credential.provider == Provider::OpenAi.id() => { + Ok("openai_codex".to_string()) + } + AuthDetails::CodexOAuth { .. } => Err(format!( "codex_oauth credentials are only valid for OpenAI, got {}", credential.provider )), - (provider, AuthDetails::ApiKey { .. }) => Ok(provider.to_string()), } } @@ -119,7 +120,7 @@ mod tests { fn oauth_credential(expires_at: DateTime) -> AuthCredential { AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "access".to_string(), @@ -162,7 +163,7 @@ mod tests { #[test] fn credential_id_for_openai_api_key() { let credential = AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: "sk-test".to_string(), }, @@ -173,7 +174,7 @@ mod tests { #[test] fn credential_id_for_non_openai_codex_oauth_errors() { let mut credential = oauth_credential(Utc::now() + Duration::hours(1)); - credential.provider = Provider::Anthropic; + credential.provider = Provider::Anthropic.id(); assert!(credential_id_for(&credential).is_err()); } @@ -187,6 +188,35 @@ mod tests { assert!(parse_credential_secret("openai_codex", "{").is_err()); } + #[test] + fn credential_id_for_custom_api_key_uses_provider_id() { + let credential = AuthCredential { + provider: ProviderId::new("venice"), + details: AuthDetails::ApiKey { + key: "sk-test".to_string(), + }, + }; + + assert_eq!(credential_id_for(&credential).unwrap(), "venice"); + } + + #[test] + fn parse_credential_secret_accepts_custom_provider_api_key() { + let credential = AuthCredential { + provider: ProviderId::new("venice"), + details: AuthDetails::ApiKey { + key: "sk-test".to_string(), + }, + }; + let json = serde_json::to_string(&credential).unwrap(); + + assert_eq!( + parse_credential_secret("venice", &json).unwrap(), + credential + ); + assert!(parse_credential_secret("openai", &json).is_err()); + } + #[test] fn api_key_header_debug_redacts_secret_values() { let header = ApiKeyHeader::Bearer("sk-test".to_string()); diff --git a/lib/crates/fabro-auth/src/credential_source.rs b/lib/crates/fabro-auth/src/credential_source.rs index bba9f4600..cf0489d7e 100644 --- a/lib/crates/fabro-auth/src/credential_source.rs +++ b/lib/crates/fabro-auth/src/credential_source.rs @@ -1,17 +1,17 @@ use async_trait::async_trait; -use fabro_model::Provider; +use fabro_model::ProviderId; use crate::{ApiCredential, ResolveError}; #[derive(Debug)] pub struct ResolvedCredentials { pub credentials: Vec, - pub auth_issues: Vec<(Provider, ResolveError)>, + pub auth_issues: Vec<(ProviderId, ResolveError)>, } #[async_trait] pub trait CredentialSource: Send + Sync { async fn resolve(&self) -> anyhow::Result; - async fn configured_providers(&self) -> Vec; + async fn configured_providers(&self) -> Vec; } diff --git a/lib/crates/fabro-auth/src/env_source.rs b/lib/crates/fabro-auth/src/env_source.rs index 2f136f013..499a0573f 100644 --- a/lib/crates/fabro-auth/src/env_source.rs +++ b/lib/crates/fabro-auth/src/env_source.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use async_trait::async_trait; -use fabro_model::Provider; +use fabro_model::catalog::CatalogProvider; +use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId}; use fabro_static::EnvVars; use crate::credential_source::{CredentialSource, ResolvedCredentials}; @@ -31,39 +32,62 @@ impl EnvCredentialSource { (self.env_lookup)(name) } - fn credential_for(&self, provider: Provider) -> Option { - let key = provider - .api_key_env_vars() - .iter() - .find_map(|var| self.lookup(var))?; + fn credential_for(&self, provider: &CatalogProvider) -> Option { + let key = provider.credentials.iter().find_map(|credential_ref| { + let CredentialRef::Env(name) = credential_ref else { + return None; + }; + self.lookup(name) + })?; - let mut cred = ApiCredential::from_api_key(provider, key); - match provider { - Provider::Anthropic => { - cred.base_url = self.lookup(EnvVars::ANTHROPIC_BASE_URL); + let mut cred = ApiCredential::from_api_key(provider.id.clone(), key); + cred.base_url = self + .env_base_url(&provider.id) + .or_else(|| provider.base_url.clone()); + cred.extra_headers = self.resolved_extra_headers(provider)?; + if provider.id == Provider::OpenAi.id() { + cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID); + cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID); + if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) { + cred.base_url = Some("https://chatgpt.com/backend-api/codex".to_string()); + cred.codex_mode = true; + cred.extra_headers + .insert("ChatGPT-Account-Id".to_string(), account_id); + cred.extra_headers + .insert("originator".to_string(), "fabro".to_string()); } - Provider::OpenAi => { - cred.base_url = self.lookup(EnvVars::OPENAI_BASE_URL); - cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID); - cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID); - if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) { - cred.base_url = Some("https://chatgpt.com/backend-api/codex".to_string()); - cred.codex_mode = true; - cred.extra_headers - .insert("ChatGPT-Account-Id".to_string(), account_id); - cred.extra_headers - .insert("originator".to_string(), "fabro".to_string()); - } - } - Provider::Gemini => { - cred.base_url = self.lookup(EnvVars::GEMINI_BASE_URL); - } - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => {} - // OpenAiCompatible has no api_key_env_vars, so find_map returned None above. - Provider::OpenAiCompatible => unreachable!(), } Some(cred) } + + fn env_base_url(&self, provider: &ProviderId) -> Option { + match Provider::from_id(provider) { + Some(Provider::Anthropic) => self.lookup(EnvVars::ANTHROPIC_BASE_URL), + Some(Provider::OpenAi) => self.lookup(EnvVars::OPENAI_BASE_URL), + Some(Provider::Gemini) => self.lookup(EnvVars::GEMINI_BASE_URL), + Some(Provider::OpenAiCompatible) => self.lookup(EnvVars::OPENAI_COMPATIBLE_BASE_URL), + Some(Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception) + | None => None, + } + } + + fn resolved_extra_headers( + &self, + provider: &CatalogProvider, + ) -> Option> { + provider + .extra_headers + .iter() + .map(|(name, value_ref)| { + let value = match value_ref { + HeaderValueRef::Literal(value) => Some(value.clone()), + HeaderValueRef::Env(name) => self.lookup(name), + HeaderValueRef::Credential(_) => None, + }?; + Some((name.clone(), value)) + }) + .collect() + } } impl std::fmt::Debug for EnvCredentialSource { @@ -82,9 +106,9 @@ impl Default for EnvCredentialSource { #[async_trait] impl CredentialSource for EnvCredentialSource { async fn resolve(&self) -> anyhow::Result { - let credentials = Provider::ALL + let credentials = Catalog::builtin() + .providers() .iter() - .copied() .filter_map(|provider| self.credential_for(provider)) .collect(); @@ -94,16 +118,19 @@ impl CredentialSource for EnvCredentialSource { }) } - async fn configured_providers(&self) -> Vec { - Provider::ALL + async fn configured_providers(&self) -> Vec { + Catalog::builtin() + .providers() .iter() - .copied() .filter(|provider| { provider - .api_key_env_vars() + .credentials .iter() - .any(|env_var| self.lookup(env_var).is_some()) + .any(|credential_ref| { + matches!(credential_ref, CredentialRef::Env(name) if self.lookup(name).is_some()) + }) }) + .map(|provider| provider.id.clone()) .collect() } } @@ -131,7 +158,7 @@ mod tests { let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]); assert_eq!(source.configured_providers().await, vec![ - Provider::Anthropic + Provider::Anthropic.id() ]); } @@ -156,7 +183,7 @@ mod tests { let resolved = source.resolve().await.unwrap(); let credential = resolved.credentials.first().unwrap(); - assert_eq!(credential.provider, Provider::OpenAi); + assert_eq!(credential.provider, Provider::OpenAi.id()); assert!(credential.codex_mode); assert_eq!( credential.base_url.as_deref(), @@ -168,4 +195,18 @@ mod tests { ); assert_eq!(credential.project_id.as_deref(), Some("project_123")); } + + #[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 resolved = source.resolve().await.unwrap(); + let credential = resolved.credentials.first().unwrap(); + + assert_eq!(credential.provider, Provider::Kimi.id()); + assert_eq!( + credential.base_url.as_deref(), + Some("https://api.moonshot.ai/v1") + ); + } } diff --git a/lib/crates/fabro-auth/src/lib.rs b/lib/crates/fabro-auth/src/lib.rs index c845930da..9ba21b959 100644 --- a/lib/crates/fabro-auth/src/lib.rs +++ b/lib/crates/fabro-auth/src/lib.rs @@ -20,7 +20,8 @@ pub use env_source::EnvCredentialSource; pub use refresh::refresh_oauth_credential; pub use resolve::{ ApiCredential, CliAgentKind, CliCredential, CredentialResolver, CredentialUsage, EnvLookup, - ResolveError, ResolvedCredential, auth_issue_message, configured_providers_from_process_env, + ResolveError, ResolvedCredential, auth_issue_message, build_api_key_header, + configured_providers_from_process_env, }; pub use strategy::{ AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, codex_oauth_config, diff --git a/lib/crates/fabro-auth/src/refresh.rs b/lib/crates/fabro-auth/src/refresh.rs index c1eb04be4..3392957c5 100644 --- a/lib/crates/fabro-auth/src/refresh.rs +++ b/lib/crates/fabro-auth/src/refresh.rs @@ -24,7 +24,7 @@ pub async fn refresh_oauth_credential( .await .map_err(anyhow::Error::msg)?; Ok(AuthCredential { - provider: credential.provider, + provider: credential.provider.clone(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: response.access_token, diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index f1a63c984..dd186a965 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::sync::Arc; -use fabro_model::Provider; +use fabro_model::{ + ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter, +}; use fabro_static::EnvVars; use fabro_vault::Vault; use shlex::try_quote; @@ -29,7 +31,7 @@ pub enum CredentialUsage { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiCredential { - pub provider: Provider, + pub provider: ProviderId, pub auth_header: ApiKeyHeader, pub extra_headers: HashMap, pub base_url: Option, @@ -44,14 +46,9 @@ impl ApiCredential { /// everyone else uses `Authorization: Bearer`). All other fields /// default to empty. #[must_use] - pub fn from_api_key(provider: Provider, key: String) -> Self { - let auth_header = match provider { - Provider::Anthropic => ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: key, - }, - _ => ApiKeyHeader::Bearer(key), - }; + pub fn from_api_key(provider: impl Into, key: String) -> Self { + let provider = provider.into(); + let auth_header = auth_header_for_provider(&provider, key); Self { provider, auth_header, @@ -64,6 +61,31 @@ impl ApiCredential { } } +#[must_use] +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, + }, + } +} + +fn auth_header_for_provider(provider: &ProviderId, key: String) -> ApiKeyHeader { + let policy = Catalog::builtin() + .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, + ); + build_api_key_header(policy, key) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CliCredential { pub env_vars: HashMap, @@ -79,32 +101,30 @@ pub enum ResolvedCredential { #[derive(Debug, thiserror::Error)] pub enum ResolveError { #[error("{0} is not configured")] - NotConfigured(Provider), + NotConfigured(ProviderId), #[error("{provider} requires re-authentication: {source}")] RefreshFailed { - provider: Provider, + provider: ProviderId, #[source] source: anyhow::Error, }, #[error("{0} requires re-authentication: missing refresh token")] - RefreshTokenMissing(Provider), + RefreshTokenMissing(ProviderId), } #[must_use] -pub fn auth_issue_message(provider: Provider, err: &ResolveError) -> String { +pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String { + let provider_name = Provider::display_name_for_id(provider); match err { ResolveError::NotConfigured(_) => { - format!("{} is not configured", provider.display_name()) + format!("{provider_name} is not configured") + } + ResolveError::RefreshFailed { source, .. } => { + format!("{provider_name} requires re-authentication: {source}") + } + ResolveError::RefreshTokenMissing(_) => { + format!("{provider_name} requires re-authentication: refresh token missing") } - ResolveError::RefreshFailed { source, .. } => format!( - "{} requires re-authentication: {}", - provider.display_name(), - source - ), - ResolveError::RefreshTokenMissing(_) => format!( - "{} requires re-authentication: refresh token missing", - provider.display_name() - ), } } @@ -131,12 +151,13 @@ impl CredentialResolver { pub async fn resolve( &self, - provider: Provider, + provider: impl Into, usage: CredentialUsage, ) -> Result { + let provider = provider.into(); let initial_credential = { let vault = self.vault.read().await; - self.find_credential(&vault, provider, usage)? + self.find_credential(&vault, &provider, usage)? }; let credential = if initial_credential.needs_refresh() { @@ -144,16 +165,19 @@ impl CredentialResolver { unreachable!("only OAuth credentials can need refresh"); }; if tokens.refresh_token.is_none() { - return Err(ResolveError::RefreshTokenMissing(provider)); + return Err(ResolveError::RefreshTokenMissing(provider.clone())); } let refreshed = refresh_oauth_credential(&initial_credential) .await - .map_err(|source| ResolveError::RefreshFailed { provider, source })?; + .map_err(|source| ResolveError::RefreshFailed { + provider: provider.clone(), + source, + })?; let credential_id = credential_id_for(&refreshed).map_err(|message| ResolveError::RefreshFailed { - provider, - source: anyhow::anyhow!(message), + provider: provider.clone(), + source: anyhow::anyhow!(message), })?; let refreshed_for_store = refreshed.clone(); let vault = Arc::clone(&self.vault); @@ -165,10 +189,13 @@ impl CredentialResolver { }) .await .map_err(|join_err| ResolveError::RefreshFailed { - provider, - source: anyhow::Error::from(join_err), + provider: provider.clone(), + source: anyhow::Error::from(join_err), })? - .map_err(|source| ResolveError::RefreshFailed { provider, source })?; + .map_err(|source| ResolveError::RefreshFailed { + provider: provider.clone(), + source, + })?; refreshed } else { initial_credential @@ -176,9 +203,9 @@ impl CredentialResolver { let vault = self.vault.read().await; match usage { - CredentialUsage::ApiRequest => Ok(ResolvedCredential::Api( - self.to_api_credential(&vault, &credential), - )), + CredentialUsage::ApiRequest => self + .to_api_credential(&vault, &credential) + .map(ResolvedCredential::Api), CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli( Self::to_cli_credential(&credential, kind), )), @@ -186,71 +213,139 @@ impl CredentialResolver { } #[must_use] - pub fn configured_providers(&self, vault: &Vault) -> Vec { - Provider::ALL + pub fn configured_providers(&self, vault: &Vault) -> Vec { + Catalog::builtin() + .providers() .iter() - .copied() - .filter(|&provider| self.has_credential_material(vault, provider)) + .filter(|provider| self.has_credential_material(vault, &provider.id)) + .map(|provider| provider.id.clone()) .collect() } fn find_credential( &self, vault: &Vault, - provider: Provider, + provider: &ProviderId, usage: CredentialUsage, ) -> Result { - for credential_id in credential_ids_for(provider, usage) { - if let Some(credential) = vault_get_credential(vault, credential_id) { - return Ok(credential); + 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); + } } } - for env_var in provider.api_key_env_vars() { - if let Some(value) = self.lookup_env_or_vault(vault, env_var) { - return Ok(AuthCredential { - provider, - details: AuthDetails::ApiKey { key: value }, - }); + if let Some(catalog_provider) = Catalog::builtin().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)) + Err(ResolveError::NotConfigured(provider.clone())) } - fn has_credential_material(&self, vault: &Vault, provider: Provider) -> bool { - credential_ids_for(provider, CredentialUsage::ApiRequest) - .iter() - .any(|id| vault_get_credential(vault, id).is_some()) - || provider - .api_key_env_vars() - .iter() - .any(|env_var| self.lookup_env_or_vault(vault, env_var).is_some()) + fn has_credential_material(&self, vault: &Vault, provider: &ProviderId) -> bool { + Catalog::builtin().provider(provider).map_or_else( + || vault_get_credential(vault, provider.as_str()).is_some(), + |catalog_provider| { + catalog_provider.credentials.iter().any(|credential_ref| { + self.credential_from_ref(vault, provider, credential_ref) + .is_some() + }) + }, + ) + } + + fn credential_from_ref( + &self, + vault: &Vault, + provider: &ProviderId, + credential_ref: &CredentialRef, + ) -> Option { + match credential_ref { + CredentialRef::Credential(id) => vault_get_credential(vault, id), + CredentialRef::Env(name) => { + self.lookup_env_or_vault(vault, name) + .map(|key| AuthCredential { + provider: provider.clone(), + details: AuthDetails::ApiKey { key }, + }) + } + } } fn lookup_env_or_vault(&self, vault: &Vault, name: &str) -> Option { (self.env_lookup)(name).or_else(|| vault.get(name).map(str::to_string)) } - fn to_api_credential(&self, vault: &Vault, credential: &AuthCredential) -> ApiCredential { - let base_url = match credential.provider { - Provider::Anthropic => self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL), - Provider::OpenAi => self.lookup_env_or_vault(vault, EnvVars::OPENAI_BASE_URL), - Provider::Gemini => self.lookup_env_or_vault(vault, EnvVars::GEMINI_BASE_URL), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => None, - Provider::OpenAiCompatible => { + fn provider_base_url(&self, vault: &Vault, provider: &ProviderId) -> Option { + let env_base_url = match Provider::from_id(provider) { + Some(Provider::Anthropic) => { + self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL) + } + Some(Provider::OpenAi) => self.lookup_env_or_vault(vault, EnvVars::OPENAI_BASE_URL), + Some(Provider::Gemini) => self.lookup_env_or_vault(vault, EnvVars::GEMINI_BASE_URL), + Some(Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception) + | None => None, + Some(Provider::OpenAiCompatible) => { self.lookup_env_or_vault(vault, EnvVars::OPENAI_COMPATIBLE_BASE_URL) } }; + env_base_url.or_else(|| { + Catalog::builtin() + .provider(provider) + .and_then(|provider| provider.base_url.clone()) + }) + } + + fn resolved_extra_headers( + &self, + vault: &Vault, + provider: &ProviderId, + ) -> Result, ResolveError> { + let Some(catalog_provider) = Catalog::builtin().provider(provider) else { + return Ok(HashMap::new()); + }; + catalog_provider + .extra_headers + .iter() + .map(|(name, value_ref)| { + let value = match value_ref { + HeaderValueRef::Literal(value) => Some(value.clone()), + HeaderValueRef::Env(name) => self.lookup_env_or_vault(vault, name), + HeaderValueRef::Credential(name) => vault.get(name).map(str::to_string), + } + .ok_or_else(|| ResolveError::NotConfigured(provider.clone()))?; + Ok((name.clone(), value)) + }) + .collect() + } + + fn to_api_credential( + &self, + vault: &Vault, + credential: &AuthCredential, + ) -> Result { + let base_url = self.provider_base_url(vault, &credential.provider); match &credential.details { AuthDetails::ApiKey { key } => { - let mut cred = ApiCredential::from_api_key(credential.provider, key.clone()); + let mut cred = + ApiCredential::from_api_key(credential.provider.clone(), key.clone()); cred.base_url = base_url; - if credential.provider == Provider::OpenAi { + cred.extra_headers = self.resolved_extra_headers(vault, &credential.provider)?; + if credential.provider == Provider::OpenAi.id() { cred.org_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_ORG_ID); cred.project_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID); } - cred + Ok(cred) } AuthDetails::CodexOAuth { tokens, account_id, .. @@ -260,28 +355,29 @@ impl CredentialResolver { extra_headers.insert("ChatGPT-Account-Id".to_string(), account_id.clone()); extra_headers.insert("originator".to_string(), "fabro".to_string()); } - ApiCredential { - provider: credential.provider, + Ok(ApiCredential { + provider: credential.provider.clone(), auth_header: ApiKeyHeader::Bearer(tokens.access_token.clone()), extra_headers, base_url: Some("https://chatgpt.com/backend-api/codex".to_string()), codex_mode: true, org_id: self.lookup_env_or_vault(vault, EnvVars::OPENAI_ORG_ID), project_id: self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID), - } + }) } } } fn to_cli_credential(credential: &AuthCredential, kind: CliAgentKind) -> CliCredential { let mut env_vars = HashMap::new(); - let login_command = match (&credential.provider, &credential.details, kind) { - (Provider::OpenAi, AuthDetails::ApiKey { key }, CliAgentKind::Codex) => { + let provider = Provider::from_id(&credential.provider); + let login_command = match (provider, &credential.details, kind) { + (Some(Provider::OpenAi), AuthDetails::ApiKey { key }, CliAgentKind::Codex) => { env_vars.insert(EnvVars::OPENAI_API_KEY.to_string(), key.clone()); Some(codex_login_command(key)) } ( - Provider::OpenAi, + Some(Provider::OpenAi), AuthDetails::CodexOAuth { tokens, account_id, .. }, @@ -297,8 +393,8 @@ impl CredentialResolver { Some(codex_login_command(&tokens.access_token)) } (_, AuthDetails::ApiKey { key }, _) => { - if let Some(name) = credential.provider.api_key_env_vars().first() { - env_vars.insert((*name).to_string(), key.clone()); + if let Some(name) = primary_api_key_env_var(&credential.provider) { + env_vars.insert(name.to_string(), key.clone()); } None } @@ -320,17 +416,18 @@ impl CredentialResolver { pub async fn configured_providers_from_process_env( vault: Option<&Arc>>, -) -> Vec { +) -> Vec { match vault { Some(vault_arc) => { let resolver = CredentialResolver::new(Arc::clone(vault_arc)); let guard = vault_arc.read().await; resolver.configured_providers(&guard) } - None => Provider::ALL + None => Catalog::builtin() + .providers() .iter() - .copied() - .filter(|provider| provider_has_process_env_api_key(*provider)) + .filter(|provider| provider_has_process_env_api_key(&provider.id)) + .map(|provider| provider.id.clone()) .collect(), } } @@ -339,11 +436,25 @@ 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: Provider) -> bool { - provider - .api_key_env_vars() +fn provider_has_process_env_api_key(provider: &ProviderId) -> bool { + Catalog::builtin() + .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 primary_api_key_env_var(provider: &ProviderId) -> Option<&'static str> { + Catalog::builtin() + .provider(provider)? + .credentials .iter() - .any(|env_var| std::env::var(env_var).is_ok()) + .find_map(|credential_ref| match credential_ref { + CredentialRef::Env(name) => Some(name.as_str()), + CredentialRef::Credential(_) => None, + }) } fn codex_login_command(api_key: &str) -> String { @@ -354,22 +465,6 @@ fn codex_login_command(api_key: &str) -> String { ) } -fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&'static str] { - match (provider, usage) { - (Provider::OpenAi, CredentialUsage::CliAgent(CliAgentKind::Codex)) => { - &["openai_codex", "openai"] - } - (Provider::OpenAi, _) => &["openai", "openai_codex"], - (Provider::Anthropic, _) => &["anthropic"], - (Provider::Gemini, _) => &["gemini"], - (Provider::Kimi, _) => &["kimi"], - (Provider::Zai, _) => &["zai"], - (Provider::Minimax, _) => &["minimax"], - (Provider::Inception, _) => &["inception"], - (Provider::OpenAiCompatible, _) => &["openai_compatible"], - } -} - #[cfg(test)] mod tests { #[cfg(unix)] @@ -385,8 +480,8 @@ mod tests { fn api_key_credential(provider: Provider, key: &str) -> AuthCredential { AuthCredential { - provider, - details: AuthDetails::ApiKey { + provider: provider.id(), + details: AuthDetails::ApiKey { key: key.to_string(), }, } @@ -394,7 +489,7 @@ mod tests { fn oauth_credential(token_url: String, expires_at: chrono::DateTime) -> AuthCredential { AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "expired-access".to_string(), @@ -491,7 +586,7 @@ mod tests { assert!(matches!( err, - ResolveError::NotConfigured(Provider::Anthropic) + ResolveError::NotConfigured(provider) if provider == Provider::Anthropic.id() )); } @@ -746,7 +841,7 @@ mod tests { let vault = resolver.vault.read().await; assert_eq!(resolver.configured_providers(&vault), vec![ - Provider::OpenAi + Provider::OpenAi.id() ]); } @@ -761,7 +856,7 @@ mod tests { let vault = resolver.vault.read().await; assert_eq!(resolver.configured_providers(&vault), vec![ - Provider::OpenAi + Provider::OpenAi.id() ]); } @@ -860,15 +955,15 @@ mod tests { assert!(matches!( err, - ResolveError::RefreshTokenMissing(Provider::OpenAi) + ResolveError::RefreshTokenMissing(provider) if provider == Provider::OpenAi.id() )); } #[test] fn auth_issue_message_formats_refresh_token_missing() { let message = auth_issue_message( - Provider::OpenAi, - &ResolveError::RefreshTokenMissing(Provider::OpenAi), + &Provider::OpenAi.id(), + &ResolveError::RefreshTokenMissing(Provider::OpenAi.id()), ); assert_eq!( @@ -880,7 +975,7 @@ mod tests { #[test] fn api_credential_debug_redacts_secret_material() { let credential = ApiCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), auth_header: ApiKeyHeader::Bearer("sk-test".to_string()), extra_headers: HashMap::new(), base_url: None, diff --git a/lib/crates/fabro-auth/src/strategies/api_key.rs b/lib/crates/fabro-auth/src/strategies/api_key.rs index 5de78ea6b..0c5cb37c8 100644 --- a/lib/crates/fabro-auth/src/strategies/api_key.rs +++ b/lib/crates/fabro-auth/src/strategies/api_key.rs @@ -33,7 +33,7 @@ impl AuthStrategy for ApiKeyStrategy { async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result { match response { AuthContextResponse::ApiKey { key } => Ok(AuthCredential { - provider: self.provider, + provider: self.provider.id(), details: AuthDetails::ApiKey { key }, }), AuthContextResponse::DeviceCodeConfirmed => { diff --git a/lib/crates/fabro-auth/src/strategies/codex_device.rs b/lib/crates/fabro-auth/src/strategies/codex_device.rs index 3a7197a90..4d01aa1e9 100644 --- a/lib/crates/fabro-auth/src/strategies/codex_device.rs +++ b/lib/crates/fabro-auth/src/strategies/codex_device.rs @@ -300,7 +300,7 @@ impl AuthStrategy for CodexDeviceStrategy { .map_err(anyhow::Error::msg)?; Ok(AuthCredential { - provider: fabro_model::Provider::OpenAi, + provider: fabro_model::Provider::OpenAi.id(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: token_response.access_token, diff --git a/lib/crates/fabro-auth/src/vault_ext.rs b/lib/crates/fabro-auth/src/vault_ext.rs index 24949cc7d..b2f460040 100644 --- a/lib/crates/fabro-auth/src/vault_ext.rs +++ b/lib/crates/fabro-auth/src/vault_ext.rs @@ -1,4 +1,4 @@ -use fabro_model::Provider; +use fabro_model::ProviderId; use fabro_types::SecretMetadata; use fabro_vault::{SecretType, Vault}; @@ -25,8 +25,9 @@ pub fn vault_get_credential(vault: &Vault, id: &str) -> Option { #[must_use] pub fn vault_credentials_for_provider( vault: &Vault, - provider: Provider, + provider: impl Into, ) -> Vec<(String, AuthCredential)> { + let provider = provider.into(); vault .credential_entries() .into_iter() @@ -42,13 +43,14 @@ pub fn vault_credentials_for_provider( #[cfg(test)] mod tests { use chrono::{Duration, Utc}; + use fabro_model::Provider; use super::*; use crate::credential::{AuthDetails, OAuthConfig, OAuthTokens}; fn oauth_credential() -> AuthCredential { AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "access".to_string(), @@ -88,7 +90,7 @@ mod tests { let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_credential(&mut vault, "openai_codex", &oauth_credential()).unwrap(); vault_set_credential(&mut vault, "anthropic", &AuthCredential { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), details: AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, diff --git a/lib/crates/fabro-auth/src/vault_source.rs b/lib/crates/fabro-auth/src/vault_source.rs index 052171fc1..95ae59d82 100644 --- a/lib/crates/fabro-auth/src/vault_source.rs +++ b/lib/crates/fabro-auth/src/vault_source.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; -use fabro_model::Provider; +use fabro_model::{Catalog, ProviderId}; use fabro_vault::Vault; use tokio::sync::RwLock as AsyncRwLock; @@ -45,15 +45,15 @@ impl CredentialSource for VaultCredentialSource { let mut credentials = Vec::new(); let mut auth_issues = Vec::new(); - for provider in Provider::ALL { + for provider in Catalog::builtin().providers() { match self .resolver - .resolve(*provider, CredentialUsage::ApiRequest) + .resolve(provider.id.clone(), CredentialUsage::ApiRequest) .await { Ok(ResolvedCredential::Api(credential)) => credentials.push(credential), Ok(ResolvedCredential::Cli(_)) | Err(ResolveError::NotConfigured(_)) => {} - Err(err) => auth_issues.push((*provider, err)), + Err(err) => auth_issues.push((provider.id.clone(), err)), } } @@ -63,7 +63,7 @@ impl CredentialSource for VaultCredentialSource { }) } - async fn configured_providers(&self) -> Vec { + async fn configured_providers(&self) -> Vec { let vault = self.vault.read().await; self.resolver.configured_providers(&vault) } @@ -84,8 +84,8 @@ mod tests { fn api_key_credential(provider: Provider, key: &str) -> AuthCredential { AuthCredential { - provider, - details: AuthDetails::ApiKey { + provider: provider.id(), + details: AuthDetails::ApiKey { key: key.to_string(), }, } @@ -93,7 +93,7 @@ mod tests { fn expired_openai_credential() -> AuthCredential { AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "expired-access".to_string(), @@ -141,14 +141,14 @@ mod tests { let resolved = source.resolve().await.unwrap(); assert_eq!(resolved.credentials.len(), 1); - assert_eq!(resolved.credentials[0].provider, Provider::Anthropic); + assert_eq!(resolved.credentials[0].provider, Provider::Anthropic.id()); assert_eq!(resolved.auth_issues.len(), 1); assert!(matches!( - resolved.auth_issues[0].1, + &resolved.auth_issues[0].1, ResolveError::RefreshFailed { - provider: Provider::OpenAi, + provider, .. - } + } if provider == &Provider::OpenAi.id() )); } @@ -178,8 +178,8 @@ mod tests { VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None); assert_eq!(source.configured_providers().await, vec![ - Provider::Anthropic, - Provider::OpenAi + Provider::Anthropic.id(), + Provider::OpenAi.id() ]); } } diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 04a742a47..9d46bd8ec 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -2466,7 +2466,7 @@ client_id = "client-id" description: None, }, credential_secret_request(&AuthCredential { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), details: fabro_auth::AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 9ccfc20a4..909fc949f 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; -use fabro_model::{Catalog, Model, ModelTestMode, Provider}; +use fabro_model::{Catalog, Model, ModelTestMode, Provider, ProviderId}; use fabro_util::terminal::Styles; use futures::{StreamExt, stream}; use serde::Serialize; @@ -22,7 +22,7 @@ enum ModelTestResultKind { #[derive(Serialize)] struct ModelTestRow { model: String, - provider: Provider, + provider: ProviderId, result: ModelTestResultKind, #[serde(skip_serializing_if = "Option::is_none")] detail: Option, @@ -112,6 +112,7 @@ fn model_row(model: &Model, use_color: bool) -> Vec { model.id.clone().cell().bold(use_color), model .provider + .as_str() .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), aliases @@ -190,21 +191,21 @@ fn model_test_row_from_status(model: &Model, status: &str, result_color: Color) match result_color { Color::Green => ModelTestRow { model: model.id.clone(), - provider: model.provider, + provider: model.provider.clone(), result: ModelTestResultKind::Pass, detail: None, error: None, }, Color::Yellow => ModelTestRow { model: model.id.clone(), - provider: model.provider, + provider: model.provider.clone(), result: ModelTestResultKind::Skip, detail: Some(trimmed.to_string()), error: None, }, _ => ModelTestRow { model: model.id.clone(), - provider: model.provider, + provider: model.provider.clone(), result: ModelTestResultKind::Fail, detail: None, error: Some( @@ -307,7 +308,7 @@ async fn test_models_via_server( for info in &unconfigured { skipped += 1; - let provider_name = info.provider.display_name().to_string(); + let provider_name = Provider::display_name_for_id(&info.provider); if !skipped_providers.contains(&provider_name) { skipped_providers.push(provider_name); } @@ -474,31 +475,31 @@ mod tests { fn test_model_json(id: &str, provider: Provider) -> serde_json::Value { serde_json::to_value(Model { - id: id.to_string(), - provider, - family: "test".to_string(), - display_name: format!("{id} display"), - limits: ModelLimits { + id: id.to_string(), + provider: provider.id(), + family: "test".to_string(), + display_name: format!("{id} display"), + limits: ModelLimits { context_window: 128_000, max_output: Some(4096), }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { + training: None, + knowledge_cutoff: None, + features: ModelFeatures { tools: true, vision: false, reasoning: false, effort: false, }, - costs: ModelCosts { + costs: ModelCosts { input_cost_per_mtok: Some(1.0), output_cost_per_mtok: Some(2.0), cache_input_cost_per_mtok: None, }, estimated_output_tps: Some(100.0), - aliases: vec!["tm".to_string()], - default: false, - configured: false, + aliases: vec!["tm".to_string()], + default: false, + configured: false, }) .unwrap() } @@ -689,7 +690,7 @@ mod tests { mock.assert_async().await; assert_eq!(models.len(), 1); assert_eq!(models[0].id, "test-model"); - assert_eq!(models[0].provider, Provider::Anthropic); + assert_eq!(models[0].provider, Provider::Anthropic.id()); } #[tokio::test] diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 5eb37ff79..b2d5ea08f 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -571,7 +571,7 @@ mod tests { agent_event(stage, AgentEvent::AssistantMessage { text: "done".into(), model: ModelRef { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model_id: model.into(), speed: None, }, diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index f25079e6a..ca20d7a47 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -972,7 +972,7 @@ mod tests { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), details: AuthDetails::ApiKey { key: "vault-key".to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs index 9c9092b47..800e5663d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs @@ -31,7 +31,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &st .set( "openai", &serde_json::to_string(&AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: api_key.to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index b9315e2e8..4525d2533 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -43,29 +43,29 @@ fn bare() { exit_code: 0 ----- stdout ----- MODEL PROVIDER ALIASES CONTEXT COST SPEED - claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s + claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s - claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s - gpt-5.2 openai gpt5 1m $1.8 / $14.0 65 tok/s + gemini-3-flash-preview gemini gemini-flash 1m $0.5 / $3.0 150 tok/s + gemini-3.1-flash-lite-preview gemini gemini-flash-lite 1m $0.2 / $1.5 200 tok/s + gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s + gemini-3.1-pro-preview-customtools gemini gemini-customtools 1m $2.0 / $12.0 85 tok/s + mercury-2 inception mercury 131k $0.2 / $0.8 1000 tok/s + kimi-k2.5 kimi kimi 262k $0.6 / $3.0 50 tok/s + minimax-m2.5 minimax minimax 197k $0.3 / $1.2 45 tok/s gpt-5-mini openai gpt5-mini 1m $0.2 / $2.0 70 tok/s + gpt-5.2 openai gpt5 1m $1.8 / $14.0 65 tok/s gpt-5.2-codex openai 1m $1.8 / $14.0 100 tok/s gpt-5.3-codex openai codex 1m $1.8 / $14.0 100 tok/s gpt-5.3-codex-spark openai codex-spark 131k - / - 1000 tok/s gpt-5.4 openai gpt54, gpt-54 1m $2.5 / $15.0 70 tok/s + gpt-5.4-mini openai gpt54-mini, gpt-54-mini 400k $0.8 / $4.5 140 tok/s + gpt-5.4-pro openai gpt54-pro, gpt-54-pro 1m $30.0 / $180.0 20 tok/s gpt-5.5 openai gpt55, gpt-55 1m $5.0 / $30.0 70 tok/s gpt-5.5-pro openai gpt55-pro, gpt-55-pro 1m $30.0 / $180.0 20 tok/s - gpt-5.4-pro openai gpt54-pro, gpt-54-pro 1m $30.0 / $180.0 20 tok/s - gpt-5.4-mini openai gpt54-mini, gpt-54-mini 400k $0.8 / $4.5 140 tok/s - gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s - gemini-3.1-pro-preview-customtools gemini gemini-customtools 1m $2.0 / $12.0 85 tok/s - gemini-3-flash-preview gemini gemini-flash 1m $0.5 / $3.0 150 tok/s - gemini-3.1-flash-lite-preview gemini gemini-flash-lite 1m $0.2 / $1.5 200 tok/s - kimi-k2.5 kimi kimi 262k $0.6 / $3.0 50 tok/s - glm-4.7 zai glm, glm4 203k $0.6 / $2.2 100 tok/s - minimax-m2.5 minimax minimax 197k $0.3 / $1.2 45 tok/s - mercury-2 inception mercury 131k $0.2 / $0.8 1000 tok/s + glm-4.7 zai glm, glm4 203k $0.6 / $2.2 100 tok/s ----- stderr ----- "); } @@ -80,29 +80,29 @@ fn list() { exit_code: 0 ----- stdout ----- MODEL PROVIDER ALIASES CONTEXT COST SPEED - claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s + claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s - claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s - gpt-5.2 openai gpt5 1m $1.8 / $14.0 65 tok/s + gemini-3-flash-preview gemini gemini-flash 1m $0.5 / $3.0 150 tok/s + gemini-3.1-flash-lite-preview gemini gemini-flash-lite 1m $0.2 / $1.5 200 tok/s + gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s + gemini-3.1-pro-preview-customtools gemini gemini-customtools 1m $2.0 / $12.0 85 tok/s + mercury-2 inception mercury 131k $0.2 / $0.8 1000 tok/s + kimi-k2.5 kimi kimi 262k $0.6 / $3.0 50 tok/s + minimax-m2.5 minimax minimax 197k $0.3 / $1.2 45 tok/s gpt-5-mini openai gpt5-mini 1m $0.2 / $2.0 70 tok/s + gpt-5.2 openai gpt5 1m $1.8 / $14.0 65 tok/s gpt-5.2-codex openai 1m $1.8 / $14.0 100 tok/s gpt-5.3-codex openai codex 1m $1.8 / $14.0 100 tok/s gpt-5.3-codex-spark openai codex-spark 131k - / - 1000 tok/s gpt-5.4 openai gpt54, gpt-54 1m $2.5 / $15.0 70 tok/s + gpt-5.4-mini openai gpt54-mini, gpt-54-mini 400k $0.8 / $4.5 140 tok/s + gpt-5.4-pro openai gpt54-pro, gpt-54-pro 1m $30.0 / $180.0 20 tok/s gpt-5.5 openai gpt55, gpt-55 1m $5.0 / $30.0 70 tok/s gpt-5.5-pro openai gpt55-pro, gpt-55-pro 1m $30.0 / $180.0 20 tok/s - gpt-5.4-pro openai gpt54-pro, gpt-54-pro 1m $30.0 / $180.0 20 tok/s - gpt-5.4-mini openai gpt54-mini, gpt-54-mini 400k $0.8 / $4.5 140 tok/s - gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s - gemini-3.1-pro-preview-customtools gemini gemini-customtools 1m $2.0 / $12.0 85 tok/s - gemini-3-flash-preview gemini gemini-flash 1m $0.5 / $3.0 150 tok/s - gemini-3.1-flash-lite-preview gemini gemini-flash-lite 1m $0.2 / $1.5 200 tok/s - kimi-k2.5 kimi kimi 262k $0.6 / $3.0 50 tok/s - glm-4.7 zai glm, glm4 203k $0.6 / $2.2 100 tok/s - minimax-m2.5 minimax minimax 197k $0.3 / $1.2 45 tok/s - mercury-2 inception mercury 131k $0.2 / $0.8 1000 tok/s + glm-4.7 zai glm, glm4 203k $0.6 / $2.2 100 tok/s ----- stderr ----- "); } @@ -117,11 +117,11 @@ fn list_provider() { exit_code: 0 ----- stdout ----- MODEL PROVIDER ALIASES CONTEXT COST SPEED - claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s + claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s - claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s - claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s + claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s ----- stderr ----- "); } @@ -136,8 +136,8 @@ fn list_query() { exit_code: 0 ----- stdout ----- MODEL PROVIDER ALIASES CONTEXT COST SPEED - claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s - claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s ----- stderr ----- "); } @@ -169,8 +169,8 @@ fn list_query_case_insensitive() { exit_code: 0 ----- stdout ----- MODEL PROVIDER ALIASES CONTEXT COST SPEED - claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s - claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-6 anthropic 1m $5.0 / $25.0 25 tok/s + claude-opus-4-7 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 4b3c1e055..a3a64562f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -91,7 +91,7 @@ fn seed_anthropic_vault(storage_dir: &std::path::Path, base_url: &str) { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), details: AuthDetails::ApiKey { key: "vault-anthropic-key".to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/workflow/acp.rs b/lib/crates/fabro-cli/tests/it/workflow/acp.rs index dffd20e49..3c3dbef97 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/acp.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/acp.rs @@ -164,7 +164,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path) { .set( "openai", &serde_json::to_string(&AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: "test-openai-key".to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/workflow/hooks.rs b/lib/crates/fabro-cli/tests/it/workflow/hooks.rs index 5245d1a7f..491a707c2 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/hooks.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/hooks.rs @@ -100,7 +100,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &st .set( "openai", &serde_json::to_string(&AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: api_key.to_string(), }, diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index 1eb3b7247..7e8290f6a 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -21,6 +21,7 @@ anyhow.workspace = true clap = { workspace = true, optional = true } chrono.workspace = true fabro-macros = { path = "../fabro-macros" } +fabro-model = { path = "../fabro-model" } fabro-options-metadata.workspace = true fabro-proc = { path = "../fabro-proc" } fabro-static.workspace = true diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index 94eb345e9..dd58fe934 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -1,6 +1,5 @@ use std::collections::{BTreeMap, HashMap}; -use chrono::NaiveDate; use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::{ AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, @@ -80,7 +79,6 @@ impl_combine_or_option!( RunMode, GithubIntegrationStrategy, LogDestination, - NaiveDate, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy, diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index 5a347732f..396f1e650 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -28,14 +28,12 @@ use std::collections::{BTreeMap, HashMap}; -use chrono::NaiveDate; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use fabro_model::catalog::deserialize_knowledge_cutoff; +pub use fabro_model::{CredentialRef, CredentialRefParseError, HeaderValueRef}; +use serde::{Deserialize, Serialize}; use super::maps::MergeMap; -const CREDENTIAL_REF_PREFIX: &str = "credential:"; -const ENV_REF_PREFIX: &str = "env:"; - /// Top-level `[llm]` settings layer. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] @@ -93,15 +91,18 @@ pub struct ModelSettings { pub display_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub family: Option, - /// Knowledge cutoff as an exact `YYYY-MM-DD` date. Lower-precision labels - /// (e.g. `May 2025`) migrate to the first of the month (`2025-05-01`); - /// presentation can render lower precision. + /// Training data cutoff label. Built-ins keep the exact public string + /// already exposed by the model API. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub training: Option, + /// Public knowledge cutoff label. Built-ins keep values such as + /// `"May 2025"` exactly; bare TOML dates are normalized to `YYYY-MM-DD`. #[serde( default, deserialize_with = "deserialize_knowledge_cutoff", skip_serializing_if = "Option::is_none" )] - pub knowledge_cutoff: Option, + pub knowledge_cutoff: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub default: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -183,279 +184,6 @@ pub struct CostRates { pub cache_input_cost_per_mtok: Option, } -/// Accept either a TOML local-date (`2025-01-01` β†’ `Datetime`) or a -/// `YYYY-MM-DD` string for `knowledge_cutoff`. JSON has no native date -/// literal; settings authors use the bare TOML date form, but JSON loaders -/// (e.g. defaults bundled as JSON) supply a string. -fn deserialize_knowledge_cutoff<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error; - use toml::value::Datetime; - - #[derive(Deserialize)] - #[serde(untagged)] - enum Either { - Toml(Datetime), - Str(String), - } - - let value = Option::::deserialize(deserializer)?; - match value { - None => Ok(None), - Some(Either::Str(s)) => NaiveDate::parse_from_str(&s, "%Y-%m-%d") - .map(Some) - .map_err(D::Error::custom), - Some(Either::Toml(dt)) => { - let date = dt - .date - .ok_or_else(|| D::Error::custom("knowledge_cutoff requires a date component"))?; - NaiveDate::from_ymd_opt(date.year.into(), date.month.into(), date.day.into()) - .ok_or_else(|| D::Error::custom("knowledge_cutoff is not a valid calendar date")) - .map(Some) - } - } -} - -// --------------------------------------------------------------------------- -// CredentialRef β€” typed credential reference -// --------------------------------------------------------------------------- - -/// A typed credential reference. Literal secret strings are rejected at -/// deserialization so settings never carry a successful "secret string" -/// representation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(into = "String", try_from = "String")] -pub enum CredentialRef { - /// Structured credential stored in `fabro-vault` keyed by ``. - Credential(String), - /// Process environment variable ``. Falls back to a raw vault - /// secret with the same name when the env var is unset. - Env(String), -} - -impl std::fmt::Display for CredentialRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Display deliberately writes only the typed reference form, never - // any resolved secret value. Env names and credential IDs are not - // themselves secret. - match self { - Self::Credential(id) => write!(f, "{CREDENTIAL_REF_PREFIX}{id}"), - Self::Env(name) => write!(f, "{ENV_REF_PREFIX}{name}"), - } - } -} - -impl From for String { - fn from(value: CredentialRef) -> Self { - value.to_string() - } -} - -/// Error returned when a credential string is neither `credential:` nor -/// `env:`. Literal secret strings always fall into this branch and -/// fail deserialization β€” by design. Variants deliberately never carry the -/// rejected input, since it could be a literal secret. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CredentialRefParseError(CredentialRefParseErrorKind); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CredentialRefParseErrorKind { - MissingCredentialId, - MissingEnvName, - InvalidForm, -} - -impl std::fmt::Display for CredentialRefParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.0 { - CredentialRefParseErrorKind::MissingCredentialId => { - f.write_str("credential reference is missing an ID after `credential:`") - } - CredentialRefParseErrorKind::MissingEnvName => { - f.write_str("credential reference is missing a name after `env:`") - } - CredentialRefParseErrorKind::InvalidForm => f.write_str( - "credential reference must be `credential:` or `env:`; literal secret strings are rejected", - ), - } - } -} - -impl std::error::Error for CredentialRefParseError {} - -impl std::str::FromStr for CredentialRef { - type Err = CredentialRefParseError; - - fn from_str(s: &str) -> Result { - if let Some(id) = s.strip_prefix(CREDENTIAL_REF_PREFIX) { - if id.is_empty() { - return Err(CredentialRefParseError( - CredentialRefParseErrorKind::MissingCredentialId, - )); - } - return Ok(Self::Credential(id.to_string())); - } - if let Some(name) = s.strip_prefix(ENV_REF_PREFIX) { - if name.is_empty() { - return Err(CredentialRefParseError( - CredentialRefParseErrorKind::MissingEnvName, - )); - } - return Ok(Self::Env(name.to_string())); - } - Err(CredentialRefParseError( - CredentialRefParseErrorKind::InvalidForm, - )) - } -} - -impl TryFrom for CredentialRef { - type Error = CredentialRefParseError; - - fn try_from(value: String) -> Result { - value.parse() - } -} - -// --------------------------------------------------------------------------- -// HeaderValueRef - typed extra header value -// --------------------------------------------------------------------------- - -/// A typed provider extra-header value. -/// -/// Literal values are intended for non-secret routing metadata. Secret-bearing -/// values must use `env` or `credential` references so settings never need to -/// carry raw API keys as successful values. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum HeaderValueRef { - Literal(String), - Env(String), - Credential(String), -} - -impl Serialize for HeaderValueRef { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - use serde::ser::SerializeMap; - - let mut map = serializer.serialize_map(Some(1))?; - match self { - Self::Literal(value) => map.serialize_entry("literal", value)?, - Self::Env(value) => map.serialize_entry("env", value)?, - Self::Credential(value) => map.serialize_entry("credential", value)?, - } - map.end() - } -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum HeaderValueRefInput { - Table(HeaderValueRefSerde), - BareString(String), -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -struct HeaderValueRefSerde { - #[serde(default)] - literal: Option, - #[serde(default)] - env: Option, - #[serde(default)] - credential: Option, -} - -impl<'de> Deserialize<'de> for HeaderValueRef { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - use serde::de::Error as _; - - match HeaderValueRefInput::deserialize(deserializer)? { - HeaderValueRefInput::Table(value) => value.try_into().map_err(D::Error::custom), - HeaderValueRefInput::BareString(value) => { - drop(value); - Err(D::Error::custom(HeaderValueRefParseError::WrongFieldCount)) - } - } - } -} - -impl TryFrom for HeaderValueRef { - type Error = HeaderValueRefParseError; - - fn try_from(value: HeaderValueRefSerde) -> Result { - let populated = [ - value.literal.as_ref(), - value.env.as_ref(), - value.credential.as_ref(), - ] - .into_iter() - .flatten() - .count(); - - if populated != 1 { - return Err(HeaderValueRefParseError::WrongFieldCount); - } - - if let Some(value) = value.literal { - if value.is_empty() { - return Err(HeaderValueRefParseError::EmptyValue); - } - return Ok(Self::Literal(value)); - } - if let Some(value) = value.env { - if value.is_empty() { - return Err(HeaderValueRefParseError::EmptyValue); - } - return Ok(Self::Env(value)); - } - if let Some(value) = value.credential { - if value.is_empty() { - return Err(HeaderValueRefParseError::EmptyValue); - } - return Ok(Self::Credential(value)); - } - - unreachable!("populated field count was already checked"); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HeaderValueRefParseError { - WrongFieldCount, - EmptyValue, -} - -impl std::fmt::Display for HeaderValueRefParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::WrongFieldCount => f.write_str( - "header value must be a table with exactly one of `literal`, `env`, or `credential`; bare strings are rejected", - ), - Self::EmptyValue => f.write_str("header value reference must not be empty"), - } - } -} - -impl std::error::Error for HeaderValueRefParseError {} - -impl std::fmt::Display for HeaderValueRef { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Literal(_) => f.write_str("literal:"), - Self::Env(name) => write!(f, "env:{name}"), - Self::Credential(id) => write!(f, "credential:{id}"), - } - } -} - #[cfg(test)] mod tests { use std::str::FromStr; @@ -770,6 +498,7 @@ provider = "kimi" api_id = "kimi-k2.5" display_name = "Kimi K2.5" family = "kimi" +training = "2025-01-01" knowledge_cutoff = 2025-01-01 default = true enabled = true @@ -797,10 +526,8 @@ cache_input_cost_per_mtok = 0.15 assert_eq!(m.api_id.as_deref(), Some("kimi-k2.5")); assert_eq!(m.display_name.as_deref(), Some("Kimi K2.5")); assert_eq!(m.family.as_deref(), Some("kimi")); - assert_eq!( - m.knowledge_cutoff, - Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()) - ); + assert_eq!(m.training.as_deref(), Some("2025-01-01")); + assert_eq!(m.knowledge_cutoff.as_deref(), Some("2025-01-01")); assert_eq!(m.default, Some(true)); assert_eq!(m.enabled, Some(true)); assert_eq!(m.aliases.as_deref(), Some(&["kimi".to_string()][..])); @@ -823,6 +550,19 @@ cache_input_cost_per_mtok = 0.15 assert!(costs.speed.is_none()); } + #[test] + fn parses_knowledge_cutoff_display_label() { + let toml = r#" +[models."claude-opus-4-7"] +provider = "anthropic" +knowledge_cutoff = "May 2025" +"#; + let layer: LlmLayer = toml::from_str(toml).unwrap(); + let m = layer.models.get("claude-opus-4-7").unwrap(); + + assert_eq!(m.knowledge_cutoff.as_deref(), Some("May 2025")); + } + #[test] fn parses_controls_and_per_speed_costs() { let toml = r#" diff --git a/lib/crates/fabro-dev/tests/it/policy.rs b/lib/crates/fabro-dev/tests/it/policy.rs index 86f4f593c..da71eccaf 100644 --- a/lib/crates/fabro-dev/tests/it/policy.rs +++ b/lib/crates/fabro-dev/tests/it/policy.rs @@ -22,6 +22,8 @@ use crate::workspace_root; const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[ // The bootstrap module itself. "lib/crates/fabro-model/src/bootstrap_catalog", + // Public module declaration for the bootstrap hatch. + "lib/crates/fabro-model/src/lib.rs", // Install / first-run / API-key validation flows that legitimately need // a built-in catalog before any project settings have been loaded. "lib/crates/fabro-install/", diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 18711d952..5910d6c9a 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -4,17 +4,12 @@ use std::sync::Arc; use fabro_auth::{ApiCredential, ApiKeyHeader, CredentialSource}; use tracing::debug; +use crate::adapter_registry::{AdapterConfig, factory_for}; use crate::error::Error; use crate::middleware::{Middleware, NextFn, NextStreamFn}; use crate::provider::{ProviderAdapter, StreamEventStream}; -use crate::providers; use crate::types::{Request, Response}; -const KIMI_BASE_URL: &str = "https://api.moonshot.ai/v1"; -const ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; -const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1"; -const INCEPTION_BASE_URL: &str = "https://api.inceptionlabs.ai/v1"; - /// The core client that routes requests to provider adapters (Section 2.2, 3). #[derive(Clone)] pub struct Client { @@ -65,107 +60,35 @@ impl Client { }; for credential in credentials { - let auth_value = auth_value(&credential.auth_header); - match credential.provider { - fabro_model::Provider::Anthropic => { - let mut adapter = providers::AnthropicAdapter::new(auth_value); - if let Some(base_url) = credential.base_url { - adapter = adapter.with_base_url(base_url); - } - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::OpenAi => { - let mut adapter = providers::OpenAiAdapter::new(auth_value); - if let Some(base_url) = credential.base_url { - adapter = adapter.with_base_url(base_url); - } - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - if credential.codex_mode { - adapter = adapter.with_codex_mode(); - } - if let Some(org_id) = credential.org_id { - adapter = adapter.with_org_id(org_id); - } - if let Some(project_id) = credential.project_id { - adapter = adapter.with_project_id(project_id); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::Gemini => { - let mut adapter = providers::GeminiAdapter::new(auth_value); - if let Some(base_url) = credential.base_url { - adapter = adapter.with_base_url(base_url); - } - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::Kimi => { - let mut adapter = providers::OpenAiCompatibleAdapter::new( - auth_value, - credential - .base_url - .unwrap_or_else(|| KIMI_BASE_URL.to_string()), - ) - .with_name("kimi"); - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::Zai => { - let mut adapter = providers::OpenAiCompatibleAdapter::new( - auth_value, - credential - .base_url - .unwrap_or_else(|| ZAI_BASE_URL.to_string()), - ) - .with_name("zai"); - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::Minimax => { - let mut adapter = providers::OpenAiCompatibleAdapter::new( - auth_value, - credential - .base_url - .unwrap_or_else(|| MINIMAX_BASE_URL.to_string()), - ) - .with_name("minimax"); - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::Inception => { - let mut adapter = providers::OpenAiCompatibleAdapter::new( - auth_value, - credential - .base_url - .unwrap_or_else(|| INCEPTION_BASE_URL.to_string()), - ) - .with_name("inception"); - if !credential.extra_headers.is_empty() { - adapter = adapter.with_default_headers(credential.extra_headers); - } - client.register_provider(Arc::new(adapter)).await?; - } - fabro_model::Provider::OpenAiCompatible => { - return Err(Error::Configuration { - message: "Provider::OpenAiCompatible is not supported by from_credentials" - .to_string(), - source: None, - }); - } - } + let provider_id = credential.provider.clone(); + let Some(provider) = fabro_model::Catalog::builtin().provider(&provider_id) else { + return Err(Error::Configuration { + message: format!( + "Provider \"{provider_id}\" is not supported by credential-only registration" + ), + source: None, + }); + }; + let Some(factory) = factory_for(&provider.adapter) else { + return Err(Error::Configuration { + message: format!( + "Provider \"{provider_id}\" uses unsupported adapter \"{}\"", + provider.adapter + ), + source: None, + }); + }; + + let adapter = factory(AdapterConfig { + provider_id: provider.id.to_string(), + auth_header: credential.auth_header, + base_url: credential.base_url.or_else(|| provider.base_url.clone()), + extra_headers: credential.extra_headers, + codex_mode: credential.codex_mode, + org_id: credential.org_id, + project_id: credential.project_id, + }); + client.register_provider(adapter).await?; } debug!( @@ -314,6 +237,12 @@ impl Client { .collect() } + /// Check whether a provider adapter is registered. + #[must_use] + pub fn has_provider(&self, name: &str) -> bool { + self.providers.contains_key(name) + } + /// Get the default provider name. #[must_use] pub fn default_provider(&self) -> Option<&str> { @@ -432,10 +361,10 @@ mod tests { }) } - async fn configured_providers(&self) -> Vec { + async fn configured_providers(&self) -> Vec { self.credentials .iter() - .map(|credential| credential.provider) + .map(|credential| credential.provider.clone()) .collect() } } @@ -498,7 +427,7 @@ mod tests { async fn from_credentials_registers_multiple_providers() { let client = Client::from_credentials(vec![ ApiCredential { - provider: fabro_model::Provider::Anthropic, + provider: fabro_model::Provider::Anthropic.id(), auth_header: ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "anthropic-key".to_string(), @@ -510,7 +439,7 @@ mod tests { project_id: None, }, ApiCredential { - provider: fabro_model::Provider::OpenAi, + provider: fabro_model::Provider::OpenAi.id(), auth_header: ApiKeyHeader::Bearer("openai-key".to_string()), extra_headers: HashMap::new(), base_url: None, @@ -531,7 +460,7 @@ 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, + provider: fabro_model::Provider::Kimi.id(), auth_header: ApiKeyHeader::Bearer("kimi-key".to_string()), extra_headers: HashMap::new(), base_url: None, @@ -546,11 +475,36 @@ mod tests { assert_eq!(client.default_provider(), Some("kimi")); } + #[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: ApiKeyHeader::Bearer("venice-key".to_string()), + extra_headers: HashMap::new(), + base_url: None, + codex_mode: false, + org_id: None, + project_id: None, + }]) + .await; + let Err(err) = result else { + panic!("custom provider credentials should fail without a registered adapter"); + }; + + assert!(matches!( + err, + Error::Configuration { + ref message, + .. + } if message == "Provider \"venice\" is not supported by credential-only registration" + )); + } + #[tokio::test] async fn from_source_registers_provider_from_resolved_credentials() { let source = StubSource { credentials: vec![ApiCredential { - provider: fabro_model::Provider::Anthropic, + provider: fabro_model::Provider::Anthropic.id(), auth_header: ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "anthropic-key".to_string(), diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index 3304d9d13..c67ffba5f 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/fabro-llm/src/model_test.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use std::time::Duration; +use fabro_model::Model; pub use fabro_model::ModelTestMode; -use fabro_model::{Model, Provider}; use strum::IntoStaticStr; use tokio::time; @@ -54,18 +54,18 @@ pub async fn run_model_test( } async fn run_basic_test(info: &Model, client: Arc) -> ModelTestOutcome { - run_basic_model_probe(&info.id, info.provider, client).await + run_basic_model_probe(&info.id, &info.provider, client).await } /// Run the cheap single-prompt model availability probe without requiring a /// catalog-backed [`Model`]. pub async fn run_basic_model_probe( model_id: &str, - provider: Provider, + provider: impl ToString, client: Arc, ) -> ModelTestOutcome { let params = GenerateParams::new(model_id, client) - .provider(<&'static str>::from(provider)) + .provider(provider.to_string()) .prompt("Say OK") .max_tokens(16); @@ -133,7 +133,7 @@ fn build_deep_test_params(info: &Model, client: Arc) -> Option::from(info.provider)) + .provider(info.provider.to_string()) .prompt( "Use the add tool twice: first add 15 and 27, then add that result to 42. \ Finally, tell me whether the grand total is even or odd and why.", @@ -177,7 +177,7 @@ mod tests { fn test_model_with(features: ModelFeatures) -> Model { Model { id: "test-model".to_string(), - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), family: "test".to_string(), display_name: "Test Model".to_string(), limits: ModelLimits { diff --git a/lib/crates/fabro-model/Cargo.toml b/lib/crates/fabro-model/Cargo.toml index 70fc3a44c..8dfb3617f 100644 --- a/lib/crates/fabro-model/Cargo.toml +++ b/lib/crates/fabro-model/Cargo.toml @@ -14,9 +14,12 @@ workspace = true [dependencies] fabro-static.workspace = true +rust-embed.workspace = true serde.workspace = true serde_json.workspace = true strum.workspace = true +thiserror.workspace = true +toml.workspace = true [dev-dependencies] insta.workspace = true diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index 10b97d564..74682a62b 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; -use crate::{Model, Provider}; +use crate::{Model, Provider, ProviderId}; const TOKENS_PER_MTOK: i128 = 1_000_000; const ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR: i64 = 6; @@ -118,7 +118,7 @@ pub enum Speed { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ModelRef { - pub provider: Provider, + pub provider: ProviderId, pub model_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub speed: Option, @@ -409,7 +409,7 @@ impl Model { #[must_use] pub fn billing_model_ref(&self, speed: Option) -> ModelRef { ModelRef { - provider: self.provider, + provider: self.provider.clone(), model_id: self.id.clone(), speed, } @@ -427,7 +427,9 @@ impl Model { .cache_input_cost_per_mtok .map(PricePerMTok::from_usd); - let (input, output, cached_input) = match (self.provider, speed) { + let provider = self.builtin_provider()?; + + let (input, output, cached_input) = match (provider, speed) { (Provider::Anthropic, Some(Speed::Fast)) if self.id == "claude-opus-4-7" || self.id == "claude-opus-4-6" => { @@ -452,7 +454,7 @@ impl Model { _ => return None, }; - let policy = match self.provider { + let policy = match provider { Provider::OpenAi => ModelPricingPolicy::OpenAi(OpenAiModelPricing { input, cached_input, @@ -632,7 +634,7 @@ mod tests { input: ModelBillingInput { usage: ModelUsage { model: ModelRef { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model_id: "gpt-5.4".to_string(), speed: None, }, @@ -767,7 +769,7 @@ mod tests { fn openai_pricing_bills_cached_input_and_reasoning_output() { let pricing = ModelPricing { model: ModelRef { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model_id: "gpt-5.4".to_string(), speed: None, }, @@ -820,7 +822,7 @@ mod tests { fn anthropic_billing_supports_distinct_cache_write_buckets() { let pricing = ModelPricing { model: ModelRef { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), model_id: "claude-opus-4-6".to_string(), speed: Some(Speed::Fast), }, @@ -866,7 +868,7 @@ mod tests { fn gemini_billing_requires_storage_pricing_when_storage_facts_exist() { let pricing = ModelPricing { model: ModelRef { - provider: Provider::Gemini, + provider: Provider::Gemini.id(), model_id: "gemini-3.1-pro-preview".to_string(), speed: None, }, diff --git a/lib/crates/fabro-model/src/bootstrap_catalog.rs b/lib/crates/fabro-model/src/bootstrap_catalog.rs new file mode 100644 index 000000000..4b150e0eb --- /dev/null +++ b/lib/crates/fabro-model/src/bootstrap_catalog.rs @@ -0,0 +1,22 @@ +//! Install/API-key validation access to the built-in catalog. +//! +//! Runtime request-serving paths should use a resolved catalog threaded +//! through their state. This module is the explicit hatch for setup flows that +//! need built-in provider/model metadata before project settings are loaded. + +use crate::Catalog; + +#[must_use] +pub fn catalog() -> &'static Catalog { + Catalog::builtin() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bootstrap_catalog_is_the_builtin_catalog() { + assert!(std::ptr::eq(catalog(), Catalog::builtin())); + } +} diff --git a/lib/crates/fabro-model/src/catalog.json b/lib/crates/fabro-model/src/catalog.json deleted file mode 100644 index 8636473bc..000000000 --- a/lib/crates/fabro-model/src/catalog.json +++ /dev/null @@ -1,400 +0,0 @@ -[ - { - "id": "claude-opus-4-7", - "provider": "anthropic", - "family": "claude-4", - "display_name": "Claude Opus 4.7", - "limits": { "context_window": 1000000, "max_output": 128000 }, - "training": "2025-08-01", - "knowledge_cutoff": "May 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 5.0, - "output_cost_per_mtok": 25.0, - "cache_input_cost_per_mtok": 0.50 - }, - "estimated_output_tps": 25, - "aliases": ["opus", "claude-opus"] - }, - { - "id": "claude-opus-4-6", - "provider": "anthropic", - "family": "claude-4", - "display_name": "Claude Opus 4.6", - "limits": { "context_window": 1000000, "max_output": 128000 }, - "training": "2025-08-01", - "knowledge_cutoff": "May 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 5.0, - "output_cost_per_mtok": 25.0, - "cache_input_cost_per_mtok": 0.50 - }, - "estimated_output_tps": 25, - "aliases": [] - }, - { - "id": "claude-sonnet-4-5", - "provider": "anthropic", - "family": "claude-4", - "display_name": "Claude Sonnet 4.5", - "limits": { "context_window": 200000, "max_output": 64000 }, - "training": "2025-08-01", - "knowledge_cutoff": "May 2025", - "features": { "tools": true, "vision": true, "reasoning": true }, - "costs": { - "input_cost_per_mtok": 3.0, - "output_cost_per_mtok": 15.0, - "cache_input_cost_per_mtok": 0.30 - }, - "estimated_output_tps": 50, - "aliases": [] - }, - { - "id": "claude-sonnet-4-6", - "provider": "anthropic", - "family": "claude-4", - "display_name": "Claude Sonnet 4.6", - "limits": { "context_window": 200000, "max_output": 64000 }, - "training": "2025-08-01", - "knowledge_cutoff": "May 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 3.0, - "output_cost_per_mtok": 15.0, - "cache_input_cost_per_mtok": 0.30 - }, - "estimated_output_tps": 50, - "aliases": ["sonnet", "claude-sonnet"], - "default": true - }, - { - "id": "claude-haiku-4-5", - "provider": "anthropic", - "family": "claude-4", - "display_name": "Claude Haiku 4.5", - "limits": { "context_window": 200000, "max_output": 8192 }, - "training": "2025-08-01", - "knowledge_cutoff": "May 2025", - "features": { "tools": true, "vision": true, "reasoning": false }, - "costs": { - "input_cost_per_mtok": 0.8, - "output_cost_per_mtok": 4.0, - "cache_input_cost_per_mtok": 0.08 - }, - "estimated_output_tps": 100, - "aliases": ["haiku", "claude-haiku"] - }, - { - "id": "gpt-5.2", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.2", - "limits": { "context_window": 1047576, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 1.75, - "output_cost_per_mtok": 14.0, - "cache_input_cost_per_mtok": 0.175 - }, - "estimated_output_tps": 65, - "aliases": ["gpt5"] - }, - { - "id": "gpt-5-mini", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5 Mini", - "limits": { "context_window": 1047576, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 0.25, - "output_cost_per_mtok": 2.0, - "cache_input_cost_per_mtok": 0.025 - }, - "estimated_output_tps": 70, - "aliases": ["gpt5-mini"] - }, - { - "id": "gpt-5.2-codex", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.2 Codex", - "limits": { "context_window": 1047576, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 1.75, - "output_cost_per_mtok": 14.0, - "cache_input_cost_per_mtok": 0.175 - }, - "estimated_output_tps": 100, - "aliases": [] - }, - { - "id": "gpt-5.3-codex", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.3 Codex", - "limits": { "context_window": 1047576, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 1.75, - "output_cost_per_mtok": 14.0, - "cache_input_cost_per_mtok": 0.175 - }, - "estimated_output_tps": 100, - "aliases": ["codex"] - }, - { - "id": "gpt-5.3-codex-spark", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.3 Codex Spark", - "limits": { "context_window": 131072, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": false, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": null, - "output_cost_per_mtok": null, - "cache_input_cost_per_mtok": null - }, - "estimated_output_tps": 1000, - "aliases": ["codex-spark"] - }, - { - "id": "gpt-5.4", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.4", - "limits": { "context_window": 1047576, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 2.5, - "output_cost_per_mtok": 15.0, - "cache_input_cost_per_mtok": 0.25 - }, - "estimated_output_tps": 70, - "aliases": ["gpt54", "gpt-54"], - "default": true - }, - { - "id": "gpt-5.5", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.5", - "limits": { "context_window": 1050000, "max_output": 128000 }, - "training": "2025-12-01", - "knowledge_cutoff": "December 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 5.0, - "output_cost_per_mtok": 30.0, - "cache_input_cost_per_mtok": 0.50 - }, - "estimated_output_tps": 70, - "aliases": ["gpt55", "gpt-55"] - }, - { - "id": "gpt-5.5-pro", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.5 Pro", - "limits": { "context_window": 1050000, "max_output": 128000 }, - "training": "2025-12-01", - "knowledge_cutoff": "December 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 30.0, - "output_cost_per_mtok": 180.0, - "cache_input_cost_per_mtok": 3.0 - }, - "estimated_output_tps": 20, - "aliases": ["gpt55-pro", "gpt-55-pro"] - }, - { - "id": "gpt-5.4-pro", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.4 Pro", - "limits": { "context_window": 1047576, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 30.0, - "output_cost_per_mtok": 180.0, - "cache_input_cost_per_mtok": 3.0 - }, - "estimated_output_tps": 20, - "aliases": ["gpt54-pro", "gpt-54-pro"] - }, - { - "id": "gpt-5.4-mini", - "provider": "openai", - "family": "gpt-5", - "display_name": "GPT-5.4 Mini", - "limits": { "context_window": 400000, "max_output": 128000 }, - "training": "2025-08-31", - "knowledge_cutoff": "April 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 0.75, - "output_cost_per_mtok": 4.50, - "cache_input_cost_per_mtok": 0.075 - }, - "estimated_output_tps": 140, - "aliases": ["gpt54-mini", "gpt-54-mini"] - }, - { - "id": "gemini-3.1-pro-preview", - "provider": "gemini", - "family": "gemini-3", - "display_name": "Gemini 3.1 Pro (Preview)", - "limits": { "context_window": 1048576, "max_output": 65536 }, - "training": "2025-01-01", - "knowledge_cutoff": "January 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 2.0, - "output_cost_per_mtok": 12.0, - "cache_input_cost_per_mtok": 0.50 - }, - "estimated_output_tps": 85, - "aliases": ["gemini-pro"], - "default": true - }, - { - "id": "gemini-3.1-pro-preview-customtools", - "provider": "gemini", - "family": "gemini-3", - "display_name": "Gemini 3.1 Pro Custom Tools (Preview)", - "limits": { "context_window": 1048576, "max_output": 65536 }, - "training": "2025-01-01", - "knowledge_cutoff": "January 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 2.0, - "output_cost_per_mtok": 12.0, - "cache_input_cost_per_mtok": 0.50 - }, - "estimated_output_tps": 85, - "aliases": ["gemini-customtools"] - }, - { - "id": "gemini-3-flash-preview", - "provider": "gemini", - "family": "gemini-3", - "display_name": "Gemini 3 Flash (Preview)", - "limits": { "context_window": 1048576, "max_output": 65536 }, - "training": "2025-01-01", - "knowledge_cutoff": "January 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 0.5, - "output_cost_per_mtok": 3.0, - "cache_input_cost_per_mtok": 0.125 - }, - "estimated_output_tps": 150, - "aliases": ["gemini-flash"] - }, - { - "id": "gemini-3.1-flash-lite-preview", - "provider": "gemini", - "family": "gemini-3", - "display_name": "Gemini 3.1 Flash Lite (Preview)", - "limits": { "context_window": 1048576, "max_output": 65536 }, - "training": "2025-01-01", - "knowledge_cutoff": "January 2025", - "features": { "tools": true, "vision": true, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 0.25, - "output_cost_per_mtok": 1.5, - "cache_input_cost_per_mtok": 0.0625 - }, - "estimated_output_tps": 200, - "aliases": ["gemini-flash-lite"] - }, - { - "id": "kimi-k2.5", - "provider": "kimi", - "family": "kimi-k2", - "display_name": "Kimi K2.5", - "limits": { "context_window": 262144, "max_output": 16000 }, - "training": "2025-10-01", - "knowledge_cutoff": "October 2025", - "features": { "tools": true, "vision": true, "reasoning": false }, - "costs": { - "input_cost_per_mtok": 0.6, - "output_cost_per_mtok": 3.0, - "cache_input_cost_per_mtok": null - }, - "estimated_output_tps": 50, - "aliases": ["kimi"], - "default": true - }, - { - "id": "glm-4.7", - "provider": "zai", - "family": "glm-4", - "display_name": "GLM 4.7", - "limits": { "context_window": 202752, "max_output": 16384 }, - "training": null, - "knowledge_cutoff": null, - "features": { "tools": true, "vision": false, "reasoning": false }, - "costs": { - "input_cost_per_mtok": 0.6, - "output_cost_per_mtok": 2.2, - "cache_input_cost_per_mtok": null - }, - "estimated_output_tps": 100, - "aliases": ["glm", "glm4"], - "default": true - }, - { - "id": "minimax-m2.5", - "provider": "minimax", - "family": "minimax-m2", - "display_name": "Minimax M2.5", - "limits": { "context_window": 196608, "max_output": 16384 }, - "training": null, - "knowledge_cutoff": null, - "features": { "tools": true, "vision": false, "reasoning": false }, - "costs": { - "input_cost_per_mtok": 0.3, - "output_cost_per_mtok": 1.2, - "cache_input_cost_per_mtok": null - }, - "estimated_output_tps": 45, - "aliases": ["minimax"], - "default": true - }, - { - "id": "mercury-2", - "provider": "inception", - "family": "mercury", - "display_name": "Mercury 2", - "limits": { "context_window": 131072, "max_output": 50000 }, - "training": null, - "knowledge_cutoff": null, - "features": { "tools": true, "vision": false, "reasoning": true, "effort": true }, - "costs": { - "input_cost_per_mtok": 0.25, - "output_cost_per_mtok": 0.75, - "cache_input_cost_per_mtok": null - }, - "estimated_output_tps": 1000, - "aliases": ["mercury"], - "default": true - } -] diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index d01a660e0..9c35c03c1 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -1,14 +1,350 @@ -use std::collections::HashMap; +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::str::FromStr; use std::sync::LazyLock; -use crate::provider::Provider; -use crate::types::Model; +use rust_embed::RustEmbed; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use strum::VariantArray; +use toml::de::Error as TomlDeError; -/// Global singleton catalog parsed from embedded catalog.json. +use crate::Speed; +use crate::adapter::{self, AdapterMetadata}; +use crate::ids::ProviderId; +use crate::provider::Provider; +use crate::reasoning::ReasoningEffort; +use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits}; + +#[derive(RustEmbed)] +#[folder = "src/catalog/providers"] +struct BuiltinCatalogToml; + +/// TOML shape used by the model catalog builder. +/// +/// This deliberately lives in `fabro-model` instead of reusing +/// `fabro-config::LlmLayer`: `fabro-config` depends on `fabro-types`, and +/// `fabro-types` depends on `fabro-model`, so the catalog cannot depend on +/// `fabro-config` without creating a crate cycle. +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LlmCatalogSettings { + #[serde(default)] + pub providers: HashMap, + #[serde(default)] + pub models: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderCatalogSettings { + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub adapter: Option, + #[serde(default)] + pub base_url: Option, + #[serde(default)] + pub credentials: Option>, + #[serde(default)] + pub extra_headers: Option>, + #[serde(default)] + pub priority: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub aliases: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModelCatalogSettings { + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub api_id: Option, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub family: Option, + #[serde(default)] + pub training: Option, + #[serde(default, deserialize_with = "deserialize_knowledge_cutoff")] + pub knowledge_cutoff: Option, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub aliases: Option>, + #[serde(default)] + pub estimated_output_tps: Option, + #[serde(default)] + pub limits: Option, + #[serde(default)] + pub features: Option, + #[serde(default)] + pub controls: Option, + #[serde(default)] + pub costs: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettingsModelLimits { + #[serde(default)] + pub context_window: Option, + #[serde(default)] + pub max_output: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettingsModelFeatures { + #[serde(default)] + pub tools: Option, + #[serde(default)] + pub vision: Option, + #[serde(default)] + pub reasoning: Option, + #[serde(default)] + pub effort: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettingsModelControls { + #[serde(default)] + pub reasoning_effort: Option>, + #[serde(default)] + pub speed: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettingsModelCostTable { + #[serde(flatten)] + pub base: CostRates, + #[serde(default)] + pub speed: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CostRates { + #[serde(default)] + pub input_cost_per_mtok: Option, + #[serde(default)] + pub output_cost_per_mtok: Option, + #[serde(default)] + pub cache_input_cost_per_mtok: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub enum CredentialRef { + Credential(String), + Env(String), +} + +impl std::fmt::Display for CredentialRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Credential(id) => write!(f, "credential:{id}"), + Self::Env(name) => write!(f, "env:{name}"), + } + } +} + +impl From for String { + fn from(value: CredentialRef) -> Self { + value.to_string() + } +} + +impl FromStr for CredentialRef { + type Err = CredentialRefParseError; + + fn from_str(value: &str) -> Result { + if let Some(id) = value.strip_prefix("credential:") { + if id.is_empty() { + return Err(CredentialRefParseError::EmptyCredential); + } + return Ok(Self::Credential(id.to_string())); + } + if let Some(name) = value.strip_prefix("env:") { + if name.is_empty() { + return Err(CredentialRefParseError::EmptyEnv); + } + return Ok(Self::Env(name.to_string())); + } + Err(CredentialRefParseError::Invalid) + } +} + +impl TryFrom for CredentialRef { + type Error = CredentialRefParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CredentialRefParseError { + #[error("credential reference must be `credential:` or `env:`")] + Invalid, + #[error("credential reference is missing an ID after `credential:`")] + EmptyCredential, + #[error("credential reference is missing a name after `env:`")] + EmptyEnv, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HeaderValueRef { + Literal(String), + Env(String), + Credential(String), +} + +impl Serialize for HeaderValueRef { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeMap; + + let mut map = serializer.serialize_map(Some(1))?; + match self { + Self::Literal(value) => map.serialize_entry("literal", value)?, + Self::Env(value) => map.serialize_entry("env", value)?, + Self::Credential(value) => map.serialize_entry("credential", value)?, + } + map.end() + } +} + +impl std::fmt::Display for HeaderValueRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Literal(_) => f.write_str("literal:"), + Self::Env(name) => write!(f, "env:{name}"), + Self::Credential(id) => write!(f, "credential:{id}"), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum HeaderValueRefInput { + Table(HeaderValueRefSerde), + BareString(String), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct HeaderValueRefSerde { + #[serde(default)] + literal: Option, + #[serde(default)] + env: Option, + #[serde(default)] + credential: Option, +} + +impl<'de> Deserialize<'de> for HeaderValueRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error as _; + + match HeaderValueRefInput::deserialize(deserializer)? { + HeaderValueRefInput::BareString(value) => { + drop(value); + Err(D::Error::custom("header value must be a table")) + } + HeaderValueRefInput::Table(value) => value.try_into().map_err(D::Error::custom), + } + } +} + +impl TryFrom for HeaderValueRef { + type Error = HeaderValueRefParseError; + + fn try_from(value: HeaderValueRefSerde) -> Result { + let populated = [ + value.literal.as_ref(), + value.env.as_ref(), + value.credential.as_ref(), + ] + .into_iter() + .flatten() + .count(); + if populated != 1 { + return Err(HeaderValueRefParseError::WrongFieldCount); + } + if let Some(value) = value.literal { + return non_empty_header_value(value).map(Self::Literal); + } + if let Some(value) = value.env { + return non_empty_header_value(value).map(Self::Env); + } + if let Some(value) = value.credential { + return non_empty_header_value(value).map(Self::Credential); + } + unreachable!("populated field count was already checked"); + } +} + +fn non_empty_header_value(value: String) -> Result { + if value.is_empty() { + Err(HeaderValueRefParseError::EmptyValue) + } else { + Ok(value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum HeaderValueRefParseError { + #[error("header value must contain exactly one of `literal`, `env`, or `credential`")] + WrongFieldCount, + #[error("header value reference must not be empty")] + EmptyValue, +} + +pub fn deserialize_knowledge_cutoff<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error as _; + use toml::value::Datetime; + + #[derive(Deserialize)] + #[serde(untagged)] + enum Either { + Toml(Datetime), + Str(String), + } + + let value = Option::::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(Either::Str(value)) => Ok(Some(value)), + Some(Either::Toml(value)) => { + let date = value + .date + .ok_or_else(|| D::Error::custom("knowledge_cutoff requires a date component"))?; + Ok(Some(format!( + "{:04}-{:02}-{:02}", + date.year, date.month, date.day + ))) + } + } +} + +/// Global singleton catalog parsed from embedded provider TOML files. static GLOBAL_CATALOG: LazyLock = LazyLock::new(|| { - let models: Vec = serde_json::from_str(include_str!("catalog.json")) - .expect("embedded catalog.json must be valid"); - Catalog { models } + Catalog::from_builtin_toml().expect("embedded provider TOML files must build a valid catalog") }); /// A resolved fallback target: provider name + model ID. @@ -18,42 +354,382 @@ pub struct FallbackTarget { pub model: String, } +#[derive(Debug, Clone, PartialEq)] +pub struct CatalogProvider { + pub id: ProviderId, + pub display_name: String, + pub adapter: String, + pub base_url: Option, + pub credentials: Vec, + pub extra_headers: HashMap, + pub priority: i32, + pub aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogModelControls { + pub reasoning_effort: Vec, + pub speed: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CatalogModelSettings { + pub api_id: String, + pub controls: CatalogModelControls, + pub speed_costs: HashMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum CatalogBuildError { + #[error("embedded built-in catalog contains no provider TOML files")] + NoBuiltinProviderFiles, + #[error("failed to read embedded provider TOML path '{path}' as UTF-8")] + InvalidBuiltinUtf8 { + path: String, + #[source] + source: std::str::Utf8Error, + }, + #[error("failed to parse embedded provider TOML '{path}'")] + InvalidBuiltinToml { + path: String, + #[source] + source: TomlDeError, + }, + #[error("embedded provider TOML '{path}' must define exactly one provider row")] + InvalidBuiltinProviderCount { path: String }, + #[error("embedded provider TOML '{path}' must define provider '{expected}', found '{actual}'")] + BuiltinProviderIdMismatch { + path: String, + expected: String, + actual: String, + }, + #[error( + "embedded provider TOML '{path}' contains model '{model}' for provider '{actual}', expected '{expected}'" + )] + BuiltinModelProviderMismatch { + path: String, + model: String, + expected: String, + actual: String, + }, + #[error("provider '{provider}' is missing required field '{field}'")] + MissingProviderField { + provider: ProviderId, + field: &'static str, + }, + #[error("provider '{provider}' uses unknown adapter '{adapter}'")] + UnknownAdapter { + provider: ProviderId, + adapter: String, + }, + #[error("provider '{provider}' uses adapter 'openai_compatible' without base_url")] + MissingOpenAiCompatibleBaseUrl { provider: ProviderId }, + #[error("provider identifier '{identifier}' is declared by both '{first}' and '{second}'")] + DuplicateProviderIdentifier { + identifier: String, + first: ProviderId, + second: ProviderId, + }, + #[error("model '{model}' is missing required field '{field}'")] + MissingModelField { model: String, field: &'static str }, + #[error("model '{model}' references unknown provider '{provider}'")] + UnknownModelProvider { + model: String, + provider: ProviderId, + }, + #[error("model identifier '{identifier}' is declared by both '{first}' and '{second}'")] + DuplicateModelIdentifier { + identifier: String, + first: String, + second: String, + }, + #[error("provider '{provider}' has multiple default models: {models:?}")] + MultipleProviderDefaults { + provider: ProviderId, + models: Vec, + }, + #[error("catalog must contain at least one enabled default model")] + NoDefaultModel, + #[error("model '{model}' has invalid reasoning_effort '{value}'")] + InvalidReasoningEffort { + model: String, + value: String, + #[source] + source: strum::ParseError, + }, + #[error( + "model '{model}' declares unsupported reasoning_effort '{value}' for adapter '{adapter}'" + )] + UnsupportedReasoningEffort { + model: String, + adapter: String, + value: ReasoningEffort, + }, + #[error("model '{model}' declares reasoning_effort controls but features.effort is false")] + ReasoningEffortWithoutFeature { model: String }, + #[error( + "model '{model}' must declare at least one reasoning_effort when features.effort is true" + )] + EmptyReasoningEffortControls { model: String }, + #[error("model '{model}' has invalid speed '{value}'")] + InvalidSpeed { + model: String, + value: String, + #[source] + source: strum::ParseError, + }, + #[error("model '{model}' must not declare standard in controls.speed")] + StandardSpeedControl { model: String }, + #[error("model '{model}' declares unsupported speed '{speed}' for adapter '{adapter}'")] + UnsupportedSpeed { + model: String, + adapter: String, + speed: Speed, + }, + #[error("model '{model}' has costs.speed.{speed} without declaring controls.speed")] + UndeclaredSpeedCost { model: String, speed: Speed }, +} + /// Typed model catalog backed by a `Vec`. /// -/// Use [`Catalog::builtin()`] for the embedded catalog, or +/// Use [`Catalog::builtin()`] for the embedded settings-backed catalog, or /// [`Catalog::from_models()`] for testing with custom model sets. +#[derive(Debug)] pub struct Catalog { - models: Vec, + models: Vec, + providers: Vec, + model_settings: HashMap, + model_index: HashMap, + provider_aliases: HashMap, + provider_index: HashMap, } impl Catalog { /// Returns a reference to the global built-in catalog (loaded once from - /// catalog.json). + /// embedded provider TOML files). #[must_use] pub fn builtin() -> &'static Self { &GLOBAL_CATALOG } + pub fn from_settings(settings: &LlmCatalogSettings) -> Result { + let mut providers = build_providers(settings)?; + providers.sort_by(provider_order); + + let mut provider_index = HashMap::new(); + for (idx, provider) in providers.iter().enumerate() { + provider_index.insert(provider.id.clone(), idx); + } + + let provider_aliases = build_provider_aliases(&providers)?; + let known_providers: HashSet<&str> = + settings.providers.keys().map(String::as_str).collect(); + let enabled_providers: HashSet<&str> = providers + .iter() + .map(|provider| provider.id.as_str()) + .collect(); + let provider_by_id: HashMap<&str, &CatalogProvider> = providers + .iter() + .map(|provider| (provider.id.as_str(), provider)) + .collect(); + + let mut models_with_settings = Vec::new(); + let mut model_identifiers = BTreeMap::::new(); + let mut defaults_by_provider = HashMap::>::new(); + + let mut model_ids = settings.models.keys().cloned().collect::>(); + model_ids.sort_unstable(); + for model_id in model_ids { + let model_settings = settings + .models + .get(&model_id) + .expect("model ID came from settings map keys"); + if model_settings.enabled == Some(false) { + continue; + } + + let provider_id = + required_model_string(&model_id, model_settings.provider.as_ref(), "provider")?; + if !known_providers.contains(provider_id.as_str()) { + return Err(CatalogBuildError::UnknownModelProvider { + model: model_id, + provider: ProviderId::from(provider_id), + }); + } + if !enabled_providers.contains(provider_id.as_str()) { + continue; + } + + let provider = provider_by_id + .get(provider_id.as_str()) + .expect("enabled provider ID should have provider metadata"); + let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?; + + register_model_identifier(&mut model_identifiers, model.id.clone(), model.id.clone())?; + for alias in &model.aliases { + register_model_identifier(&mut model_identifiers, alias.clone(), model.id.clone())?; + } + + if model.default { + defaults_by_provider + .entry(model.provider.clone()) + .or_default() + .push(model.id.clone()); + } + models_with_settings.push((model, resolved_settings)); + } + + for (provider, defaults) in defaults_by_provider { + if defaults.len() > 1 { + return Err(CatalogBuildError::MultipleProviderDefaults { + provider, + models: defaults, + }); + } + } + if !models_with_settings.iter().any(|(model, _)| model.default) { + return Err(CatalogBuildError::NoDefaultModel); + } + + models_with_settings.sort_by(|(left, _), (right, _)| model_order(left, right)); + let mut model_settings_by_id = HashMap::new(); + let mut models = Vec::new(); + for (model, settings) in models_with_settings { + model_settings_by_id.insert(model.id.clone(), settings); + models.push(model); + } + let model_index = build_model_index(&models); + + Ok(Self { + models, + providers, + model_settings: model_settings_by_id, + model_index, + provider_aliases, + provider_index, + }) + } + /// Create a catalog from a custom set of models (useful for testing). #[must_use] pub fn from_models(models: Vec) -> Self { - Self { models } + let mut providers = Vec::::new(); + let mut seen = HashSet::::new(); + for model in &models { + if !seen.insert(model.provider.clone()) { + continue; + } + providers.push(CatalogProvider { + id: model.provider.clone(), + display_name: Provider::display_name_for_id(&model.provider), + adapter: default_adapter_for_provider(&model.provider).to_string(), + base_url: None, + credentials: Vec::new(), + extra_headers: HashMap::new(), + priority: 0, + aliases: Vec::new(), + }); + } + + let provider_index = providers + .iter() + .enumerate() + .map(|(idx, provider)| (provider.id.clone(), idx)) + .collect::>(); + let model_settings = models + .iter() + .map(|model| { + (model.id.clone(), CatalogModelSettings { + api_id: model.id.clone(), + controls: default_controls_for_model(model), + speed_costs: HashMap::new(), + }) + }) + .collect(); + let model_index = build_model_index(&models); + + Self { + models, + providers, + model_settings, + model_index, + provider_aliases: HashMap::new(), + provider_index, + } + } + + fn from_builtin_toml() -> Result { + let mut layer = LlmCatalogSettings::default(); + let mut paths = BuiltinCatalogToml::iter() + .filter(|path| path.ends_with(".toml")) + .map(Cow::into_owned) + .collect::>(); + paths.sort_unstable(); + if paths.is_empty() { + return Err(CatalogBuildError::NoBuiltinProviderFiles); + } + + for path in paths { + let file = BuiltinCatalogToml::get(&path) + .expect("path came from embedded built-in catalog iterator"); + let source = std::str::from_utf8(file.data.as_ref()).map_err(|source| { + CatalogBuildError::InvalidBuiltinUtf8 { + path: path.clone(), + source, + } + })?; + let fragment: LlmCatalogSettings = + toml::from_str(source).map_err(|source| CatalogBuildError::InvalidBuiltinToml { + path: path.clone(), + source, + })?; + validate_builtin_fragment(&path, &fragment)?; + layer.providers.extend(fragment.providers); + layer.models.extend(fragment.models); + } + + Self::from_settings(&layer) } /// Look up a model by ID or alias. #[must_use] pub fn get(&self, id: &str) -> Option<&Model> { - self.models - .iter() - .find(|m| m.id == id || m.aliases.iter().any(|a| a == id)) + self.model_index + .get(id) + .and_then(|idx| self.models.get(*idx)) + } + + #[must_use] + pub fn providers(&self) -> &[CatalogProvider] { + &self.providers + } + + #[must_use] + pub fn provider(&self, id: &ProviderId) -> Option<&CatalogProvider> { + let canonical = self.provider_aliases.get(id.as_str()).unwrap_or(id); + self.provider_index + .get(canonical) + .and_then(|idx| self.providers.get(*idx)) + } + + #[must_use] + pub fn model_settings(&self, id: &str) -> Option<&CatalogModelSettings> { + let model = self.get(id)?; + self.model_settings.get(&model.id) } /// List all models, optionally filtered by provider. #[must_use] - pub fn list(&self, provider: Option) -> Vec<&Model> { + pub fn list(&self, provider: Option<&ProviderId>) -> Vec<&Model> { match provider { None => self.models.iter().collect(), - Some(p) => self.models.iter().filter(|m| m.provider == p).collect(), + Some(p) => { + let provider_id = self.provider(p).map_or(p, |provider| &provider.id); + self.models + .iter() + .filter(|m| &m.provider == provider_id) + .collect() + } } } @@ -63,33 +739,72 @@ impl Catalog { /// Panics if the catalog contains no default model. #[must_use] pub fn default_model(&self) -> &Model { - self.models + self.providers .iter() - .find(|m| m.default) + .find_map(|provider| self.default_for_provider(&provider.id)) + .or_else(|| self.models.iter().find(|m| m.default)) .expect("catalog must contain at least one default model") } /// The default model for a specific provider. #[must_use] - pub fn default_for_provider(&self, p: Provider) -> Option<&Model> { - self.models.iter().find(|m| m.provider == p && m.default) + pub fn default_for_provider(&self, p: &ProviderId) -> Option<&Model> { + let provider_id = self + .provider(p) + .map_or_else(|| p.clone(), |provider| provider.id.clone()); + self.models + .iter() + .find(|m| m.provider == provider_id && m.default) } /// Default model for the best-available provider (based on API keys), /// falling back to the global catalog default. #[must_use] + #[expect( + clippy::disallowed_methods, + reason = "Catalog default selection intentionally checks provider API-key env refs." + )] pub fn default_from_env(&self) -> &Model { - let provider = Provider::default_from_env(); - self.default_for_provider(provider) - .unwrap_or_else(|| self.default_model()) + let configured = self + .providers + .iter() + .filter(|provider| { + provider.credentials.iter().any(|credential| { + matches!(credential, CredentialRef::Env(name) if std::env::var(name).is_ok()) + }) + }) + .map(|provider| provider.id.clone()) + .collect::>(); + self.default_for_configured_ids(&configured) } /// Default model for the best configured provider, falling back to the /// global catalog default. #[must_use] pub fn default_for_configured(&self, configured: &[Provider]) -> &Model { - let provider = Provider::default_for_configured(configured); - self.default_for_provider(provider) + let configured = configured + .iter() + .copied() + .map(Provider::id) + .collect::>(); + self.default_for_configured_ids(&configured) + } + + /// Default model for the best-available built-in provider IDs, falling + /// back to the global catalog default. + #[must_use] + pub fn default_for_configured_ids(&self, configured: &[ProviderId]) -> &Model { + if configured.is_empty() { + return self.default_model(); + } + let configured = configured + .iter() + .filter_map(|id| self.provider(id).map(|provider| provider.id.clone())) + .collect::>(); + self.providers + .iter() + .filter(|provider| configured.contains(&provider.id)) + .find_map(|provider| self.default_for_provider(&provider.id)) .unwrap_or_else(|| self.default_model()) } @@ -108,7 +823,7 @@ impl Catalog { return Some(info); } } - self.default_for_provider(p) + self.default_for_provider(&p.id()) } /// Find the closest model on a target provider matching the reference's @@ -118,11 +833,14 @@ impl Catalog { /// `features.reasoning`. Among matches, picks the closest by /// `costs.input_cost_per_mtok` (absolute diff). #[must_use] - pub fn closest(&self, target: Provider, reference: &Model) -> Option<&Model> { + pub fn closest(&self, target: &ProviderId, reference: &Model) -> Option<&Model> { + let target = self + .provider(target) + .map_or(target, |provider| &provider.id); self.models .iter() .filter(|m| { - m.provider == target + &m.provider == target && m.features.tools == reference.features.tools && m.features.vision == reference.features.vision && m.features.reasoning == reference.features.reasoning @@ -145,7 +863,7 @@ impl Catalog { #[must_use] pub fn build_fallback_chain( &self, - primary: Provider, + primary: &ProviderId, model: &str, fallbacks: &HashMap>, ) -> Vec { @@ -153,15 +871,15 @@ impl Catalog { return Vec::new(); }; - let Some(fallback_providers) = fallbacks.get(<&'static str>::from(primary)) else { + let Some(fallback_providers) = fallbacks.get(primary.as_str()) else { return Vec::new(); }; fallback_providers .iter() .filter_map(|provider_str| { - let provider = provider_str.parse::().ok()?; - self.closest(provider, reference).map(|m| FallbackTarget { + let provider = ProviderId::from(provider_str.clone()); + self.closest(&provider, reference).map(|m| FallbackTarget { provider: provider_str.clone(), model: m.id.clone(), }) @@ -170,12 +888,469 @@ impl Catalog { } } +fn build_model_index(models: &[Model]) -> HashMap { + let mut index = HashMap::new(); + for (idx, model) in models.iter().enumerate() { + index.insert(model.id.clone(), idx); + for alias in &model.aliases { + index.insert(alias.clone(), idx); + } + } + index +} + +fn build_providers( + settings: &LlmCatalogSettings, +) -> Result, CatalogBuildError> { + let mut providers = Vec::new(); + let mut ids = settings.providers.keys().cloned().collect::>(); + ids.sort_unstable(); + for id in ids { + let provider_id = ProviderId::from(id.clone()); + let settings = settings + .providers + .get(&id) + .expect("provider ID came from settings map keys"); + if settings.enabled == Some(false) { + continue; + } + + let adapter = required_provider_string(&provider_id, settings.adapter.as_ref(), "adapter")?; + if adapter::get(adapter.as_str()).is_none() { + return Err(CatalogBuildError::UnknownAdapter { + provider: provider_id, + adapter, + }); + } + if adapter == adapter::OPENAI_COMPATIBLE.key && settings.base_url.is_none() { + return Err(CatalogBuildError::MissingOpenAiCompatibleBaseUrl { + provider: provider_id, + }); + } + + providers.push(CatalogProvider { + id: provider_id, + display_name: settings.display_name.clone().unwrap_or_else(|| id.clone()), + adapter, + base_url: settings.base_url.clone(), + credentials: settings.credentials.clone().unwrap_or_default(), + extra_headers: settings.extra_headers.clone().unwrap_or_default(), + priority: settings.priority.unwrap_or_default(), + aliases: settings.aliases.clone().unwrap_or_default(), + }); + } + Ok(providers) +} + +fn build_provider_aliases( + providers: &[CatalogProvider], +) -> Result, CatalogBuildError> { + let mut identifiers = BTreeMap::::new(); + for provider in providers { + register_provider_identifier( + &mut identifiers, + provider.id.as_str().to_string(), + provider.id.clone(), + )?; + for alias in &provider.aliases { + register_provider_identifier(&mut identifiers, alias.clone(), provider.id.clone())?; + } + } + Ok(identifiers.into_iter().collect()) +} + +fn build_model( + model_id: &str, + settings: &ModelCatalogSettings, + provider: &CatalogProvider, +) -> Result<(Model, CatalogModelSettings), CatalogBuildError> { + let family = required_model_string(model_id, settings.family.as_ref(), "family")?; + let display_name = + required_model_string(model_id, settings.display_name.as_ref(), "display_name")?; + let limits = settings + .limits + .as_ref() + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model_id.to_string(), + field: "limits", + })?; + let context_window = + limits + .context_window + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model_id.to_string(), + field: "limits.context_window", + })?; + let features = + settings + .features + .as_ref() + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model_id.to_string(), + field: "features", + })?; + let model_features = build_model_features(model_id, features)?; + let adapter = adapter::get(&provider.adapter).expect("provider adapter was validated earlier"); + let controls = build_model_controls(model_id, &model_features, settings, adapter)?; + let costs = build_model_costs(settings.costs.as_ref()); + let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; + + let model = Model { + id: model_id.to_string(), + provider: provider.id.clone(), + family, + display_name, + limits: ModelLimits { + context_window, + max_output: limits.max_output, + }, + training: settings.training.clone(), + knowledge_cutoff: settings.knowledge_cutoff.clone(), + features: model_features, + costs, + estimated_output_tps: settings.estimated_output_tps, + aliases: settings.aliases.clone().unwrap_or_default(), + default: settings.default.unwrap_or_default(), + configured: false, + }; + let catalog_settings = CatalogModelSettings { + api_id: settings + .api_id + .clone() + .unwrap_or_else(|| model_id.to_string()), + controls, + speed_costs, + }; + Ok((model, catalog_settings)) +} + +fn build_model_features( + model_id: &str, + features: &SettingsModelFeatures, +) -> Result { + Ok(ModelFeatures { + tools: features + .tools + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model_id.to_string(), + field: "features.tools", + })?, + vision: features + .vision + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model_id.to_string(), + field: "features.vision", + })?, + reasoning: features + .reasoning + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model_id.to_string(), + field: "features.reasoning", + })?, + effort: features.effort.unwrap_or_default(), + }) +} + +fn build_model_costs(costs: Option<&SettingsModelCostTable>) -> ModelCosts { + let base = costs.map(|costs| &costs.base); + ModelCosts { + input_cost_per_mtok: base.and_then(|base| base.input_cost_per_mtok), + output_cost_per_mtok: base.and_then(|base| base.output_cost_per_mtok), + cache_input_cost_per_mtok: base.and_then(|base| base.cache_input_cost_per_mtok), + } +} + +fn build_speed_costs( + model_id: &str, + costs: Option<&SettingsModelCostTable>, + controls: &CatalogModelControls, +) -> Result, CatalogBuildError> { + let mut speed_costs = HashMap::new(); + let Some(costs) = costs.and_then(|costs| costs.speed.as_ref()) else { + return Ok(speed_costs); + }; + for (speed, rates) in costs { + let speed = parse_speed(model_id, speed)?; + if !controls.speed.contains(&speed) { + return Err(CatalogBuildError::UndeclaredSpeedCost { + model: model_id.to_string(), + speed, + }); + } + speed_costs.insert(speed, cost_rates_to_model_costs(rates)); + } + Ok(speed_costs) +} + +fn cost_rates_to_model_costs(rates: &CostRates) -> ModelCosts { + ModelCosts { + input_cost_per_mtok: rates.input_cost_per_mtok, + output_cost_per_mtok: rates.output_cost_per_mtok, + cache_input_cost_per_mtok: rates.cache_input_cost_per_mtok, + } +} + +fn build_model_controls( + model_id: &str, + features: &ModelFeatures, + settings: &ModelCatalogSettings, + adapter: &'static AdapterMetadata, +) -> Result { + let reasoning_effort = match settings + .controls + .as_ref() + .and_then(|controls| controls.reasoning_effort.as_ref()) + { + Some(values) if !features.effort && !values.is_empty() => { + return Err(CatalogBuildError::ReasoningEffortWithoutFeature { + model: model_id.to_string(), + }); + } + Some(values) if values.is_empty() && features.effort => { + return Err(CatalogBuildError::EmptyReasoningEffortControls { + model: model_id.to_string(), + }); + } + Some(values) => values + .iter() + .map(|value| parse_reasoning_effort(model_id, value)) + .collect::, _>>()?, + None if features.effort => adapter.controls.native_reasoning_effort.to_vec(), + None => Vec::new(), + }; + for value in &reasoning_effort { + if !adapter.controls.native_reasoning_effort.contains(value) { + return Err(CatalogBuildError::UnsupportedReasoningEffort { + model: model_id.to_string(), + adapter: adapter.key.to_string(), + value: *value, + }); + } + } + + let speed = settings + .controls + .as_ref() + .and_then(|controls| controls.speed.as_ref()) + .map(|values| { + values + .iter() + .map(|value| parse_speed_control(model_id, value, adapter)) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + + Ok(CatalogModelControls { + reasoning_effort, + speed, + }) +} + +fn parse_reasoning_effort( + model_id: &str, + value: &str, +) -> Result { + ReasoningEffort::from_str(value).map_err(|source| CatalogBuildError::InvalidReasoningEffort { + model: model_id.to_string(), + value: value.to_string(), + source, + }) +} + +fn parse_speed(model_id: &str, value: &str) -> Result { + Speed::from_str(value).map_err(|source| CatalogBuildError::InvalidSpeed { + model: model_id.to_string(), + value: value.to_string(), + source, + }) +} + +fn parse_speed_control( + model_id: &str, + value: &str, + adapter: &'static AdapterMetadata, +) -> Result { + let speed = parse_speed(model_id, value)?; + if speed == Speed::Standard { + return Err(CatalogBuildError::StandardSpeedControl { + model: model_id.to_string(), + }); + } + if !adapter.controls.additional_speeds.contains(&speed) { + return Err(CatalogBuildError::UnsupportedSpeed { + model: model_id.to_string(), + adapter: adapter.key.to_string(), + speed, + }); + } + Ok(speed) +} + +fn required_provider_string( + provider: &ProviderId, + value: Option<&String>, + field: &'static str, +) -> Result { + value + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| CatalogBuildError::MissingProviderField { + provider: provider.clone(), + field, + }) +} + +fn required_model_string( + model: &str, + value: Option<&String>, + field: &'static str, +) -> Result { + value + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| CatalogBuildError::MissingModelField { + model: model.to_string(), + field, + }) +} + +fn register_provider_identifier( + identifiers: &mut BTreeMap, + identifier: String, + owner: ProviderId, +) -> Result<(), CatalogBuildError> { + match identifiers.get(&identifier) { + Some(existing) if existing != &owner => { + Err(CatalogBuildError::DuplicateProviderIdentifier { + identifier, + first: existing.clone(), + second: owner, + }) + } + _ => { + identifiers.insert(identifier, owner); + Ok(()) + } + } +} + +fn register_model_identifier( + identifiers: &mut BTreeMap, + identifier: String, + owner: String, +) -> Result<(), CatalogBuildError> { + match identifiers.get(&identifier) { + Some(existing) if existing != &owner => Err(CatalogBuildError::DuplicateModelIdentifier { + identifier, + first: existing.clone(), + second: owner, + }), + _ => { + identifiers.insert(identifier, owner); + Ok(()) + } + } +} + +fn validate_builtin_fragment( + path: &str, + fragment: &LlmCatalogSettings, +) -> Result<(), CatalogBuildError> { + if fragment.providers.len() != 1 { + return Err(CatalogBuildError::InvalidBuiltinProviderCount { + path: path.to_string(), + }); + } + let expected = path + .strip_suffix(".toml") + .unwrap_or(path) + .rsplit('/') + .next() + .unwrap_or(path); + let actual = fragment + .providers + .keys() + .next() + .expect("provider count was checked"); + if actual != expected { + return Err(CatalogBuildError::BuiltinProviderIdMismatch { + path: path.to_string(), + expected: expected.to_string(), + actual: actual.clone(), + }); + } + + for (model, settings) in &fragment.models { + let Some(provider) = settings.provider.as_ref() else { + continue; + }; + if provider != expected { + return Err(CatalogBuildError::BuiltinModelProviderMismatch { + path: path.to_string(), + model: model.clone(), + expected: expected.to_string(), + actual: provider.clone(), + }); + } + } + Ok(()) +} + +fn default_adapter_for_provider(provider: &ProviderId) -> &'static str { + match Provider::from_id(provider) { + Some(Provider::Anthropic) => adapter::ANTHROPIC.key, + Some(Provider::OpenAi) => adapter::OPENAI.key, + Some(Provider::Gemini) => adapter::GEMINI.key, + Some( + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible, + ) + | None => adapter::OPENAI_COMPATIBLE.key, + } +} + +fn default_controls_for_model(model: &Model) -> CatalogModelControls { + CatalogModelControls { + reasoning_effort: if model.features.effort { + ReasoningEffort::VARIANTS.to_vec() + } else { + Vec::new() + }, + speed: Vec::new(), + } +} + +fn provider_order(left: &CatalogProvider, right: &CatalogProvider) -> std::cmp::Ordering { + right + .priority + .cmp(&left.priority) + .then_with(|| left.id.cmp(&right.id)) +} + +fn model_order(left: &Model, right: &Model) -> std::cmp::Ordering { + left.provider + .cmp(&right.provider) + .then_with(|| left.id.cmp(&right.id)) +} + #[cfg(test)] mod tests { use std::str::FromStr; + use strum::VariantArray; + use super::*; + use crate::Speed; use crate::provider::Provider; + use crate::reasoning::ReasoningEffort; + + fn minimal_settings(source: &str) -> LlmCatalogSettings { + toml::from_str(source).expect("fixture should parse as an LLM settings layer") + } // ---- Catalog struct tests ---- @@ -204,15 +1379,19 @@ mod tests { #[test] fn builtin_list_by_provider() { - let anthropic = Catalog::builtin().list(Some(Provider::Anthropic)); + let anthropic = Catalog::builtin().list(Some(&Provider::Anthropic.id())); assert!(!anthropic.is_empty()); - assert!(anthropic.iter().all(|m| m.provider == Provider::Anthropic)); + assert!( + anthropic + .iter() + .all(|m| m.provider == Provider::Anthropic.id()) + ); } #[test] fn builtin_list_unknown_provider_empty() { // OpenAiCompatible has no catalog models - let models = Catalog::builtin().list(Some(Provider::OpenAiCompatible)); + let models = Catalog::builtin().list(Some(&Provider::OpenAiCompatible.id())); assert!(models.is_empty()); } @@ -225,18 +1404,18 @@ mod tests { #[test] fn builtin_default_for_provider() { let m = Catalog::builtin() - .default_for_provider(Provider::Anthropic) + .default_for_provider(&Provider::Anthropic.id()) .unwrap(); assert_eq!(m.id, "claude-sonnet-4-6"); assert!(m.default); let m = Catalog::builtin() - .default_for_provider(Provider::OpenAi) + .default_for_provider(&Provider::OpenAi.id()) .unwrap(); assert_eq!(m.id, "gpt-5.4"); let m = Catalog::builtin() - .default_for_provider(Provider::Gemini) + .default_for_provider(&Provider::Gemini.id()) .unwrap(); assert_eq!(m.id, "gemini-3.1-pro-preview"); } @@ -268,7 +1447,9 @@ mod tests { #[test] fn builtin_closest_opus_to_gemini() { let opus = Catalog::builtin().get("claude-opus-4-6").unwrap(); - let result = Catalog::builtin().closest(Provider::Gemini, opus).unwrap(); + let result = Catalog::builtin() + .closest(&Provider::Gemini.id(), opus) + .unwrap(); assert_eq!(result.id, "gemini-3.1-pro-preview"); } @@ -277,7 +1458,7 @@ mod tests { let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); assert!( Catalog::builtin() - .closest(Provider::OpenAi, haiku) + .closest(&Provider::OpenAi.id(), haiku) .is_none() ); } @@ -289,7 +1470,7 @@ mod tests { "openai".to_string(), ])]); let chain = Catalog::builtin().build_fallback_chain( - Provider::Anthropic, + &Provider::Anthropic.id(), "claude-opus-4-6", &fallbacks, ); @@ -303,8 +1484,11 @@ mod tests { #[test] fn builtin_build_fallback_chain_unknown_model() { let fallbacks = HashMap::from([("anthropic".to_string(), vec!["gemini".to_string()])]); - let chain = - Catalog::builtin().build_fallback_chain(Provider::Anthropic, "unknown-xyz", &fallbacks); + let chain = Catalog::builtin().build_fallback_chain( + &Provider::Anthropic.id(), + "unknown-xyz", + &fallbacks, + ); assert!(chain.is_empty()); } @@ -312,7 +1496,7 @@ mod tests { fn builtin_build_fallback_chain_provider_not_in_map() { let fallbacks = HashMap::from([("openai".to_string(), vec!["anthropic".to_string()])]); let chain = Catalog::builtin().build_fallback_chain( - Provider::Anthropic, + &Provider::Anthropic.id(), "claude-opus-4-6", &fallbacks, ); @@ -326,7 +1510,7 @@ mod tests { "kimi".to_string(), ])]); let chain = Catalog::builtin().build_fallback_chain( - Provider::Anthropic, + &Provider::Anthropic.id(), "claude-haiku-4-5", &fallbacks, ); @@ -339,7 +1523,7 @@ mod tests { fn builtin_build_fallback_chain_empty_map() { let fallbacks = HashMap::new(); let chain = Catalog::builtin().build_fallback_chain( - Provider::Anthropic, + &Provider::Anthropic.id(), "claude-opus-4-6", &fallbacks, ); @@ -352,7 +1536,7 @@ mod tests { let models = vec![Model { id: "test-model".to_string(), - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), family: "test".to_string(), display_name: "Test Model".to_string(), limits: ModelLimits { @@ -386,12 +1570,417 @@ mod tests { assert_eq!(catalog.list(None).len(), 1); } + #[test] + fn builtin_catalog_is_loaded_from_provider_toml_settings() { + let catalog = Catalog::builtin(); + + assert_eq!( + catalog.provider(&ProviderId::openai()).unwrap().adapter, + "openai" + ); + assert_eq!( + catalog + .provider(&ProviderId::kimi()) + .unwrap() + .base_url + .as_deref(), + Some("https://api.moonshot.ai/v1") + ); + assert_eq!(catalog.model_settings("gpt-5.4").unwrap().api_id, "gpt-5.4"); + assert_eq!( + catalog.get("claude-opus-4-7").unwrap().knowledge_cutoff(), + Some("May 2025") + ); + assert_eq!( + catalog + .model_settings("gpt-5.4") + .unwrap() + .controls + .reasoning_effort, + ReasoningEffort::VARIANTS + ); + } + + #[test] + fn catalog_from_settings_rejects_unknown_adapter() { + let layer = minimal_settings( + r#" +[providers.venice] +display_name = "Venice" +adapter = "not_real" +enabled = true +"#, + ); + + let err = Catalog::from_settings(&layer).unwrap_err(); + + assert!(matches!( + err, + CatalogBuildError::UnknownAdapter { provider, adapter } + if provider == ProviderId::new("venice") && adapter == "not_real" + )); + } + + #[test] + fn catalog_from_settings_rejects_duplicate_model_aliases() { + let layer = minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai" +enabled = true + +[models.one] +provider = "test" +display_name = "One" +family = "test" +aliases = ["shared"] + +[models.one.limits] +context_window = 1000 + +[models.one.features] +tools = false +vision = false +reasoning = false + +[models.two] +provider = "test" +display_name = "Two" +family = "test" +aliases = ["shared"] + +[models.two.limits] +context_window = 1000 + +[models.two.features] +tools = false +vision = false +reasoning = false +"#, + ); + + let err = Catalog::from_settings(&layer).unwrap_err(); + + assert!(matches!( + err, + CatalogBuildError::DuplicateModelIdentifier { identifier, first, second } + if identifier == "shared" && first == "one" && second == "two" + )); + } + + #[test] + fn catalog_from_settings_filters_disabled_providers_and_models() { + let layer = minimal_settings( + r#" +[providers.enabled] +display_name = "Enabled" +adapter = "openai" +enabled = true + +[providers.disabled] +enabled = false + +[models.enabled_model] +provider = "enabled" +display_name = "Enabled Model" +family = "test" +aliases = ["enabled-alias"] +default = true + +[models.enabled_model.limits] +context_window = 1000 + +[models.enabled_model.features] +tools = false +vision = false +reasoning = false + +[models.disabled_model] +provider = "enabled" +display_name = "Disabled Model" +family = "test" +aliases = ["disabled-alias"] +enabled = false + +[models.disabled_model.limits] +context_window = 1000 + +[models.disabled_model.features] +tools = false +vision = false +reasoning = false + +[models.model_on_disabled_provider] +provider = "disabled" +display_name = "Hidden" +family = "test" + +[models.model_on_disabled_provider.limits] +context_window = 1000 + +[models.model_on_disabled_provider.features] +tools = false +vision = false +reasoning = false +"#, + ); + + let catalog = Catalog::from_settings(&layer).unwrap(); + + assert!(catalog.get("enabled_model").is_some()); + assert!(catalog.get("enabled-alias").is_some()); + assert!(catalog.get("disabled_model").is_none()); + assert!(catalog.get("disabled-alias").is_none()); + assert!(catalog.get("model_on_disabled_provider").is_none()); + assert!(catalog.provider(&ProviderId::new("disabled")).is_none()); + } + + #[test] + fn provider_priority_drives_configured_default_ordering() { + let layer = minimal_settings( + r#" +[providers.low] +display_name = "Low" +adapter = "openai" +priority = 10 + +[providers.high] +display_name = "High" +adapter = "openai" +priority = 20 + +[models.low_default] +provider = "low" +display_name = "Low Default" +family = "test" +default = true + +[models.low_default.limits] +context_window = 1000 + +[models.low_default.features] +tools = false +vision = false +reasoning = false + +[models.high_default] +provider = "high" +display_name = "High Default" +family = "test" +default = true + +[models.high_default.limits] +context_window = 1000 + +[models.high_default.features] +tools = false +vision = false +reasoning = false +"#, + ); + let catalog = Catalog::from_settings(&layer).unwrap(); + + assert_eq!(catalog.default_model().id, "high_default"); + assert_eq!( + catalog + .default_for_configured_ids(&[ProviderId::new("low"), ProviderId::new("high")]) + .id, + "high_default" + ); + assert_eq!( + catalog + .default_for_configured_ids(&[ProviderId::new("low")]) + .id, + "low_default" + ); + } + + #[test] + fn catalog_lists_models_by_provider_then_model_id() { + let layer = minimal_settings( + r#" +[providers.zeta] +display_name = "Zeta" +adapter = "openai" +priority = 20 + +[providers.alpha] +display_name = "Alpha" +adapter = "openai" +priority = 10 + +[models.zeta_two] +provider = "zeta" +display_name = "Zeta Two" +family = "test" +default = true + +[models.zeta_two.limits] +context_window = 1000 + +[models.zeta_two.features] +tools = false +vision = false +reasoning = false + +[models.alpha_one] +provider = "alpha" +display_name = "Alpha One" +family = "test" +default = true + +[models.alpha_one.limits] +context_window = 1000 + +[models.alpha_one.features] +tools = false +vision = false +reasoning = false + +[models.zeta_one] +provider = "zeta" +display_name = "Zeta One" +family = "test" + +[models.zeta_one.limits] +context_window = 1000 + +[models.zeta_one.features] +tools = false +vision = false +reasoning = false +"#, + ); + let catalog = Catalog::from_settings(&layer).unwrap(); + + let ids = catalog + .list(None) + .into_iter() + .map(|model| model.id.as_str()) + .collect::>(); + + assert_eq!(ids, ["alpha_one", "zeta_one", "zeta_two"]); + assert_eq!(catalog.default_model().id, "zeta_two"); + } + + #[test] + fn provider_aliases_resolve_provider_scoped_catalog_methods() { + let layer = minimal_settings( + r#" +[providers.canonical] +display_name = "Canonical" +adapter = "openai" +aliases = ["alias"] + +[models.default_model] +provider = "canonical" +display_name = "Default Model" +family = "test" +default = true + +[models.default_model.limits] +context_window = 1000 + +[models.default_model.features] +tools = false +vision = false +reasoning = false +"#, + ); + let catalog = Catalog::from_settings(&layer).unwrap(); + let alias = ProviderId::new("alias"); + let reference = catalog.get("default_model").unwrap(); + + assert_eq!( + catalog.provider(&alias).unwrap().id, + ProviderId::new("canonical") + ); + assert_eq!( + catalog.default_for_provider(&alias).unwrap().id, + "default_model" + ); + assert_eq!( + catalog + .default_for_configured_ids(std::slice::from_ref(&alias)) + .id, + "default_model" + ); + assert_eq!(catalog.list(Some(&alias))[0].id, "default_model"); + assert_eq!( + catalog.closest(&alias, reference).unwrap().id, + "default_model" + ); + } + + #[test] + fn catalog_from_settings_validates_model_controls_and_speed_costs() { + let invalid_effort = minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai" + +[models.model] +provider = "test" +display_name = "Model" +family = "test" + +[models.model.limits] +context_window = 1000 + +[models.model.features] +tools = false +vision = false +reasoning = true +effort = true + +[models.model.controls] +reasoning_effort = ["turbo"] +"#, + ); + assert!(matches!( + Catalog::from_settings(&invalid_effort).unwrap_err(), + CatalogBuildError::InvalidReasoningEffort { model, value, .. } + if model == "model" && value == "turbo" + )); + + let undeclared_speed_cost = minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "anthropic" + +[models.model] +provider = "test" +display_name = "Model" +family = "test" + +[models.model.limits] +context_window = 1000 + +[models.model.features] +tools = false +vision = false +reasoning = false + +[models.model.costs.speed.fast] +input_cost_per_mtok = 1.0 +"#, + ); + assert!(matches!( + Catalog::from_settings(&undeclared_speed_cost).unwrap_err(), + CatalogBuildError::UndeclaredSpeedCost { model, speed } + if model == "model" && speed == Speed::Fast + )); + } + // ---- Provider / catalog data integrity tests ---- #[test] fn every_provider_has_catalog_models() { for &provider in Provider::ALL { - let models = Catalog::builtin().list(Some(provider)); + let models = Catalog::builtin().list(Some(&provider.id())); assert!( !models.is_empty(), "Provider {provider:?} has no models in catalog" @@ -403,7 +1992,7 @@ mod tests { fn every_provider_has_exactly_one_default_model() { for &provider in Provider::ALL { let defaults: Vec<_> = Catalog::builtin() - .list(Some(provider)) + .list(Some(&provider.id())) .into_iter() .filter(|m| m.default) .collect(); @@ -421,11 +2010,15 @@ mod tests { #[test] fn catalog_providers_roundtrip_through_static_str() { for model in Catalog::builtin().list(None) { - let roundtripped = Provider::from_str(<&'static str>::from(model.provider)); + let roundtripped = Provider::from_id(&model.provider); assert_eq!( roundtripped, - Ok(model.provider), - "catalog model '{}' provider {:?} does not roundtrip through IntoStaticStr", + Some( + model + .builtin_provider() + .expect("catalog model provider should be a built-in provider") + ), + "catalog model '{}' provider {:?} does not roundtrip through ProviderId", model.id, model.provider ); @@ -452,7 +2045,7 @@ mod tests { insta::assert_debug_snapshot!(info, @r#" Model { id: "claude-opus-4-6", - provider: Anthropic, + provider: anthropic, family: "claude-4", display_name: "Claude Opus 4.6", limits: ModelLimits { @@ -520,7 +2113,7 @@ mod tests { insta::assert_debug_snapshot!(m, @r#" Model { id: "gemini-3.1-flash-lite-preview", - provider: Gemini, + provider: gemini, family: "gemini-3", display_name: "Gemini 3.1 Flash Lite (Preview)", limits: ModelLimits { @@ -578,7 +2171,7 @@ mod tests { insta::assert_debug_snapshot!(m, @r#" Model { id: "kimi-k2.5", - provider: Kimi, + provider: kimi, family: "kimi-k2", display_name: "Kimi K2.5", limits: ModelLimits { @@ -628,13 +2221,13 @@ mod tests { #[test] fn glm_4_7_in_catalog() { let m = Catalog::builtin().get("glm-4.7").unwrap(); - assert_eq!(m.provider, Provider::Zai); + assert_eq!(m.provider, Provider::Zai.id()); } #[test] fn minimax_m2_5_in_catalog() { let m = Catalog::builtin().get("minimax-m2.5").unwrap(); - assert_eq!(m.provider, Provider::Minimax); + assert_eq!(m.provider, Provider::Minimax.id()); } #[test] @@ -643,7 +2236,7 @@ mod tests { insta::assert_debug_snapshot!(m, @r#" Model { id: "mercury-2", - provider: Inception, + provider: inception, family: "mercury", display_name: "Mercury 2", limits: ModelLimits { @@ -692,7 +2285,7 @@ mod tests { insta::assert_debug_snapshot!(m, @r#" Model { id: "gpt-5.4", - provider: OpenAi, + provider: openai, family: "gpt-5", display_name: "GPT-5.4", limits: ModelLimits { @@ -743,7 +2336,7 @@ mod tests { insta::assert_debug_snapshot!(m, @r#" Model { id: "gpt-5.4-pro", - provider: OpenAi, + provider: openai, family: "gpt-5", display_name: "GPT-5.4 Pro", limits: ModelLimits { @@ -820,7 +2413,7 @@ mod tests { insta::assert_debug_snapshot!(m, @r#" Model { id: "gpt-5.3-codex-spark", - provider: OpenAi, + provider: openai, family: "gpt-5", display_name: "GPT-5.3 Codex Spark", limits: ModelLimits { @@ -872,7 +2465,7 @@ mod tests { fn closest_model_sonnet_to_gemini() { let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); let result = Catalog::builtin() - .closest(Provider::Gemini, sonnet) + .closest(&Provider::Gemini.id(), sonnet) .unwrap(); assert_eq!(result.id, "gemini-3.1-pro-preview"); } @@ -880,14 +2473,20 @@ mod tests { #[test] fn closest_model_haiku_to_kimi() { let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); - let result = Catalog::builtin().closest(Provider::Kimi, haiku).unwrap(); + let result = Catalog::builtin() + .closest(&Provider::Kimi.id(), haiku) + .unwrap(); assert_eq!(result.id, "kimi-k2.5"); } #[test] fn closest_model_no_capability_match() { let glm = Catalog::builtin().get("glm-4.7").unwrap(); - assert!(Catalog::builtin().closest(Provider::Gemini, glm).is_none()); + assert!( + Catalog::builtin() + .closest(&Provider::Gemini.id(), glm) + .is_none() + ); } // ---- Cost tests ---- diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml new file mode 100644 index 000000000..0b2ec120f --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml @@ -0,0 +1,127 @@ +[providers.anthropic] +display_name = "Anthropic" +adapter = "anthropic" +credentials = ["credential:anthropic", "env:ANTHROPIC_API_KEY"] +priority = 100 + +[models."claude-opus-4-7"] +provider = "anthropic" +api_id = "claude-opus-4-7" +display_name = "Claude Opus 4.7" +family = "claude-4" +training = "2025-08-01" +knowledge_cutoff = "May 2025" +estimated_output_tps = 25 +aliases = ["opus", "claude-opus"] + +[models."claude-opus-4-7".limits] +context_window = 1000000 +max_output = 128000 + +[models."claude-opus-4-7".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."claude-opus-4-7".costs] +input_cost_per_mtok = 5.0 +output_cost_per_mtok = 25.0 +cache_input_cost_per_mtok = 0.5 + +[models."claude-opus-4-6"] +provider = "anthropic" +api_id = "claude-opus-4-6" +display_name = "Claude Opus 4.6" +family = "claude-4" +training = "2025-08-01" +knowledge_cutoff = "May 2025" +estimated_output_tps = 25 + +[models."claude-opus-4-6".limits] +context_window = 1000000 +max_output = 128000 + +[models."claude-opus-4-6".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."claude-opus-4-6".costs] +input_cost_per_mtok = 5.0 +output_cost_per_mtok = 25.0 +cache_input_cost_per_mtok = 0.5 + +[models."claude-sonnet-4-5"] +provider = "anthropic" +api_id = "claude-sonnet-4-5" +display_name = "Claude Sonnet 4.5" +family = "claude-4" +training = "2025-08-01" +knowledge_cutoff = "May 2025" +estimated_output_tps = 50 + +[models."claude-sonnet-4-5".limits] +context_window = 200000 +max_output = 64000 + +[models."claude-sonnet-4-5".features] +tools = true +vision = true +reasoning = true + +[models."claude-sonnet-4-5".costs] +input_cost_per_mtok = 3.0 +output_cost_per_mtok = 15.0 +cache_input_cost_per_mtok = 0.3 + +[models."claude-sonnet-4-6"] +provider = "anthropic" +api_id = "claude-sonnet-4-6" +display_name = "Claude Sonnet 4.6" +family = "claude-4" +training = "2025-08-01" +knowledge_cutoff = "May 2025" +default = true +estimated_output_tps = 50 +aliases = ["sonnet", "claude-sonnet"] + +[models."claude-sonnet-4-6".limits] +context_window = 200000 +max_output = 64000 + +[models."claude-sonnet-4-6".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."claude-sonnet-4-6".costs] +input_cost_per_mtok = 3.0 +output_cost_per_mtok = 15.0 +cache_input_cost_per_mtok = 0.3 + +[models."claude-haiku-4-5"] +provider = "anthropic" +api_id = "claude-haiku-4-5" +display_name = "Claude Haiku 4.5" +family = "claude-4" +training = "2025-08-01" +knowledge_cutoff = "May 2025" +estimated_output_tps = 100 +aliases = ["haiku", "claude-haiku"] + +[models."claude-haiku-4-5".limits] +context_window = 200000 +max_output = 8192 + +[models."claude-haiku-4-5".features] +tools = true +vision = true +reasoning = false + +[models."claude-haiku-4-5".costs] +input_cost_per_mtok = 0.8 +output_cost_per_mtok = 4.0 +cache_input_cost_per_mtok = 0.08 diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml new file mode 100644 index 000000000..441e3c46d --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml @@ -0,0 +1,106 @@ +[providers.gemini] +display_name = "Gemini" +adapter = "gemini" +credentials = ["credential:gemini", "env:GEMINI_API_KEY", "env:GOOGLE_API_KEY"] +priority = 80 + +[models."gemini-3.1-pro-preview"] +provider = "gemini" +api_id = "gemini-3.1-pro-preview" +display_name = "Gemini 3.1 Pro (Preview)" +family = "gemini-3" +training = "2025-01-01" +knowledge_cutoff = "January 2025" +default = true +estimated_output_tps = 85 +aliases = ["gemini-pro"] + +[models."gemini-3.1-pro-preview".limits] +context_window = 1048576 +max_output = 65536 + +[models."gemini-3.1-pro-preview".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gemini-3.1-pro-preview".costs] +input_cost_per_mtok = 2.0 +output_cost_per_mtok = 12.0 +cache_input_cost_per_mtok = 0.5 + +[models."gemini-3.1-pro-preview-customtools"] +provider = "gemini" +api_id = "gemini-3.1-pro-preview-customtools" +display_name = "Gemini 3.1 Pro Custom Tools (Preview)" +family = "gemini-3" +training = "2025-01-01" +knowledge_cutoff = "January 2025" +estimated_output_tps = 85 +aliases = ["gemini-customtools"] + +[models."gemini-3.1-pro-preview-customtools".limits] +context_window = 1048576 +max_output = 65536 + +[models."gemini-3.1-pro-preview-customtools".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gemini-3.1-pro-preview-customtools".costs] +input_cost_per_mtok = 2.0 +output_cost_per_mtok = 12.0 +cache_input_cost_per_mtok = 0.5 + +[models."gemini-3-flash-preview"] +provider = "gemini" +api_id = "gemini-3-flash-preview" +display_name = "Gemini 3 Flash (Preview)" +family = "gemini-3" +training = "2025-01-01" +knowledge_cutoff = "January 2025" +estimated_output_tps = 150 +aliases = ["gemini-flash"] + +[models."gemini-3-flash-preview".limits] +context_window = 1048576 +max_output = 65536 + +[models."gemini-3-flash-preview".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gemini-3-flash-preview".costs] +input_cost_per_mtok = 0.5 +output_cost_per_mtok = 3.0 +cache_input_cost_per_mtok = 0.125 + +[models."gemini-3.1-flash-lite-preview"] +provider = "gemini" +api_id = "gemini-3.1-flash-lite-preview" +display_name = "Gemini 3.1 Flash Lite (Preview)" +family = "gemini-3" +training = "2025-01-01" +knowledge_cutoff = "January 2025" +estimated_output_tps = 200 +aliases = ["gemini-flash-lite"] + +[models."gemini-3.1-flash-lite-preview".limits] +context_window = 1048576 +max_output = 65536 + +[models."gemini-3.1-flash-lite-preview".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gemini-3.1-flash-lite-preview".costs] +input_cost_per_mtok = 0.25 +output_cost_per_mtok = 1.5 +cache_input_cost_per_mtok = 0.0625 diff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/crates/fabro-model/src/catalog/providers/inception.toml new file mode 100644 index 000000000..591e76237 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/inception.toml @@ -0,0 +1,30 @@ +[providers.inception] +display_name = "Inception" +adapter = "openai_compatible" +base_url = "https://api.inceptionlabs.ai/v1" +credentials = ["credential:inception", "env:INCEPTION_API_KEY"] +priority = 40 +aliases = ["inception_labs"] + +[models."mercury-2"] +provider = "inception" +api_id = "mercury-2" +display_name = "Mercury 2" +family = "mercury" +default = true +estimated_output_tps = 1000 +aliases = ["mercury"] + +[models."mercury-2".limits] +context_window = 131072 +max_output = 50000 + +[models."mercury-2".features] +tools = true +vision = false +reasoning = true +effort = true + +[models."mercury-2".costs] +input_cost_per_mtok = 0.25 +output_cost_per_mtok = 0.75 diff --git a/lib/crates/fabro-model/src/catalog/providers/kimi.toml b/lib/crates/fabro-model/src/catalog/providers/kimi.toml new file mode 100644 index 000000000..b9be19ad5 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/kimi.toml @@ -0,0 +1,30 @@ +[providers.kimi] +display_name = "Kimi" +adapter = "openai_compatible" +base_url = "https://api.moonshot.ai/v1" +credentials = ["credential:kimi", "env:KIMI_API_KEY"] +priority = 70 + +[models."kimi-k2.5"] +provider = "kimi" +api_id = "kimi-k2.5" +display_name = "Kimi K2.5" +family = "kimi-k2" +training = "2025-10-01" +knowledge_cutoff = "October 2025" +default = true +estimated_output_tps = 50 +aliases = ["kimi"] + +[models."kimi-k2.5".limits] +context_window = 262144 +max_output = 16000 + +[models."kimi-k2.5".features] +tools = true +vision = true +reasoning = false + +[models."kimi-k2.5".costs] +input_cost_per_mtok = 0.6 +output_cost_per_mtok = 3.0 diff --git a/lib/crates/fabro-model/src/catalog/providers/minimax.toml b/lib/crates/fabro-model/src/catalog/providers/minimax.toml new file mode 100644 index 000000000..1a11c1395 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/minimax.toml @@ -0,0 +1,28 @@ +[providers.minimax] +display_name = "MiniMax" +adapter = "openai_compatible" +base_url = "https://api.minimax.io/v1" +credentials = ["credential:minimax", "env:MINIMAX_API_KEY"] +priority = 50 + +[models."minimax-m2.5"] +provider = "minimax" +api_id = "minimax-m2.5" +display_name = "Minimax M2.5" +family = "minimax-m2" +default = true +estimated_output_tps = 45 +aliases = ["minimax"] + +[models."minimax-m2.5".limits] +context_window = 196608 +max_output = 16384 + +[models."minimax-m2.5".features] +tools = true +vision = false +reasoning = false + +[models."minimax-m2.5".costs] +input_cost_per_mtok = 0.3 +output_cost_per_mtok = 1.2 diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml new file mode 100644 index 000000000..954c8a6bc --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml @@ -0,0 +1,251 @@ +[providers.openai] +display_name = "OpenAI" +adapter = "openai" +credentials = ["credential:openai", "credential:openai_codex", "env:OPENAI_API_KEY"] +priority = 90 +aliases = ["open_ai"] + +[models."gpt-5.2"] +provider = "openai" +api_id = "gpt-5.2" +display_name = "GPT-5.2" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 65 +aliases = ["gpt5"] + +[models."gpt-5.2".limits] +context_window = 1047576 +max_output = 128000 + +[models."gpt-5.2".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.2".costs] +input_cost_per_mtok = 1.75 +output_cost_per_mtok = 14.0 +cache_input_cost_per_mtok = 0.175 + +[models."gpt-5-mini"] +provider = "openai" +api_id = "gpt-5-mini" +display_name = "GPT-5 Mini" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 70 +aliases = ["gpt5-mini"] + +[models."gpt-5-mini".limits] +context_window = 1047576 +max_output = 128000 + +[models."gpt-5-mini".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5-mini".costs] +input_cost_per_mtok = 0.25 +output_cost_per_mtok = 2.0 +cache_input_cost_per_mtok = 0.025 + +[models."gpt-5.2-codex"] +provider = "openai" +api_id = "gpt-5.2-codex" +display_name = "GPT-5.2 Codex" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 100 + +[models."gpt-5.2-codex".limits] +context_window = 1047576 +max_output = 128000 + +[models."gpt-5.2-codex".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.2-codex".costs] +input_cost_per_mtok = 1.75 +output_cost_per_mtok = 14.0 +cache_input_cost_per_mtok = 0.175 + +[models."gpt-5.3-codex"] +provider = "openai" +api_id = "gpt-5.3-codex" +display_name = "GPT-5.3 Codex" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 100 +aliases = ["codex"] + +[models."gpt-5.3-codex".limits] +context_window = 1047576 +max_output = 128000 + +[models."gpt-5.3-codex".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.3-codex".costs] +input_cost_per_mtok = 1.75 +output_cost_per_mtok = 14.0 +cache_input_cost_per_mtok = 0.175 + +[models."gpt-5.3-codex-spark"] +provider = "openai" +api_id = "gpt-5.3-codex-spark" +display_name = "GPT-5.3 Codex Spark" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 1000 +aliases = ["codex-spark"] + +[models."gpt-5.3-codex-spark".limits] +context_window = 131072 +max_output = 128000 + +[models."gpt-5.3-codex-spark".features] +tools = true +vision = false +reasoning = true +effort = true + +[models."gpt-5.4"] +provider = "openai" +api_id = "gpt-5.4" +display_name = "GPT-5.4" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +default = true +estimated_output_tps = 70 +aliases = ["gpt54", "gpt-54"] + +[models."gpt-5.4".limits] +context_window = 1047576 +max_output = 128000 + +[models."gpt-5.4".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.4".costs] +input_cost_per_mtok = 2.5 +output_cost_per_mtok = 15.0 +cache_input_cost_per_mtok = 0.25 + +[models."gpt-5.5"] +provider = "openai" +api_id = "gpt-5.5" +display_name = "GPT-5.5" +family = "gpt-5" +training = "2025-12-01" +knowledge_cutoff = "December 2025" +estimated_output_tps = 70 +aliases = ["gpt55", "gpt-55"] + +[models."gpt-5.5".limits] +context_window = 1050000 +max_output = 128000 + +[models."gpt-5.5".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.5".costs] +input_cost_per_mtok = 5.0 +output_cost_per_mtok = 30.0 +cache_input_cost_per_mtok = 0.5 + +[models."gpt-5.5-pro"] +provider = "openai" +api_id = "gpt-5.5-pro" +display_name = "GPT-5.5 Pro" +family = "gpt-5" +training = "2025-12-01" +knowledge_cutoff = "December 2025" +estimated_output_tps = 20 +aliases = ["gpt55-pro", "gpt-55-pro"] + +[models."gpt-5.5-pro".limits] +context_window = 1050000 +max_output = 128000 + +[models."gpt-5.5-pro".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.5-pro".costs] +input_cost_per_mtok = 30.0 +output_cost_per_mtok = 180.0 +cache_input_cost_per_mtok = 3.0 + +[models."gpt-5.4-pro"] +provider = "openai" +api_id = "gpt-5.4-pro" +display_name = "GPT-5.4 Pro" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 20 +aliases = ["gpt54-pro", "gpt-54-pro"] + +[models."gpt-5.4-pro".limits] +context_window = 1047576 +max_output = 128000 + +[models."gpt-5.4-pro".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.4-pro".costs] +input_cost_per_mtok = 30.0 +output_cost_per_mtok = 180.0 +cache_input_cost_per_mtok = 3.0 + +[models."gpt-5.4-mini"] +provider = "openai" +api_id = "gpt-5.4-mini" +display_name = "GPT-5.4 Mini" +family = "gpt-5" +training = "2025-08-31" +knowledge_cutoff = "April 2025" +estimated_output_tps = 140 +aliases = ["gpt54-mini", "gpt-54-mini"] + +[models."gpt-5.4-mini".limits] +context_window = 400000 +max_output = 128000 + +[models."gpt-5.4-mini".features] +tools = true +vision = true +reasoning = true +effort = true + +[models."gpt-5.4-mini".costs] +input_cost_per_mtok = 0.75 +output_cost_per_mtok = 4.5 +cache_input_cost_per_mtok = 0.075 diff --git a/lib/crates/fabro-model/src/catalog/providers/zai.toml b/lib/crates/fabro-model/src/catalog/providers/zai.toml new file mode 100644 index 000000000..b7fac68c9 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/zai.toml @@ -0,0 +1,28 @@ +[providers.zai] +display_name = "Z.ai" +adapter = "openai_compatible" +base_url = "https://api.z.ai/api/coding/paas/v4" +credentials = ["credential:zai", "env:ZAI_API_KEY"] +priority = 60 + +[models."glm-4.7"] +provider = "zai" +api_id = "glm-4.7" +display_name = "GLM 4.7" +family = "glm-4" +default = true +estimated_output_tps = 100 +aliases = ["glm", "glm4"] + +[models."glm-4.7".limits] +context_window = 202752 +max_output = 16384 + +[models."glm-4.7".features] +tools = true +vision = false +reasoning = false + +[models."glm-4.7".costs] +input_cost_per_mtok = 0.6 +output_cost_per_mtok = 2.2 diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs index 0d10da414..9f9fcabaa 100644 --- a/lib/crates/fabro-model/src/ids.rs +++ b/lib/crates/fabro-model/src/ids.rs @@ -13,11 +13,20 @@ use serde::{Deserialize, Serialize}; /// /// Wraps a `String` because the set of providers is open-ended and supplied /// by `[llm.providers]` settings rather than compiled into a Rust enum. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct ProviderId(String); impl ProviderId { + pub const ANTHROPIC: &'static str = "anthropic"; + pub const OPENAI: &'static str = "openai"; + pub const GEMINI: &'static str = "gemini"; + pub const KIMI: &'static str = "kimi"; + pub const ZAI: &'static str = "zai"; + pub const MINIMAX: &'static str = "minimax"; + pub const INCEPTION: &'static str = "inception"; + pub const OPENAI_COMPATIBLE: &'static str = "openai_compatible"; + /// Construct a provider ID from any string-like value without validation. /// Catalog construction is responsible for canonicalisation; consumers /// only need a wrapper for type clarity. @@ -36,6 +45,46 @@ impl ProviderId { pub fn into_inner(self) -> String { self.0 } + + #[must_use] + pub fn anthropic() -> Self { + Self::new(Self::ANTHROPIC) + } + + #[must_use] + pub fn openai() -> Self { + Self::new(Self::OPENAI) + } + + #[must_use] + pub fn gemini() -> Self { + Self::new(Self::GEMINI) + } + + #[must_use] + pub fn kimi() -> Self { + Self::new(Self::KIMI) + } + + #[must_use] + pub fn zai() -> Self { + Self::new(Self::ZAI) + } + + #[must_use] + pub fn minimax() -> Self { + Self::new(Self::MINIMAX) + } + + #[must_use] + pub fn inception() -> Self { + Self::new(Self::INCEPTION) + } + + #[must_use] + pub fn openai_compatible() -> Self { + Self::new(Self::OPENAI_COMPATIBLE) + } } impl fmt::Display for ProviderId { @@ -44,6 +93,12 @@ impl fmt::Display for ProviderId { } } +impl fmt::Debug for ProviderId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + impl From<&str> for ProviderId { fn from(s: &str) -> Self { Self(s.to_string()) diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 481929cf2..516d49df8 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -1,5 +1,6 @@ pub mod adapter; pub mod billing; +pub mod bootstrap_catalog; pub mod catalog; pub mod ids; pub mod model_ref; @@ -17,7 +18,9 @@ pub use billing::{ ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage, OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, }; -pub use catalog::{Catalog, FallbackTarget}; +pub use catalog::{ + Catalog, CredentialRef, CredentialRefParseError, FallbackTarget, HeaderValueRef, +}; pub use ids::{ModelId, ProviderId}; pub use model_ref::ModelHandle; pub use model_test::ModelTestMode; diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/crates/fabro-model/src/model_ref.rs index 96cbced9b..90e643c35 100644 --- a/lib/crates/fabro-model/src/model_ref.rs +++ b/lib/crates/fabro-model/src/model_ref.rs @@ -1,7 +1,7 @@ use std::fmt; use std::sync::Arc; -use crate::provider::Provider; +use crate::ids::ProviderId; use crate::types::Model; /// A reference to a model β€” either a fully resolved `Model` or a @@ -12,7 +12,7 @@ pub enum ModelHandle { Resolved(Arc), /// An unresolved provider:model pair (e.g. from CLI input or config). ByName { - provider: Provider, + provider: ProviderId, model: String, }, } @@ -29,10 +29,10 @@ impl ModelHandle { /// The provider for this model. #[must_use] - pub fn provider(&self) -> Provider { + pub fn provider(&self) -> &ProviderId { match self { - Self::Resolved(m) => m.provider, - Self::ByName { provider, .. } => *provider, + Self::Resolved(m) => &m.provider, + Self::ByName { provider, .. } => provider, } } } @@ -60,11 +60,12 @@ impl fmt::Debug for ModelHandle { mod tests { use super::*; use crate::catalog::Catalog; + use crate::provider::Provider; #[test] fn by_name_display() { let r = ModelHandle::ByName { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), model: "claude-opus-4-6".to_string(), }; assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); @@ -73,11 +74,11 @@ mod tests { #[test] fn by_name_accessors() { let r = ModelHandle::ByName { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model: "gpt-5.4".to_string(), }; assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), Provider::OpenAi); + assert_eq!(r.provider(), &Provider::OpenAi.id()); } #[test] @@ -92,17 +93,17 @@ mod tests { let info = Catalog::builtin().get("gpt-5.4").unwrap().clone(); let r = ModelHandle::Resolved(Arc::new(info)); assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), Provider::OpenAi); + assert_eq!(r.provider(), &Provider::OpenAi.id()); } #[test] fn debug_format() { let r = ModelHandle::ByName { - provider: Provider::Gemini, + provider: Provider::Gemini.id(), model: "gemini-3.1-pro-preview".to_string(), }; let debug = format!("{r:?}"); assert!(debug.contains("ByName")); - assert!(debug.contains("Gemini")); + assert!(debug.contains("gemini")); } } diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/crates/fabro-model/src/provider.rs index 0e6f0403e..2e50969f0 100644 --- a/lib/crates/fabro-model/src/provider.rs +++ b/lib/crates/fabro-model/src/provider.rs @@ -2,6 +2,8 @@ use fabro_static::EnvVars; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; +use crate::ids::ProviderId; + // --------------------------------------------------------------------------- // Provider enum β€” compile-time safe provider identity // --------------------------------------------------------------------------- @@ -39,6 +41,16 @@ pub enum Provider { } impl Provider { + #[must_use] + pub fn id(self) -> ProviderId { + ProviderId::from(<&'static str>::from(self)) + } + + #[must_use] + pub fn from_id(id: &ProviderId) -> Option { + id.as_str().parse().ok() + } + /// All known provider variants, for use in guardrail tests and iteration. pub const ALL: &[Self] = &[ Self::Anthropic, @@ -120,6 +132,20 @@ impl Provider { Self::OpenAiCompatible => "OpenAI Compatible", } } + + #[must_use] + pub fn display_name_for_id(id: &ProviderId) -> String { + Self::from_id(id).map_or_else( + || id.to_string(), + |provider| provider.display_name().to_string(), + ) + } +} + +impl From for ProviderId { + fn from(provider: Provider) -> Self { + provider.id() + } } #[cfg(test)] @@ -131,6 +157,30 @@ mod tests { assert_eq!("kimi".parse::().unwrap(), Provider::Kimi); } + #[test] + fn provider_id_preserves_canonical_builtin_strings() { + assert_eq!(Provider::Anthropic.id().as_str(), ProviderId::ANTHROPIC); + assert_eq!(Provider::OpenAi.id().as_str(), ProviderId::OPENAI); + assert_eq!(Provider::Gemini.id().as_str(), ProviderId::GEMINI); + assert_eq!(Provider::Kimi.id().as_str(), ProviderId::KIMI); + assert_eq!(Provider::Zai.id().as_str(), ProviderId::ZAI); + assert_eq!(Provider::Minimax.id().as_str(), ProviderId::MINIMAX); + assert_eq!(Provider::Inception.id().as_str(), ProviderId::INCEPTION); + assert_eq!( + Provider::OpenAiCompatible.id().as_str(), + ProviderId::OPENAI_COMPATIBLE, + ); + } + + #[test] + fn provider_from_id_accepts_builtins_and_rejects_custom_ids() { + assert_eq!( + Provider::from_id(&ProviderId::openai()), + Some(Provider::OpenAi) + ); + assert_eq!(Provider::from_id(&ProviderId::new("venice")), None); + } + #[test] fn parse_zai() { assert_eq!("zai".parse::().unwrap(), Provider::Zai); diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs index cd96efca8..a7ab95d0b 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/fabro-model/src/types.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +use crate::ids::ProviderId; use crate::provider::Provider; // --- 2.9 Model --- @@ -34,7 +35,7 @@ pub struct ModelCosts { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Model { pub id: String, - pub provider: Provider, + pub provider: ProviderId, pub family: String, pub display_name: String, pub limits: ModelLimits, @@ -58,8 +59,12 @@ impl Model { &self.id } - pub fn provider(&self) -> Provider { - self.provider + pub fn provider(&self) -> &ProviderId { + &self.provider + } + + pub fn builtin_provider(&self) -> Option { + Provider::from_id(&self.provider) } pub fn family(&self) -> &str { @@ -136,7 +141,7 @@ mod tests { fn inherent_methods_return_correct_values() { let info = Catalog::builtin().get("claude-opus-4-7").unwrap(); assert_eq!(info.id(), "claude-opus-4-7"); - assert_eq!(info.provider(), Provider::Anthropic); + assert_eq!(info.provider(), &Provider::Anthropic.id()); assert_eq!(info.family(), "claude-4"); assert_eq!(info.display_name(), "Claude Opus 4.7"); assert_eq!(info.context_window(), 1_000_000); @@ -158,8 +163,7 @@ mod tests { #[test] fn all_catalog_providers_are_valid() { for model in Catalog::builtin().list(None) { - // provider() just returns the Provider enum, no parsing needed - let _ = model.provider(); + assert!(model.builtin_provider().is_some()); } } } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 43ef520f7..366230664 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1373,7 +1373,7 @@ mod runs { EventBody::AgentMessage(AgentMessageProps { text: "I'll start by loading the environment configurations for both production and staging to compare them.".into(), model: fabro_model::ModelRef { - provider: fabro_model::Provider::Anthropic, + provider: fabro_model::Provider::Anthropic.id(), model_id: "claude-opus-4-6".into(), speed: None, }, @@ -1430,7 +1430,7 @@ mod runs { EventBody::AgentMessage(AgentMessageProps { text: "I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** β€” production has 200, staging has 100\n2. **redis.tls** β€” enabled in production, disabled in staging\n3. **iam.session_duration** β€” production uses 3600s, staging uses 1800s".into(), model: fabro_model::ModelRef { - provider: fabro_model::Provider::Anthropic, + provider: fabro_model::Provider::Anthropic.id(), model_id: "claude-opus-4-6".into(), speed: None, }, diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 414c87852..374d2919a 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -104,9 +104,9 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { let mut details: Vec = Vec::new(); let mut failures: Vec = Vec::new(); for (provider, issue) in &result.auth_issues { - let message = auth_issue_message(*provider, issue); + let message = auth_issue_message(provider, issue); failures.push(ProviderFailure { - provider: *provider, + provider: provider.to_string(), summary_line: short_error_line(&message), }); details.push(CheckDetail::new(message)); @@ -134,14 +134,14 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { Ok(Err(err)) => { let rendered = collect_chain(&err).join(": "); failures.push(ProviderFailure { - provider, + provider: provider.to_string(), summary_line: short_error_line(&rendered), }); details.push(CheckDetail::new(format!("{provider}: {rendered}"))); } Err(_) => { failures.push(ProviderFailure { - provider, + provider: provider.to_string(), summary_line: "timeout (30s)".to_string(), }); details.push(CheckDetail::new(format!("{provider}: timeout (30s)"))); @@ -180,7 +180,7 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { } struct ProviderFailure { - provider: Provider, + provider: String, summary_line: String, } @@ -717,7 +717,7 @@ mod tests { }, ); let credential = AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: "vault-openai-key".to_string(), }, diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 38c22accb..a79c629df 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -1530,7 +1530,7 @@ async fn post_install_finish( } for provider in llm.providers { let credential = AuthCredential { - provider: provider.provider, + provider: provider.provider.id(), details: AuthDetails::ApiKey { key: provider.api_key, }, diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 225a9139b..a212010ea 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -14,9 +14,8 @@ use fabro_config::{ }; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; -use fabro_llm::Provider; use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe}; -use fabro_model::Catalog; +use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::config::{ DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource, }; @@ -170,7 +169,7 @@ pub(crate) fn validate_prepared_manifest( pub(crate) fn create_run_input( prepared: PreparedManifest, - configured_providers: Vec, + configured_providers: Vec, web_url: Option, ) -> CreateRunInput { CreateRunInput { @@ -880,7 +879,6 @@ struct PendingModelProbe { index: usize, model_id: String, provider_name: String, - provider: Provider, } async fn run_llm_check( @@ -888,7 +886,7 @@ async fn run_llm_check( checks: &mut Vec, graph: &Graph, settings: &RunNamespace, - configured_providers: &[Provider], + configured_providers: &[ProviderId], ) -> bool { let (model, provider) = resolve_model_provider(settings, graph, configured_providers); let default_provider = provider.as_deref().unwrap_or("anthropic"); @@ -945,58 +943,36 @@ async fn run_llm_check( let mut completed_checks: Vec<(usize, CheckResult)> = Vec::new(); let mut pending_probes = Vec::new(); for (index, (model_id, provider_name)) in model_providers.iter().enumerate() { - match provider_name.parse::() { - Ok(provider) => { - if let Some((_, issue)) = auth_issues - .iter() - .find(|(candidate, _)| *candidate == provider) - { - all_ok = false; - completed_checks.push((index, CheckResult { - name: "LLM".into(), - status: CheckStatus::Warning, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!( - "Provider: {provider_name}" - ))], - remediation: Some(auth_issue_message(provider, issue)), - })); - } else if !configured.iter().any(|name| name == provider_name) { - all_ok = false; - completed_checks.push((index, CheckResult { - name: "LLM".into(), - status: CheckStatus::Warning, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!( - "Provider: {provider_name}" - ))], - remediation: Some(format!( - "Provider \"{provider_name}\" is not configured" - )), - })); - } else { - pending_probes.push(PendingModelProbe { - index, - model_id: model_id.clone(), - provider_name: provider_name.clone(), - provider, - }); - } - } - Err(err) => { - all_ok = false; - completed_checks.push((index, CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!( - "Provider: {provider_name}" - ))], - remediation: Some(format!( - "Invalid provider \"{provider_name}\": {err}" - )), - })); - } + let provider_id = ProviderId::from(provider_name.as_str()); + if let Some((_, issue)) = auth_issues + .iter() + .find(|(candidate, _)| candidate == &provider_id) + { + all_ok = false; + completed_checks.push((index, CheckResult { + name: "LLM".into(), + status: CheckStatus::Warning, + summary: model_id.clone(), + details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], + remediation: Some(auth_issue_message(&provider_id, issue)), + })); + } else if !configured.iter().any(|name| name == provider_name) { + all_ok = false; + completed_checks.push((index, CheckResult { + name: "LLM".into(), + status: CheckStatus::Warning, + summary: model_id.clone(), + details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], + remediation: Some(format!( + "Provider \"{provider_name}\" is not configured" + )), + })); + } else { + pending_probes.push(PendingModelProbe { + index, + model_id: model_id.clone(), + provider_name: provider_name.clone(), + }); } } @@ -1005,7 +981,8 @@ async fn run_llm_check( let client = Arc::clone(&client); async move { let outcome = - run_basic_model_probe(&probe.model_id, probe.provider, client).await; + run_basic_model_probe(&probe.model_id, &probe.provider_name, client) + .await; let (status, remediation) = if outcome.status == ModelTestStatus::Ok { (CheckStatus::Pass, None) } else { @@ -1062,7 +1039,7 @@ async fn run_llm_check( fn resolve_model_provider( settings: &RunNamespace, _graph: &Graph, - configured_providers: &[Provider], + configured_providers: &[ProviderId], ) -> (String, Option) { let provider = settings .model @@ -1072,7 +1049,7 @@ fn resolve_model_provider( let model = settings.model.name.as_ref().map_or_else( || { Catalog::builtin() - .default_for_configured(configured_providers) + .default_for_configured_ids(configured_providers) .id .clone() }, @@ -1298,6 +1275,8 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport { #[cfg(test)] mod tests { + use fabro_model::Provider; + use super::*; fn minimal_manifest() -> types::RunManifest { @@ -1413,7 +1392,7 @@ enabled = {clone_enabled} prepared.settings.clone(), validated.graph(), Catalog::builtin(), - &[Provider::Anthropic], + &[Provider::Anthropic.id()], ) .run; @@ -1968,7 +1947,7 @@ provider = "daytona" .set( "openai", &serde_json::to_string(&fabro_auth::AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: fabro_auth::AuthDetails::ApiKey { key: "test-openai-key".to_string(), }, @@ -2017,6 +1996,49 @@ digraph Demo { assert!(response_mock.calls_async().await >= 1); } + #[tokio::test] + async fn preflight_unknown_llm_provider_reports_not_configured() { + let state = crate::test_support::test_app_state(); + let mut manifest = minimal_manifest(); + manifest.workflows.get_mut("workflow.fabro").unwrap().source = r#" +digraph Demo { + start [shape=Mdiamond] + exit [shape=Msquare] + work [prompt="Do work", model="venice-model", provider="venice"] + start -> work -> exit +} +"# + .to_string(); + let prepared = prepare_manifest( + &manifest_run_defaults(Some(&default_settings_fixture())), + &manifest, + ) + .unwrap(); + let validated = validate_prepared_manifest(&prepared, RenderMode::Strict).unwrap(); + + let (response, ok) = run_preflight(state.as_ref(), &prepared, &validated) + .await + .unwrap(); + + assert!(!ok); + let llm_check = response.checks.sections[0] + .checks + .iter() + .find(|check| check.name == "LLM" && check.summary == "venice-model") + .expect("preflight should include the requested custom LLM provider"); + assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Warning); + assert_eq!( + llm_check.remediation.as_deref(), + Some("Provider \"venice\" is not configured") + ); + assert!( + llm_check + .details + .iter() + .any(|detail| detail.text == "Provider: venice") + ); + } + mod root_workflow_run_layer_tests { //! `root_workflow_run_layer` parses bundled workflow.toml through //! the strict `SettingsLayer` schema, so unknown fields anywhere in diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 1cefd3507..c8f513efa 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -56,7 +56,7 @@ use fabro_llm::types::{ ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest, Role, ToolChoice, ToolDefinition, }; -use fabro_model::{BilledTokenCounts, Catalog, ModelTestMode, Provider}; +use fabro_model::{BilledTokenCounts, Catalog, ModelTestMode, ProviderId}; use fabro_redact::redact_jsonl_line; use fabro_sandbox::daytona::{self, DaytonaSandbox}; use fabro_sandbox::details::sandbox_details; diff --git a/lib/crates/fabro-server/src/server/handler/completions.rs b/lib/crates/fabro-server/src/server/handler/completions.rs index 5c860079c..7eed023bf 100644 --- a/lib/crates/fabro-server/src/server/handler/completions.rs +++ b/lib/crates/fabro-server/src/server/handler/completions.rs @@ -87,8 +87,9 @@ async fn create_completion( let catalog_info = fabro_model::Catalog::builtin().get(&model_id); // Resolve provider: explicit request > catalog > None - let provider_name = req - .provider + let explicit_provider = req.provider; + let provider_name = explicit_provider + .clone() .or_else(|| catalog_info.map(|i| i.provider.to_string())); info!(model = %model_id, provider = ?provider_name, "Completion request received"); @@ -166,6 +167,12 @@ async fn create_completion( warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue"); } let client = llm_result.client; + if let Some(provider) = explicit_provider.as_deref() { + if !client.has_provider(provider) { + return ApiError::bad_request(format!("Provider \"{provider}\" is not configured")) + .into_response(); + } + } if use_stream { // Streaming path: forward all StreamEvents as SSE diff --git a/lib/crates/fabro-server/src/server/handler/models.rs b/lib/crates/fabro-server/src/server/handler/models.rs index 00ad1449e..1681c1f04 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/crates/fabro-server/src/server/handler/models.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use super::super::{ ApiError, AppState, FromStr, HashSet, IntoResponse, Json, MAX_PAGE_OFFSET, ModelTestMode, Path, - Provider, Query, RequiredUser, Response, Router, State, StatusCode, auth_issue_message, + ProviderId, Query, RequiredUser, Response, Router, State, StatusCode, auth_issue_message, default_page_limit, error, get, post, run_model_test, }; @@ -35,24 +35,12 @@ async fn list_models( State(state): State>, Query(params): Query, ) -> Response { - let provider = match params.provider.as_deref() { - Some(value) => match Provider::from_str(value) { - Ok(provider) => Some(provider), - Err(_) => { - return ApiError::new( - StatusCode::BAD_REQUEST, - format!("unknown provider: {value}"), - ) - .into_response(); - } - }, - None => None, - }; + let provider_id = params.provider.as_deref().map(ProviderId::from); let query = params.query.as_ref().map(|value| value.to_lowercase()); let limit = params.limit.clamp(1, 100) as usize; let offset = params.offset.min(MAX_PAGE_OFFSET) as usize; - let configured: HashSet = state + let configured: HashSet = state .llm_source .configured_providers() .await @@ -60,7 +48,7 @@ async fn list_models( .collect(); let mut models = fabro_model::Catalog::builtin() - .list(provider) + .list(provider_id.as_ref()) .into_iter() .filter(|model| match &query { Some(query) => { @@ -130,12 +118,12 @@ async fn test_model( if let Some((_, issue)) = llm_result .auth_issues .iter() - .find(|(provider, _)| *provider == info.provider) + .find(|(provider, _)| provider == &info.provider) { - return ApiError::bad_request(auth_issue_message(info.provider, issue)).into_response(); + return ApiError::bad_request(auth_issue_message(&info.provider, issue)).into_response(); } - let provider_name = <&'static str>::from(info.provider); - if !llm_result.client.provider_names().contains(&provider_name) { + let provider_name = info.provider.as_str(); + if !llm_result.client.has_provider(provider_name) { return Json(serde_json::json!({ "model_id": info.id, "status": "skip", diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/crates/fabro-server/src/server/handler/pull_requests.rs index ec4e927db..4c0a3d0ea 100644 --- a/lib/crates/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/crates/fabro-server/src/server/handler/pull_requests.rs @@ -249,7 +249,7 @@ async fn create_run_pull_request( } else { let configured = state.llm_source.configured_providers().await; Catalog::builtin() - .default_for_configured(&configured) + .default_for_configured_ids(&configured) .id .clone() }; diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index d4dc471bd..2dc425aae 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -185,7 +185,7 @@ async fn mock_daytona_current_key<'a>( fn openai_api_key_credential(key: &str) -> AuthCredential { AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: key.to_string(), }, @@ -1014,7 +1014,7 @@ async fn create_secret_stores_valid_credential_entries() { let state = test_app_state(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let credential = fabro_auth::AuthCredential { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), details: fabro_auth::AuthDetails::CodexOAuth { tokens: fabro_auth::OAuthTokens { access_token: "access".to_string(), @@ -1218,7 +1218,7 @@ impl CredentialSource for FailingCredentialSource { .context("credential source context")) } - async fn configured_providers(&self) -> Vec { + async fn configured_providers(&self) -> Vec { Vec::new() } } @@ -1263,7 +1263,7 @@ async fn llm_source_configured_providers_reads_openai_codex_from_vault() { .unwrap(); assert_eq!(state.llm_source.configured_providers().await, vec![ - Provider::OpenAi + Provider::OpenAi.id() ]); } @@ -3822,17 +3822,19 @@ async fn list_models_marks_configured_false_when_no_credential_material() { } #[tokio::test] -async fn list_models_invalid_provider_returns_400() { +async fn list_models_unknown_provider_returns_empty_page() { let app = test_app_with(); let req = Request::builder() .method("GET") - .uri(api("/models?provider=not-a-provider")) + .uri(api("/models?provider=venice")) .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_status!(response, StatusCode::BAD_REQUEST).await; + let body = response_json!(response, StatusCode::OK).await; + assert_eq!(body["data"].as_array().unwrap().len(), 0); + assert_eq!(body["meta"]["has_more"].as_bool(), Some(false)); } #[tokio::test] @@ -8160,7 +8162,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { by_model: vec![ fabro_workflow::ProjectionBillingByModel { model: ModelRef { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model_id: "gpt-old".to_string(), speed: None, }, @@ -8177,7 +8179,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { }, fabro_workflow::ProjectionBillingByModel { model: ModelRef { - provider: Provider::OpenAi, + provider: Provider::OpenAi.id(), model_id: "gpt-new".to_string(), speed: None, }, @@ -9091,6 +9093,38 @@ async fn create_completion_missing_messages_returns_422() { assert_status!(response, StatusCode::UNPROCESSABLE_ENTITY).await; } +#[tokio::test] +async fn create_completion_unknown_provider_returns_clear_error() { + let app = test_app_with(); + + let req = Request::builder() + .method("POST") + .uri(api("/completions")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "provider": "venice", + "model": "gpt-5.4", + "stream": false, + "messages": [ + { + "role": "user", + "content": [{"kind": "text", "data": "hi"}] + } + ] + }) + .to_string(), + )) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + let body = response_json!(response, StatusCode::BAD_REQUEST).await; + assert_eq!( + body["errors"][0]["detail"], + "Provider \"venice\" is not configured" + ); +} + #[tokio::test] async fn demo_boards_runs_returns_run_list_items() { let state = test_app_state(); diff --git a/lib/crates/fabro-server/src/server_secrets.rs b/lib/crates/fabro-server/src/server_secrets.rs index 64ce1bea2..17e19de4c 100644 --- a/lib/crates/fabro-server/src/server_secrets.rs +++ b/lib/crates/fabro-server/src/server_secrets.rs @@ -4,7 +4,7 @@ use std::path::Path; use fabro_auth::ResolveError; use fabro_config::envfile; use fabro_llm::client::Client; -use fabro_model::Provider; +use fabro_model::ProviderId; #[expect( clippy::disallowed_methods, @@ -58,7 +58,7 @@ impl std::fmt::Debug for ServerSecrets { pub(crate) struct LlmClientResult { pub client: Client, - pub auth_issues: Vec<(Provider, ResolveError)>, + pub auth_issues: Vec<(ProviderId, ResolveError)>, } #[cfg(test)] diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index c05135c1a..da2af76fc 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -552,12 +552,10 @@ fn event_body_from_event(event: &Event) -> EventBody { tool_call_count, } => { let requested_speed = model.speed.map(<&'static str>::from); - let billed = billed_model_usage_from_llm( - &model.model_id, - model.provider, - requested_speed, - usage, - ); + let provider = fabro_model::Provider::from_id(&model.provider) + .expect("agent message billing currently requires a built-in provider ID"); + let billed = + billed_model_usage_from_llm(&model.model_id, provider, requested_speed, usage); let billing = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&billed)); EventBody::AgentMessage(fabro_types::AgentMessageProps { text: text.clone(), @@ -1963,7 +1961,7 @@ mod tests { event: AgentEvent::AssistantMessage { text: "ok".to_string(), model: ModelRef { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), model_id: "claude-sonnet".to_string(), speed: None, }, diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index a8cdfab03..5270490f7 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -1218,7 +1218,7 @@ mod tests { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: Provider::Anthropic, + provider: Provider::Anthropic.id(), details: AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 9f7168575..a4febb472 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use fabro_config::Storage; use fabro_graphviz::graph::{AttrValue, Graph}; -use fabro_model::{Catalog, Provider}; +use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; use fabro_template::{TemplateContext, TemplateError, render as render_template, render_lenient}; @@ -48,7 +48,7 @@ pub struct CreateRunInput { pub git: Option, pub fork_source_ref: Option, pub provenance: Option, - pub configured_providers: Vec, + pub configured_providers: Vec, /// Public URL where this run can be viewed in the web UI, when the server /// has the web UI enabled. Recorded on the `run.created` event so attach /// replays can surface the link. @@ -73,7 +73,7 @@ struct PersistCreateOptions { git: Option, fork_source_ref: Option, provenance: Option, - configured_providers: Vec, + configured_providers: Vec, } /// Resolve workflow inputs, normalize settings, and persist a run directory. diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index b69f33c08..dc9b823c3 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -314,7 +314,7 @@ impl RunSession { let model = resolved.model.name.as_ref().map_or_else( || { Catalog::builtin() - .default_for_configured(&configured) + .default_for_configured_ids(&configured) .id .clone() }, @@ -327,11 +327,16 @@ impl RunSession { .map(InterpString::as_source) .filter(|value| !value.is_empty()); - let provider_enum: Provider = match provider.as_deref() { - Some(value) => value - .parse::() - .map_err(|_| Error::Precondition(format!("unknown provider: {value}")))?, - None => Provider::default_for_configured(&configured), + let provider_enum: Provider = if let Some(value) = provider.as_deref() { + value.parse::().map_err(|_| { + Error::Precondition(format!("Provider \"{value}\" is not configured")) + })? + } else { + let configured = configured + .iter() + .filter_map(Provider::from_id) + .collect::>(); + Provider::default_for_configured(&configured) }; let fallback_chain = resolve_fallback_chain(provider_enum, &model, &resolved.model); @@ -551,7 +556,7 @@ fn resolve_fallback_chain( .or_default() .push(model_ref.to_string()); } - Catalog::builtin().build_fallback_chain(provider, model, &by_provider) + Catalog::builtin().build_fallback_chain(&provider.id(), model, &by_provider) } fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings { diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/crates/fabro-workflow/src/outcome.rs index 6fad164aa..08c163438 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/fabro-workflow/src/outcome.rs @@ -20,8 +20,9 @@ pub fn billed_model_usage_from_llm( usage: &LlmTokenCounts, ) -> BilledModelUsage { let speed = parse_speed(requested_speed); + let provider_id = provider.id(); let model = ModelRef { - provider, + provider: provider_id.clone(), model_id: model_id.to_string(), speed, }; @@ -37,7 +38,7 @@ pub fn billed_model_usage_from_llm( let total_usd_micros = Catalog::builtin() .get(model_id) - .filter(|candidate| candidate.provider == provider) + .filter(|candidate| candidate.provider == provider_id) .and_then(|candidate| candidate.pricing_for(speed)) .and_then(|pricing| pricing.bill(&input)) .map(|amount| amount.0); diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 77aa2061f..bd80072cd 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -195,7 +195,7 @@ async fn build_registry( result .auth_issues .iter() - .map(|(provider, issue)| auth_issue_message(*provider, issue)) + .map(|(provider, issue)| auth_issue_message(provider, issue)) .collect::>() .join("; ") }); @@ -992,7 +992,7 @@ mod tests { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: fabro_llm::Provider::Anthropic, + provider: fabro_llm::Provider::Anthropic.id(), details: AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, @@ -1098,7 +1098,7 @@ mod tests { .set( "openai", &serde_json::to_string(&AuthCredential { - provider: fabro_llm::Provider::OpenAi, + provider: fabro_llm::Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: "openai-key".to_string(), }, diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 4ece30d4c..bf504e728 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -799,7 +799,7 @@ mod tests { fn openai_api_key_credential(key: &str) -> AuthCredential { AuthCredential { - provider: fabro_model::Provider::OpenAi, + provider: fabro_model::Provider::OpenAi.id(), details: AuthDetails::ApiKey { key: key.to_string(), }, diff --git a/lib/crates/fabro-workflow/src/run_materialization.rs b/lib/crates/fabro-workflow/src/run_materialization.rs index 62806c3e6..f8c7356f5 100644 --- a/lib/crates/fabro-workflow/src/run_materialization.rs +++ b/lib/crates/fabro-workflow/src/run_materialization.rs @@ -1,5 +1,5 @@ use fabro_graphviz::graph::Graph; -use fabro_model::{Catalog, Provider}; +use fabro_model::{Catalog, ProviderId}; use fabro_types::WorkflowSettings; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; @@ -8,7 +8,7 @@ pub fn materialize_run( mut settings: WorkflowSettings, graph: &Graph, catalog: &Catalog, - configured_providers: &[Provider], + configured_providers: &[ProviderId], ) -> WorkflowSettings { let configured_model = settings .run @@ -37,9 +37,9 @@ pub fn materialize_run( let model = configured_model.or(graph_model).unwrap_or_else(|| { provider .as_deref() - .and_then(|value| value.parse::().ok()) - .and_then(|provider| catalog.default_for_provider(provider)) - .unwrap_or_else(|| catalog.default_for_configured(configured_providers)) + .map(ProviderId::from) + .and_then(|provider| catalog.default_for_provider(&provider)) + .unwrap_or_else(|| catalog.default_for_configured_ids(configured_providers)) .id .clone() }); diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs index a878758aa..10f1ae6b6 100644 --- a/lib/crates/fabro-workflow/src/services.rs +++ b/lib/crates/fabro-workflow/src/services.rs @@ -11,6 +11,8 @@ use fabro_auth::CredentialSource; use fabro_auth::ResolvedCredentials; use fabro_hooks::{HookContext, HookDecision, HookRunner}; use fabro_model::Provider; +#[cfg(test)] +use fabro_model::ProviderId; use tokio_util::sync::CancellationToken; use crate::ManifestPath; @@ -190,7 +192,7 @@ impl EngineServices { }) } - async fn configured_providers(&self) -> Vec { + async fn configured_providers(&self) -> Vec { Vec::new() } } diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 01324d1c1..67ca26fd8 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -6847,7 +6847,7 @@ mod real_llm { fn openai_api_key_credential(key: &str) -> fabro_auth::AuthCredential { fabro_auth::AuthCredential { - provider: fabro_model::Provider::OpenAi, + provider: fabro_model::Provider::OpenAi.id(), details: fabro_auth::AuthDetails::ApiKey { key: key.to_string(), }, diff --git a/lib/crates/fabro-workflow/tests/materialize_run.rs b/lib/crates/fabro-workflow/tests/materialize_run.rs index 9c3ff23d1..b546185bf 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/crates/fabro-workflow/tests/materialize_run.rs @@ -75,7 +75,7 @@ fn materialize_run_uses_configured_provider_defaults() { WorkflowSettings::default(), &graph(source), Catalog::builtin(), - &[Provider::OpenAi], + &[Provider::OpenAi.id()], ); let resolved = &materialized.run; diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 217121839..714dd5cb1 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -210,7 +210,6 @@ models/principal-webhook.ts models/principal-worker.ts models/principal.ts models/project-namespace.ts -models/provider.ts models/prune-run-entry.ts models/prune-runs-request.ts models/prune-runs-response.ts diff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts index 719edcb41..8d28f601f 100644 --- a/lib/packages/fabro-api-client/src/api/models-api.ts +++ b/lib/packages/fabro-api-client/src/api/models-api.ts @@ -29,8 +29,6 @@ import type { ModelTestMode } from '../models'; import type { ModelTestResult } from '../models'; // @ts-ignore import type { PaginatedModelList } from '../models'; -// @ts-ignore -import type { Provider } from '../models'; /** * ModelsApi - axios parameter creator */ @@ -39,14 +37,14 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio /** * Returns a paginated list of available LLM models from the built-in catalog. * @summary List Models - * @param {Provider} [provider] Filter models by provider name. Invalid values return `400`. + * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listModels: async (provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise => { + listModels: async (provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/api/v1/models`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -149,14 +147,14 @@ export const ModelsApiFp = function(configuration?: Configuration) { /** * Returns a paginated list of available LLM models from the built-in catalog. * @summary List Models - * @param {Provider} [provider] Filter models by provider name. Invalid values return `400`. + * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listModels(provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listModels(provider, query, pageLimit, pageOffset, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['ModelsApi.listModels']?.[localVarOperationServerIndex]?.url; @@ -188,14 +186,14 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat /** * Returns a paginated list of available LLM models from the built-in catalog. * @summary List Models - * @param {Provider} [provider] Filter models by provider name. Invalid values return `400`. + * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listModels(provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { + listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** @@ -219,14 +217,14 @@ export class ModelsApi extends BaseAPI { /** * Returns a paginated list of available LLM models from the built-in catalog. * @summary List Models - * @param {Provider} [provider] Filter models by provider name. Invalid values return `400`. + * @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set. * @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`. * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public listModels(provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) { + public listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) { return ModelsApiFp(this.configuration).listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath)); } @@ -242,4 +240,3 @@ export class ModelsApi extends BaseAPI { return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath)); } } - diff --git a/lib/packages/fabro-api-client/src/models/billing-model-ref.ts b/lib/packages/fabro-api-client/src/models/billing-model-ref.ts index b5ce39adb..5b12fa237 100644 --- a/lib/packages/fabro-api-client/src/models/billing-model-ref.ts +++ b/lib/packages/fabro-api-client/src/models/billing-model-ref.ts @@ -16,15 +16,15 @@ // May contain unused imports in some cases // @ts-ignore import type { BillingSpeed } from './billing-speed'; -// May contain unused imports in some cases -// @ts-ignore -import type { Provider } from './provider'; /** * Provider-qualified billing model identity used for cost estimates. */ export interface BillingModelRef { - 'provider': Provider; + /** + * LLM provider identifier. + */ + 'provider': string; 'model_id': string; 'speed'?: BillingSpeed | null; } diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 49ebf500d..fddfb31bc 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -187,7 +187,6 @@ export * from './principal-user'; export * from './principal-webhook'; export * from './principal-worker'; export * from './project-namespace'; -export * from './provider'; export * from './prune-run-entry'; export * from './prune-runs-request'; export * from './prune-runs-response'; diff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts index 779786680..37154fb75 100644 --- a/lib/packages/fabro-api-client/src/models/model.ts +++ b/lib/packages/fabro-api-client/src/models/model.ts @@ -22,9 +22,6 @@ import type { ModelFeatures } from './model-features'; // May contain unused imports in some cases // @ts-ignore import type { ModelLimits } from './model-limits'; -// May contain unused imports in some cases -// @ts-ignore -import type { Provider } from './provider'; /** * An available LLM model from the built-in catalog. @@ -34,7 +31,10 @@ export interface Model { * Unique model identifier. */ 'id': string; - 'provider': Provider; + /** + * LLM provider identifier. + */ + 'provider': string; /** * Model family grouping. */ @@ -73,4 +73,3 @@ export interface Model { } - diff --git a/lib/packages/fabro-api-client/src/models/provider.ts b/lib/packages/fabro-api-client/src/models/provider.ts deleted file mode 100644 index b5812d509..000000000 --- a/lib/packages/fabro-api-client/src/models/provider.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * LLM provider identifier. - */ - -export const Provider = { - ANTHROPIC: 'anthropic', - OPENAI: 'openai', - GEMINI: 'gemini', - KIMI: 'kimi', - ZAI: 'zai', - MINIMAX: 'minimax', - INCEPTION: 'inception', - OPENAI_COMPATIBLE: 'openai_compatible' -} as const; - -export type Provider = typeof Provider[keyof typeof Provider]; - - -