feat(model): support open provider catalog data (#245)

## 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)
This commit is contained in:
Bryan Helmkamp 2026-05-12 12:42:49 -07:00 committed by GitHub
parent d7cb27ff65
commit 34d83db801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 3221 additions and 1377 deletions

4
Cargo.lock generated
View file

@ -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]]

View file

@ -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:

View file

@ -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!(

View file

@ -1327,7 +1327,7 @@ impl Session {
.as_deref()
.and_then(|value| value.parse::<Speed>().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 {

View file

@ -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(),
},
};

View file

@ -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,
},

View file

@ -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;

View file

@ -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(),
},
}

View file

@ -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", &[]),

View file

@ -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 {

View file

@ -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::<ApiProvider, Provider>();
fn provider_id_reuses_canonical_model_field_type() {
assert_same_type::<ApiModel, Model>();
}
#[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::<ApiProvider>(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<T: 'static, U: 'static>() {

View file

@ -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<String, String> {
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<Utc>) -> 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());

View file

@ -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<ApiCredential>,
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<ResolvedCredentials>;
async fn configured_providers(&self) -> Vec<Provider>;
async fn configured_providers(&self) -> Vec<ProviderId>;
}

View file

@ -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<ApiCredential> {
let key = provider
.api_key_env_vars()
.iter()
.find_map(|var| self.lookup(var))?;
fn credential_for(&self, provider: &CatalogProvider) -> Option<ApiCredential> {
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<String> {
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<std::collections::HashMap<String, String>> {
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<ResolvedCredentials> {
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> {
Provider::ALL
async fn configured_providers(&self) -> Vec<ProviderId> {
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")
);
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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<String, String>,
pub base_url: Option<String>,
@ -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<ProviderId>, 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<String, String>,
@ -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<ProviderId>,
usage: CredentialUsage,
) -> Result<ResolvedCredential, ResolveError> {
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> {
Provider::ALL
pub fn configured_providers(&self, vault: &Vault) -> Vec<ProviderId> {
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<AuthCredential, ResolveError> {
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<AuthCredential> {
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<String> {
(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<String> {
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<HashMap<String, String>, 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<ApiCredential, ResolveError> {
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<AsyncRwLock<Vault>>>,
) -> Vec<Provider> {
) -> Vec<ProviderId> {
match vault {
Some(vault_arc) => {
let resolver = CredentialResolver::new(Arc::clone(vault_arc));
let guard = vault_arc.read().await;
resolver.configured_providers(&guard)
}
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<Utc>) -> 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,

View file

@ -33,7 +33,7 @@ impl AuthStrategy for ApiKeyStrategy {
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential> {
match response {
AuthContextResponse::ApiKey { key } => Ok(AuthCredential {
provider: self.provider,
provider: self.provider.id(),
details: AuthDetails::ApiKey { key },
}),
AuthContextResponse::DeviceCodeConfirmed => {

View file

@ -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,

View file

@ -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<AuthCredential> {
#[must_use]
pub fn vault_credentials_for_provider(
vault: &Vault,
provider: Provider,
provider: impl Into<ProviderId>,
) -> 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(),
},

View file

@ -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<Provider> {
async fn configured_providers(&self) -> Vec<ProviderId> {
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()
]);
}
}

View file

@ -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(),
},

View file

@ -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<String>,
@ -112,6 +112,7 @@ fn model_row(model: &Model, use_color: bool) -> Vec<CellStruct> {
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]

View file

@ -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,
},

View file

@ -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(),
},

View file

@ -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(),
},

View file

@ -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 -----
");
}

View file

@ -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(),
},

View file

@ -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(),
},

View file

@ -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(),
},

View file

@ -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

View file

@ -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,

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
/// 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<String>,
/// 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<NaiveDate>,
pub knowledge_cutoff: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -183,279 +184,6 @@ pub struct CostRates {
pub cache_input_cost_per_mtok: Option<f64>,
}
/// 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<Option<NaiveDate>, 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::<Either>::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 `<id>`.
Credential(String),
/// Process environment variable `<NAME>`. 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<CredentialRef> for String {
fn from(value: CredentialRef) -> Self {
value.to_string()
}
}
/// Error returned when a credential string is neither `credential:<id>` nor
/// `env:<NAME>`. 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:<id>` or `env:<NAME>`; 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<Self, Self::Err> {
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<String> for CredentialRef {
type Error = CredentialRefParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<String>,
#[serde(default)]
env: Option<String>,
#[serde(default)]
credential: Option<String>,
}
impl<'de> Deserialize<'de> for HeaderValueRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<HeaderValueRefSerde> for HeaderValueRef {
type Error = HeaderValueRefParseError;
fn try_from(value: HeaderValueRefSerde) -> Result<Self, Self::Error> {
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:<redacted>"),
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#"

View file

@ -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/",

View file

@ -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<fabro_model::Provider> {
async fn configured_providers(&self) -> Vec<fabro_model::ProviderId> {
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(),

View file

@ -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<Client>) -> 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<Client>,
) -> 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<Client>) -> Option<GenerateP
);
let mut params = GenerateParams::new(&info.id, client)
.provider(<&'static str>::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 {

View file

@ -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

View file

@ -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<Speed>,
@ -409,7 +409,7 @@ impl Model {
#[must_use]
pub fn billing_model_ref(&self, speed: Option<Speed>) -> 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,
},

View file

@ -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()));
}
}

View file

@ -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
}
]

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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())

View file

@ -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;

View file

@ -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<Model>),
/// 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"));
}
}

View file

@ -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<Self> {
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<Provider> for ProviderId {
fn from(provider: Provider) -> Self {
provider.id()
}
}
#[cfg(test)]
@ -131,6 +157,30 @@ mod tests {
assert_eq!("kimi".parse::<Provider>().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::<Provider>().unwrap(), Provider::Zai);

View file

@ -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> {
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());
}
}
}

View file

@ -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,
},

View file

@ -104,9 +104,9 @@ async fn check_llm_providers(state: &AppState) -> CheckResult {
let mut details: Vec<CheckDetail> = Vec::new();
let mut failures: Vec<ProviderFailure> = 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(),
},

View file

@ -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,
},

View file

@ -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<Provider>,
configured_providers: Vec<ProviderId>,
web_url: Option<String>,
) -> 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<CheckResult>,
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::<Provider>() {
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<String>) {
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

View file

@ -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;

View file

@ -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

View file

@ -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<Arc<AppState>>,
Query(params): Query<ModelListParams>,
) -> 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<Provider> = state
let configured: HashSet<ProviderId> = 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",

View file

@ -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()
};

View file

@ -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<Provider> {
async fn configured_providers(&self) -> Vec<fabro_model::ProviderId> {
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();

View file

@ -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)]

View file

@ -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,
},

View file

@ -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(),
},

View file

@ -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<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub provenance: Option<RunProvenance>,
pub configured_providers: Vec<Provider>,
pub configured_providers: Vec<ProviderId>,
/// 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<GitContext>,
fork_source_ref: Option<ForkSourceRef>,
provenance: Option<RunProvenance>,
configured_providers: Vec<Provider>,
configured_providers: Vec<ProviderId>,
}
/// Resolve workflow inputs, normalize settings, and persist a run directory.

View file

@ -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::<Provider>()
.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::<Provider>().map_err(|_| {
Error::Precondition(format!("Provider \"{value}\" is not configured"))
})?
} else {
let configured = configured
.iter()
.filter_map(Provider::from_id)
.collect::<Vec<_>>();
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 {

View file

@ -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);

View file

@ -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::<Vec<_>>()
.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(),
},

View file

@ -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(),
},

View file

@ -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::<Provider>().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()
});

View file

@ -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<Provider> {
async fn configured_providers(&self) -> Vec<ProviderId> {
Vec::new()
}
}

View file

@ -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(),
},

View file

@ -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;

View file

@ -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

View file

@ -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 &#x60;400&#x60;.
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @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<RequestArgs> => {
listModels: async (provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
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 &#x60;400&#x60;.
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @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<PaginatedModelList>> {
async listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedModelList>> {
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 &#x60;400&#x60;.
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @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<PaginatedModelList> {
listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedModelList> {
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 &#x60;400&#x60;.
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @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));
}
}

View file

@ -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;
}

View file

@ -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';

View file

@ -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 {
}

View file

@ -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];