diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index 63672fc6f..27c9725cb 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -330,6 +330,7 @@ fn provider_settings_to_catalog( model_catalog::ProviderCatalogSettings { display_name: settings.display_name, adapter: settings.adapter, + codec: settings.codec, agent_profile: settings.agent_profile, auth: settings.auth, billing_policy: settings.billing_policy, @@ -346,6 +347,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat let ModelSettings { provider, api_id, + codec, agent_profile, display_name, family, @@ -365,6 +367,7 @@ fn model_settings_to_catalog(settings: ModelSettings) -> model_catalog::ModelCat model_catalog::ModelCatalogSettings { provider, api_id, + codec, agent_profile, display_name, family, diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index a99f352e2..cdb6211e0 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, HashMap}; -use fabro_model::{AgentProfileKind, BillingPolicy, ProviderAuthConfig}; +use fabro_model::{AgentProfileKind, BillingPolicy, CodecKind, ProviderAuthConfig}; use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::{ AgentPermissions, ApprovalMode, EnvironmentNetworkMode, EnvironmentProvider, MergeStrategy, @@ -93,6 +93,7 @@ impl_combine_or_option!( LogFilter, AgentProfileKind, BillingPolicy, + CodecKind, ProviderAuthConfig, ReasoningEffortFeature, ); diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index d8037105f..56f83d071 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -29,7 +29,7 @@ use std::collections::{BTreeMap, HashMap}; use fabro_model::catalog::deserialize_knowledge_cutoff; -use fabro_model::{AgentProfileKind, BillingPolicy, ProviderAuthConfig}; +use fabro_model::{AgentProfileKind, BillingPolicy, CodecKind, ProviderAuthConfig}; pub use fabro_model::{ CredentialRef, CredentialRefParseError, HeaderValueRef, ReasoningEffortFeature, }; @@ -58,6 +58,11 @@ pub struct ProviderSettings { /// Adapter registry key (e.g. `"openai_compatible"`). #[serde(default, skip_serializing_if = "Option::is_none")] pub adapter: Option, + /// Wire dialect for this provider's routes (e.g. `"anthropic_messages"`). + /// Defaults to the adapter's codec; only the default pairing is accepted + /// today — validated at catalog build. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codec: Option, /// Agent profile used for routing/profile-specific behavior. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_profile: Option, @@ -94,6 +99,11 @@ pub struct ModelSettings { /// when omitted. #[serde(default, skip_serializing_if = "Option::is_none")] pub api_id: Option, + /// Wire dialect for this model's route, overriding the provider's codec. + /// Only the adapter's default pairing is accepted today — validated at + /// catalog build. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codec: Option, /// Agent profile used for routing/profile-specific behavior. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_profile: Option, @@ -317,6 +327,40 @@ agent_profile = "anthropic" ); } + #[test] + fn provider_codec_parses_from_toml() { + let parsed: LlmLayer = toml::from_str( + r#" +[providers.acme] +adapter = "openai_compatible" +codec = "openai_compatible" +"#, + ) + .unwrap(); + + assert_eq!( + parsed.providers.get("acme").unwrap().codec, + Some(fabro_model::CodecKind::OpenAiCompatible) + ); + } + + #[test] + fn model_codec_parses_from_toml() { + let parsed: LlmLayer = toml::from_str( + r#" +[models.acme_large] +provider = "acme" +codec = "anthropic_messages" +"#, + ) + .unwrap(); + + assert_eq!( + parsed.models.get("acme_large").unwrap().codec, + Some(fabro_model::CodecKind::AnthropicMessages) + ); + } + #[test] fn model_agent_profile_parses_from_toml() { let parsed: LlmLayer = toml::from_str( diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index a778a116b..a5cc6f535 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_auth::ApiKeyHeader; -use fabro_model::{AdapterKind, Catalog}; +use fabro_model::{AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, ProviderId}; use crate::error::Error; use crate::provider::ProviderAdapter; @@ -32,15 +32,33 @@ pub struct AdapterConfig { pub base_url: Option, /// Extra HTTP headers attached to every outgoing request. pub extra_headers: HashMap, - /// OpenAI-only: route through the ChatGPT Codex backend. - pub codex_mode: bool, - /// OpenAI-only: organization ID. - pub org_id: Option, - /// OpenAI-only: project ID. - pub project_id: Option, + /// Adapter-kind-specific options; factories for other kinds ignore + /// options that are not theirs. + pub kind_options: AdapterKindOptions, pub catalog: Option>, } +/// Construction options that only apply to one adapter kind, kept out of the +/// shared [`AdapterConfig`] fields. +#[derive(Debug, Clone, Default)] +pub enum AdapterKindOptions { + /// No kind-specific options. + #[default] + None, + OpenAi(OpenAiAdapterOptions), +} + +/// OpenAI-only construction options. +#[derive(Debug, Clone, Default)] +pub struct OpenAiAdapterOptions { + /// Route through the ChatGPT Codex backend. + pub codex_mode: bool, + /// Organization ID. + pub org_id: Option, + /// Project ID. + pub project_id: Option, +} + impl AdapterConfig { /// Construct a minimal config with just provider ID and auth header. pub fn new(provider_id: impl Into, auth_header: ApiKeyHeader) -> Self { @@ -49,9 +67,7 @@ impl AdapterConfig { auth_header: Some(auth_header), base_url: None, extra_headers: HashMap::new(), - codex_mode: false, - org_id: None, - project_id: None, + kind_options: AdapterKindOptions::None, catalog: None, } } @@ -104,6 +120,10 @@ fn build_anthropic(config: AdapterConfig) -> Result, Er fn build_openai_adapter(mut config: AdapterConfig) -> providers::OpenAiAdapter { let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); + let options = match config.kind_options { + AdapterKindOptions::OpenAi(options) => options, + AdapterKindOptions::None => OpenAiAdapterOptions::default(), + }; let mut adapter = providers::OpenAiAdapter::new_optional_auth(api_key).with_name(config.provider_id.clone()); if let Some(base_url) = config.base_url { @@ -112,13 +132,13 @@ fn build_openai_adapter(mut config: AdapterConfig) -> providers::OpenAiAdapter { if !config.extra_headers.is_empty() { adapter = adapter.with_default_headers(config.extra_headers); } - if config.codex_mode { + if options.codex_mode { adapter = adapter.with_codex_mode(); } - if let Some(org_id) = config.org_id { + if let Some(org_id) = options.org_id { adapter = adapter.with_org_id(org_id); } - if let Some(project_id) = config.project_id { + if let Some(project_id) = options.project_id { adapter = adapter.with_project_id(project_id); } if let Some(catalog) = config.catalog { @@ -196,10 +216,142 @@ pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory { } } +/// A resolved route for one catalog model: the transport+auth key, wire +/// dialect, and provider-facing identifiers a request for that model travels +/// with. +/// +/// `(provider row, model row)` → route. Codec/transport pairings are +/// validated at catalog build, so any model in a successfully built catalog +/// resolves. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Route { + /// Canonical provider this route belongs to. + pub provider: ProviderId, + /// Transport + auth scheme (the adapter registry key). + pub transport: AdapterKind, + /// Wire dialect spoken on this route. + pub codec: CodecKind, + /// Identifier sent to the provider API (the catalog `api_id`). + pub deployment_id: String, + /// Billing family used to translate usage into billed tokens. + pub billing_policy: BillingPolicy, + /// Agent profile driving profile-specific behavior. + pub agent_profile: AgentProfileKind, +} + +/// Resolve the route for `model_id_or_alias` from the catalog's provider and +/// model rows. Returns `None` when the model or its provider is unknown. +#[must_use] +pub fn resolve_route(catalog: &Catalog, model_id_or_alias: &str) -> Option { + let model = catalog.get(model_id_or_alias)?; + let provider = catalog.provider(&model.provider)?; + let settings = catalog.model_settings(&model.id)?; + Some(Route { + provider: provider.id.clone(), + transport: provider.adapter, + codec: settings.codec, + deployment_id: settings.api_id.clone(), + billing_policy: provider.billing_policy, + agent_profile: settings.agent_profile, + }) +} + #[cfg(test)] mod tests { use super::*; + /// One row of the route-equivalence table: model id plus the + /// `(deployment_id, transport, codec, billing_policy, agent_profile)` + /// tuple it must resolve to. + type RouteRow = ( + &'static str, + &'static str, + AdapterKind, + CodecKind, + BillingPolicy, + AgentProfileKind, + ); + + /// The compat mapping as an executable table: every built-in catalog + /// model resolves to exactly this tuple. Adding or rerouting a built-in + /// model means updating this table deliberately. + #[test] + fn builtin_catalog_route_equivalence_table() { + use AdapterKind as T; + use AgentProfileKind as P; + use BillingPolicy as B; + use CodecKind as C; + + #[rustfmt::skip] + let expected: &[RouteRow] = &[ + // model id deployment_id transport codec billing profile + ("claude-fable-5", "claude-fable-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("claude-haiku-4-5", "claude-haiku-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("claude-opus-4-6", "claude-opus-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("claude-opus-4-7", "claude-opus-4-7", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("claude-opus-4-8", "claude-opus-4-8", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), + ("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), + ("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), + ("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), + ("gemini-3.1-pro-preview-customtools", "gemini-3.1-pro-preview-customtools", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), + ("gemini-3.5-flash", "gemini-3.5-flash", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), + ("glm-4.7", "glm-4.7", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("gpt-5.4", "gpt-5.4", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), + ("gpt-5.4-mini", "gpt-5.4-mini", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), + ("gpt-5.4-pro", "gpt-5.4-pro", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), + ("gpt-5.5", "gpt-5.5", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), + ("gpt-5.5-pro", "gpt-5.5-pro", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi), + ("kimi-k2.5", "kimi-k2.5", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("mercury-2", "mercury-2", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("minimax-m2.5", "minimax-m2.5", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("venice-uncensored-1-2", "venice-uncensored-1-2", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("venice-uncensored-role-play", "venice-uncensored-role-play", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ]; + + let catalog = Catalog::builtin(); + + let mut model_ids: Vec<&str> = catalog + .list(None) + .iter() + .map(|model| model.id.as_str()) + .collect(); + model_ids.sort_unstable(); + let mut expected_ids: Vec<&str> = expected.iter().map(|row| row.0).collect(); + expected_ids.sort_unstable(); + assert_eq!( + model_ids, expected_ids, + "route-equivalence table must cover every built-in model row" + ); + + for (model_id, deployment_id, transport, codec, billing_policy, agent_profile) in expected { + let route = resolve_route(catalog, model_id) + .unwrap_or_else(|| panic!("built-in model '{model_id}' should resolve")); + assert_eq!(route.deployment_id, *deployment_id, "{model_id}"); + assert_eq!(route.transport, *transport, "{model_id}"); + assert_eq!(route.codec, *codec, "{model_id}"); + assert_eq!(route.billing_policy, *billing_policy, "{model_id}"); + assert_eq!(route.agent_profile, *agent_profile, "{model_id}"); + } + } + + #[test] + fn resolve_route_follows_model_aliases() { + let catalog = Catalog::builtin(); + + let by_alias = resolve_route(catalog, "sonnet").expect("alias should resolve"); + let by_id = resolve_route(catalog, "claude-sonnet-4-6").expect("id should resolve"); + + assert_eq!(by_alias, by_id); + assert_eq!(by_alias.provider, ProviderId::anthropic()); + } + + #[test] + fn resolve_route_returns_none_for_unknown_models() { + assert_eq!(resolve_route(Catalog::builtin(), "not-a-model"), None); + } + #[test] fn anthropic_factory_builds_anthropic_adapter() { let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { @@ -229,17 +381,12 @@ mod tests { #[test] fn custom_primary_auth_header_overrides_extra_header() { let config = AdapterConfig { - provider_id: "custom".to_string(), - auth_header: Some(ApiKeyHeader::Custom { + base_url: Some("https://api.custom.test/v1".to_string()), + extra_headers: HashMap::from([("x-api-key".to_string(), "secondary-key".to_string())]), + ..AdapterConfig::new("custom", ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "primary-key".to_string(), - }), - base_url: Some("https://api.custom.test/v1".to_string()), - extra_headers: HashMap::from([("x-api-key".to_string(), "secondary-key".to_string())]), - codex_mode: false, - org_id: None, - project_id: None, - catalog: None, + }) }; let adapter = build_openai_compatible_adapter(config).unwrap(); @@ -254,14 +401,8 @@ mod tests { #[test] fn openai_compatible_factory_uses_provider_id_for_name() { let config = AdapterConfig { - provider_id: "kimi".to_string(), - auth_header: Some(ApiKeyHeader::Bearer("k".to_string())), - base_url: Some("https://api.moonshot.ai/v1".to_string()), - extra_headers: HashMap::new(), - codex_mode: false, - org_id: None, - project_id: None, - catalog: None, + base_url: Some("https://api.moonshot.ai/v1".to_string()), + ..AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string())) }; let adapter = factory_for(AdapterKind::OpenAiCompatible)(config).unwrap(); assert_eq!(adapter.name(), "kimi"); @@ -270,9 +411,7 @@ mod tests { #[test] fn openai_compatible_factory_preserves_extra_headers() { let config = AdapterConfig { - provider_id: "portkey".to_string(), - auth_header: Some(ApiKeyHeader::Bearer("unused-primary-key".to_string())), - base_url: Some("https://api.portkey.ai/v1".to_string()), + base_url: Some("https://api.portkey.ai/v1".to_string()), extra_headers: HashMap::from([ ( "x-portkey-api-key".to_string(), @@ -283,10 +422,10 @@ mod tests { "@bedrock-prod".to_string(), ), ]), - codex_mode: false, - org_id: None, - project_id: None, - catalog: None, + ..AdapterConfig::new( + "portkey", + ApiKeyHeader::Bearer("unused-primary-key".to_string()), + ) }; let adapter = build_openai_compatible_adapter(config).unwrap(); @@ -305,20 +444,15 @@ mod tests { #[test] fn anthropic_factory_preserves_extra_headers() { let config = AdapterConfig { - provider_id: "anthropic-through-portkey".to_string(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "unused-primary-key".to_string(), - }), - base_url: Some("https://api.portkey.ai/v1".to_string()), + base_url: Some("https://api.portkey.ai/v1".to_string()), extra_headers: HashMap::from([( "x-portkey-api-key".to_string(), "resolved-portkey-key".to_string(), )]), - codex_mode: false, - org_id: None, - project_id: None, - catalog: None, + ..AdapterConfig::new("anthropic-through-portkey", ApiKeyHeader::Custom { + name: "x-api-key".to_string(), + value: "unused-primary-key".to_string(), + }) }; let adapter = build_anthropic_adapter(config); diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 7ad3153d4..828834e0c 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -2,10 +2,12 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_auth::{ApiCredential, CredentialSource}; -use fabro_model::{Catalog, ProviderId}; +use fabro_model::{AdapterKind, Catalog, ProviderId}; use tracing::debug; -use crate::adapter_registry::{AdapterConfig, factory_for}; +use crate::adapter_registry::{ + AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for, +}; use crate::error::{Error, ProviderErrorKind}; use crate::middleware::{Middleware, NextFn, NextStreamFn}; use crate::provider::{ProviderAdapter, StreamEventStream}; @@ -146,15 +148,21 @@ impl Client { let provider_id = credential.provider.clone(); let adapter = if let Some(provider) = catalog.provider(&provider_id) { let factory = factory_for(provider.adapter); + let kind_options = match provider.adapter { + AdapterKind::OpenAi => AdapterKindOptions::OpenAi(OpenAiAdapterOptions { + codex_mode: credential.codex_mode, + org_id: credential.org_id, + project_id: credential.project_id, + }), + _ => AdapterKindOptions::None, + }; factory(AdapterConfig { - provider_id: provider.id.to_string(), - auth_header: credential.auth_header, - base_url: credential.base_url.or_else(|| provider.base_url.clone()), + 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, - catalog: Some(Arc::clone(&catalog)), + kind_options, + catalog: Some(Arc::clone(&catalog)), }) } else { Err(Error::Configuration { diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 1ed2d7ff6..636ef1ea1 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -11,6 +11,7 @@ use tracing::warn; use crate::Speed; use crate::adapter::{AdapterKind, AgentProfileKind}; +use crate::codec::CodecKind; use crate::ids::ProviderId; use crate::provider::Provider; use crate::reasoning::ReasoningEffort; @@ -42,6 +43,10 @@ pub struct ProviderCatalogSettings { pub display_name: Option, #[serde(default)] pub adapter: Option, + /// Wire dialect for this provider's routes. Defaults to the adapter's + /// codec; only the default pairing is accepted today. + #[serde(default)] + pub codec: Option, #[serde(default)] pub agent_profile: Option, #[serde(default)] @@ -69,6 +74,11 @@ pub struct ModelCatalogSettings { pub provider: Option, #[serde(default)] pub api_id: Option, + /// Wire dialect for this model's route, overriding the provider's codec + /// (the multiplexer case). Only the adapter's default pairing is + /// accepted today. + #[serde(default)] + pub codec: Option, #[serde(default)] pub agent_profile: Option, #[serde(default)] @@ -474,6 +484,9 @@ pub struct CatalogProvider { pub id: ProviderId, pub display_name: String, pub adapter: AdapterKind, + /// Wire dialect driven by this provider's routes; models may override it + /// via [`CatalogModelSettings::codec`]. + pub codec: CodecKind, pub agent_profile: AgentProfileKind, pub auth: Option, pub billing_policy: BillingPolicy, @@ -507,6 +520,9 @@ pub struct CatalogModelControls { #[derive(Debug, Clone, PartialEq)] pub struct CatalogModelSettings { pub api_id: String, + /// Wire dialect for this model's route (the provider codec unless the + /// model row overrides it). + pub codec: CodecKind, pub agent_profile: AgentProfileKind, pub controls: CatalogModelControls, pub speed_costs: HashMap, @@ -556,6 +572,24 @@ pub enum CatalogBuildError { provider: ProviderId, adapter: String, }, + #[error( + "provider '{provider}' configures codec '{codec}', but adapter '{adapter}' only supports '{expected}'" + )] + UnsupportedProviderCodec { + provider: ProviderId, + adapter: AdapterKind, + codec: CodecKind, + expected: CodecKind, + }, + #[error( + "model '{model}' configures codec '{codec}', but adapter '{adapter}' only supports '{expected}'" + )] + UnsupportedModelCodec { + model: String, + adapter: AdapterKind, + codec: CodecKind, + expected: CodecKind, + }, #[error("provider '{provider}' API-key auth must declare at least one credential")] EmptyApiKeyCredentials { provider: ProviderId }, #[error("provider identifier '{identifier}' is declared by both '{first}' and '{second}'")] @@ -881,6 +915,24 @@ impl Catalog { Some(model_profile.unwrap_or(provider.agent_profile)) } + /// The codec a request for `model_id_or_alias` on `provider_id` speaks: + /// the model row's codec when one is configured, otherwise the + /// provider's. + #[must_use] + pub fn effective_codec( + &self, + provider_id: &ProviderId, + model_id_or_alias: Option<&str>, + ) -> Option { + let provider = self.provider(provider_id)?; + let model_codec = model_id_or_alias + .and_then(|model_id| self.get(model_id)) + .filter(|model| model.provider == provider.id) + .and_then(|model| self.model_settings.get(&model.id)) + .map(|settings| settings.codec); + Some(model_codec.unwrap_or(provider.codec)) + } + /// List all models, optionally filtered by provider. #[must_use] pub fn list(&self, provider: Option<&ProviderId>) -> Vec<&Model> { @@ -1112,6 +1164,7 @@ fn merge_provider_settings( ProviderCatalogSettings { display_name: higher.display_name.or(fallback.display_name), adapter: higher.adapter.or(fallback.adapter), + codec: higher.codec.or(fallback.codec), agent_profile: higher.agent_profile.or(fallback.agent_profile), auth: higher.auth.or(fallback.auth), billing_policy: higher.billing_policy.or(fallback.billing_policy), @@ -1131,6 +1184,7 @@ fn merge_model_settings( ModelCatalogSettings { provider: higher.provider.or(fallback.provider), api_id: higher.api_id.or(fallback.api_id), + codec: higher.codec.or(fallback.codec), agent_profile: higher.agent_profile.or(fallback.agent_profile), display_name: higher.display_name.or(fallback.display_name), family: higher.family.or(fallback.family), @@ -1255,6 +1309,7 @@ fn build_providers( } })?; let defaults = adapter_defaults(adapter); + let codec = resolve_provider_codec(&provider_id, adapter, settings.codec)?; let agent_profile = settings.agent_profile.unwrap_or(defaults.agent_profile); let auth = settings.auth.clone(); validate_provider_auth(&provider_id, auth.as_ref())?; @@ -1263,6 +1318,7 @@ fn build_providers( id: provider_id, display_name: settings.display_name.clone().unwrap_or_else(|| id.clone()), adapter, + codec, agent_profile, auth, billing_policy: settings.billing_policy.unwrap_or(defaults.billing_policy), @@ -1299,6 +1355,45 @@ fn adapter_defaults(adapter: AdapterKind) -> AdapterDefaults { } } +/// Resolve a provider row's codec, rejecting pairings outside the adapter's +/// default so no new route combination is silently enabled by configuration. +fn resolve_provider_codec( + provider: &ProviderId, + adapter: AdapterKind, + configured: Option, +) -> Result { + let expected = CodecKind::default_for(adapter); + match configured { + Some(codec) if codec != expected => Err(CatalogBuildError::UnsupportedProviderCodec { + provider: provider.clone(), + adapter, + codec, + expected, + }), + _ => Ok(expected), + } +} + +/// Resolve a model row's codec against its provider, with the same +/// only-the-default-pairing rule as [`resolve_provider_codec`]. +fn resolve_model_codec( + model_id: &str, + provider: &CatalogProvider, + configured: Option, +) -> Result { + let expected = CodecKind::default_for(provider.adapter); + match configured { + Some(codec) if codec != expected => Err(CatalogBuildError::UnsupportedModelCodec { + model: model_id.to_string(), + adapter: provider.adapter, + codec, + expected, + }), + Some(codec) => Ok(codec), + None => Ok(provider.codec), + } +} + fn validate_provider_auth( provider: &ProviderId, auth: Option<&ProviderAuthConfig>, @@ -1389,6 +1484,7 @@ fn build_model( .api_id .clone() .unwrap_or_else(|| model_id.to_string()), + codec: resolve_model_codec(model_id, provider, settings.codec)?, agent_profile: settings.agent_profile.unwrap_or(provider.agent_profile), controls, speed_costs, @@ -2082,6 +2178,162 @@ enabled = true )); } + // ---- Codec on the route ---- + + #[test] + fn provider_codec_defaults_from_adapter() { + let catalog = Catalog::builtin(); + + for (provider, expected) in [ + ("anthropic", CodecKind::AnthropicMessages), + ("openai", CodecKind::OpenAiResponses), + ("gemini", CodecKind::GeminiGenerate), + ("kimi", CodecKind::OpenAiCompatible), + ] { + let provider_id = ProviderId::new(provider); + assert_eq!(catalog.provider(&provider_id).unwrap().codec, expected); + assert_eq!(catalog.effective_codec(&provider_id, None), Some(expected)); + } + } + + #[test] + fn model_codec_inherits_provider_codec() { + let catalog = Catalog::builtin(); + + assert_eq!( + catalog.model_settings("claude-sonnet-4-5").unwrap().codec, + CodecKind::AnthropicMessages + ); + assert_eq!( + catalog.model_settings("gpt-5.4").unwrap().codec, + CodecKind::OpenAiResponses + ); + assert_eq!( + catalog.effective_codec(&ProviderId::anthropic(), Some("claude-sonnet-4-5")), + Some(CodecKind::AnthropicMessages) + ); + } + + #[test] + fn explicit_codec_matching_the_adapter_default_is_accepted() { + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r#" +[providers.acme] +display_name = "Acme" +adapter = "openai_compatible" +codec = "openai_compatible" +base_url = "https://api.acme.test/v1" + +[models."acme-large"] +provider = "acme" +codec = "openai_compatible" +display_name = "Acme Large" +family = "acme" + +[models."acme-large".limits] +context_window = 128000 + +[models."acme-large".features] +tools = true +vision = false +reasoning = false +"#, + )) + .expect("default codec pairing should build"); + + assert_eq!( + catalog.provider(&ProviderId::new("acme")).unwrap().codec, + CodecKind::OpenAiCompatible + ); + assert_eq!( + catalog.model_settings("acme-large").unwrap().codec, + CodecKind::OpenAiCompatible + ); + assert_eq!( + catalog.effective_codec(&ProviderId::new("acme"), Some("acme-large")), + Some(CodecKind::OpenAiCompatible) + ); + } + + #[test] + fn provider_codec_outside_the_adapter_default_is_rejected() { + let layer = minimal_settings( + r#" +[providers.test-provider] +display_name = "Test Provider" +adapter = "openai" +codec = "anthropic_messages" +"#, + ); + + let err = Catalog::from_settings(&layer).unwrap_err(); + + assert!(matches!( + err, + CatalogBuildError::UnsupportedProviderCodec { + provider, + adapter: AdapterKind::OpenAi, + codec: CodecKind::AnthropicMessages, + expected: CodecKind::OpenAiResponses, + } if provider == ProviderId::new("test-provider") + )); + } + + #[test] + fn model_codec_outside_the_adapter_default_is_rejected() { + let layer = minimal_settings( + r#" +[providers.test] +display_name = "Test" +adapter = "openai" +enabled = true + +[models.one] +provider = "test" +codec = "gemini_generate" +display_name = "One" +family = "test" +default = true + +[models.one.limits] +context_window = 1000 + +[models.one.features] +tools = false +vision = false +reasoning = false +"#, + ); + + let err = Catalog::from_settings(&layer).unwrap_err(); + + assert!(matches!( + err, + CatalogBuildError::UnsupportedModelCodec { + model, + adapter: AdapterKind::OpenAi, + codec: CodecKind::GeminiGenerate, + expected: CodecKind::OpenAiResponses, + } if model == "one" + )); + } + + #[test] + fn builtin_override_can_pin_the_default_codec() { + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r#" +[providers.anthropic] +codec = "anthropic_messages" +"#, + )) + .expect("override pinning the default codec should build"); + + assert_eq!( + catalog.provider(&ProviderId::anthropic()).unwrap().codec, + CodecKind::AnthropicMessages + ); + } + #[test] fn catalog_from_settings_rejects_duplicate_model_aliases() { let layer = minimal_settings( diff --git a/lib/crates/fabro-model/src/codec.rs b/lib/crates/fabro-model/src/codec.rs new file mode 100644 index 000000000..bc8bf8813 --- /dev/null +++ b/lib/crates/fabro-model/src/codec.rs @@ -0,0 +1,110 @@ +//! Wire-dialect identity shared by the model catalog and LLM route assembly. +//! +//! A codec names *what the bytes say* — the wire dialect a route speaks — +//! independently of the transport/auth scheme named by +//! [`AdapterKind`](crate::AdapterKind). Catalog rows may select a codec +//! explicitly; rows that omit it inherit the adapter's default, which +//! reproduces the historical adapter→dialect fusion exactly. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString, IntoStaticStr, VariantArray}; + +use crate::adapter::AdapterKind; + +/// Stable wire-dialect identity for a route. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + EnumString, + IntoStaticStr, + VariantArray, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum CodecKind { + AnthropicMessages, + #[serde(rename = "openai_responses")] + #[strum(to_string = "openai_responses")] + OpenAiResponses, + /// The conservative Chat Completions dialect. The name matches today's + /// `openai_compatible` adapter string; `openai_chat` stays reserved for a + /// possible future full-proprietary Chat Completions dialect. + #[serde(rename = "openai_compatible")] + #[strum(to_string = "openai_compatible")] + OpenAiCompatible, + GeminiGenerate, +} + +impl CodecKind { + /// The codec each adapter kind drives when a catalog row does not + /// configure `codec` explicitly. These defaults reproduce the historical + /// behavior where the adapter implied the wire dialect. + #[must_use] + pub fn default_for(adapter: AdapterKind) -> Self { + match adapter { + AdapterKind::Anthropic => Self::AnthropicMessages, + AdapterKind::OpenAi => Self::OpenAiResponses, + AdapterKind::Gemini => Self::GeminiGenerate, + AdapterKind::OpenAiCompatible => Self::OpenAiCompatible, + } + } + + #[must_use] + pub fn as_str(self) -> &'static str { + self.into() + } +} + +impl AsRef for CodecKind { + fn as_ref(&self) -> &str { + (*self).as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codec_kind_round_trips_as_snake_case() { + for kind in CodecKind::VARIANTS { + let json = serde_json::to_string(kind).unwrap(); + assert_eq!(json, format!("\"{}\"", kind.as_str())); + let parsed: CodecKind = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, *kind); + assert_eq!(kind.as_str().parse::().unwrap(), *kind); + } + } + + #[test] + fn codec_kind_strings_match_route_vocabulary() { + for (kind, expected) in [ + (CodecKind::AnthropicMessages, "anthropic_messages"), + (CodecKind::OpenAiResponses, "openai_responses"), + (CodecKind::OpenAiCompatible, "openai_compatible"), + (CodecKind::GeminiGenerate, "gemini_generate"), + ] { + assert_eq!(kind.as_str(), expected); + assert_eq!(kind.to_string(), expected); + } + } + + #[test] + fn adapter_defaults_reproduce_the_historical_fusion() { + for (adapter, expected) in [ + (AdapterKind::Anthropic, CodecKind::AnthropicMessages), + (AdapterKind::OpenAi, CodecKind::OpenAiResponses), + (AdapterKind::Gemini, CodecKind::GeminiGenerate), + (AdapterKind::OpenAiCompatible, CodecKind::OpenAiCompatible), + ] { + assert_eq!(CodecKind::default_for(adapter), expected); + } + } +} diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 92e95ca0b..5e89334a6 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -2,6 +2,7 @@ pub mod adapter; pub mod billing; pub mod bootstrap_catalog; pub mod catalog; +pub mod codec; pub mod ids; pub mod model_ref; pub mod model_test; @@ -20,6 +21,7 @@ pub use catalog::{ ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError, FallbackTarget, HeaderValueRef, ProviderAuthConfig, }; +pub use codec::CodecKind; pub use ids::{ModelId, ProviderId}; pub use model_ref::ModelHandle; pub use model_test::ModelTestMode;