mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
parent
d7cb27ff65
commit
34d83db801
80 changed files with 3221 additions and 1377 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", &[]),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
22
lib/crates/fabro-model/src/bootstrap_catalog.rs
Normal file
22
lib/crates/fabro-model/src/bootstrap_catalog.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
127
lib/crates/fabro-model/src/catalog/providers/anthropic.toml
Normal file
127
lib/crates/fabro-model/src/catalog/providers/anthropic.toml
Normal 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
|
||||
106
lib/crates/fabro-model/src/catalog/providers/gemini.toml
Normal file
106
lib/crates/fabro-model/src/catalog/providers/gemini.toml
Normal 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
|
||||
30
lib/crates/fabro-model/src/catalog/providers/inception.toml
Normal file
30
lib/crates/fabro-model/src/catalog/providers/inception.toml
Normal 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
|
||||
30
lib/crates/fabro-model/src/catalog/providers/kimi.toml
Normal file
30
lib/crates/fabro-model/src/catalog/providers/kimi.toml
Normal 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
|
||||
28
lib/crates/fabro-model/src/catalog/providers/minimax.toml
Normal file
28
lib/crates/fabro-model/src/catalog/providers/minimax.toml
Normal 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
|
||||
251
lib/crates/fabro-model/src/catalog/providers/openai.toml
Normal file
251
lib/crates/fabro-model/src/catalog/providers/openai.toml
Normal 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
|
||||
28
lib/crates/fabro-model/src/catalog/providers/zai.toml
Normal file
28
lib/crates/fabro-model/src/catalog/providers/zai.toml
Normal 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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ import type { ModelTestMode } from '../models';
|
|||
import type { ModelTestResult } from '../models';
|
||||
// @ts-ignore
|
||||
import type { PaginatedModelList } from '../models';
|
||||
// @ts-ignore
|
||||
import type { Provider } from '../models';
|
||||
/**
|
||||
* ModelsApi - axios parameter creator
|
||||
*/
|
||||
|
|
@ -39,14 +37,14 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio
|
|||
/**
|
||||
* Returns a paginated list of available LLM models from the built-in catalog.
|
||||
* @summary List Models
|
||||
* @param {Provider} [provider] Filter models by provider name. Invalid values return `400`.
|
||||
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listModels: async (provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<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 `400`.
|
||||
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listModels(provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<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 `400`.
|
||||
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listModels(provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<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 `400`.
|
||||
* @param {string} [provider] Filter models by provider ID. Unknown provider IDs return an empty result set.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listModels(provider?: Provider, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
|
||||
public listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
|
||||
return ModelsApiFp(this.configuration).listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
|
|
@ -242,4 +240,3 @@ export class ModelsApi extends BaseAPI {
|
|||
return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Reference in a new issue