refactor(model): put the codec on the route (#493)

PR 7 of the gateway refactor series (after #481, #488, #487, #489, #491)
— the series capstone: the wire dialect becomes route vocabulary in
fabro-model config instead of a structural implication of the adapter
type.

## What's here

**`fabro-model/src/codec.rs` (new)** — `CodecKind`
(`anthropic_messages`, `openai_responses`, `openai_compatible`,
`gemini_generate`; strum per house style).
`CodecKind::default_for(AdapterKind)` reproduces the historical
adapter→dialect fusion exactly.

**Catalog schema** — optional `codec` on provider rows and model rows
(the multiplexer case), sparse-merged with the existing `.or()` pattern.
Omitted everywhere in the built-in catalog, so **all defaults reproduce
today's routes**. Explicit pairings outside the adapter's default are
rejected at catalog build (`UnsupportedProviderCodec` /
`UnsupportedModelCodec`) so no new route combination is silently enabled
by configuration — the field is vocabulary for the OpenRouter/Bedrock
feature PRs, not a new capability. `Catalog::effective_codec` mirrors
`effective_agent_profile`. fabro-config mirrors the field through
`LlmLayer` (`ProviderSettings.codec`, `ModelSettings.codec`) and the
catalog-settings conversion.

**Route resolution** — `adapter_registry::resolve_route(catalog, model)`
assembles `(provider row, model row)` into `Route { provider, transport,
codec, deployment_id, billing_policy, agent_profile }`.

**Route-equivalence table test** — every built-in model row pinned to
its resolved tuple as an executable table (23 rows), with a coverage
assert so a new built-in model can't land without a deliberate table
edit. This is the "compat mapping as an executable table, not a comment"
test from the plan.

**`AdapterConfig` cleanup** — the OpenAI-only fields (`codex_mode`,
`org_id`, `project_id`) move out of the shared struct into
`AdapterKindOptions::OpenAi(OpenAiAdapterOptions)`; the client populates
them only for OpenAi-kind routes, which is the only factory that ever
read them.

## Deliberate scope cuts

- **No per-model `billing_policy`** — that schema change exists solely
for the OpenRouter redo, which owns it.
- **`codec_params` and `supports_count_tokens` stay adapter-internal** —
the registry `Route` carries what the catalog defines; the per-route
knobs in the adapters' `RouteConfig` move out when a second
codec/transport pairing actually exists (OpenRouter's anthropic skin /
Bedrock). Wiring `resolve_route` into `Client` request dispatch is the
optional PR 8 and is likewise deferred.
- **No user-facing docs for `codec`** — every accepted value equals the
default, so there is nothing actionable to document yet; docs land with
the first feature PR that enables a non-default pairing.

## Verification

- `cargo nextest run --workspace --no-fail-fast` (re-run post-rebase
onto #491's merge): 6701 passed; the only failures are the same 5
pre-existing environment-dependent fabro-workflow failures noted in
#491, identical on main
- fabro-llm: 548 passed — all wire snapshots unmodified
- clippy `-D warnings` + pinned-nightly fmt clean

This ends the refactor series: the seams exist. Next up are a standalone
cost PR (`cost.rs` + `Response.cost_usd`/`CostSource`, pulled forward
from the #438 triage as its own pre-OpenRouter step) and then the
feature redos — OpenRouter (#438: one TOML + typed codec params) and
Bedrock (#459: sigv4/eventstream transport + config, private codec layer
deleted).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-06-11 16:11:05 -04:00 • committed by GitHub
parent f1b021b6ab
commit d10fcd5e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 612 additions and 58 deletions

View file

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

View file

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

View file

@ -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<String>,
/// 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<CodecKind>,
/// Agent profile used for routing/profile-specific behavior.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_profile: Option<AgentProfileKind>,
@ -94,6 +99,11 @@ pub struct ModelSettings {
/// when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_id: Option<String>,
/// 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<CodecKind>,
/// Agent profile used for routing/profile-specific behavior.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_profile: Option<AgentProfileKind>,
@ -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(

View file

@ -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<String>,
/// Extra HTTP headers attached to every outgoing request.
pub extra_headers: HashMap<String, String>,
/// OpenAI-only: route through the ChatGPT Codex backend.
pub codex_mode: bool,
/// OpenAI-only: organization ID.
pub org_id: Option<String>,
/// OpenAI-only: project ID.
pub project_id: Option<String>,
/// Adapter-kind-specific options; factories for other kinds ignore
/// options that are not theirs.
pub kind_options: AdapterKindOptions,
pub catalog: Option<Arc<Catalog>>,
}
/// 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<String>,
/// Project ID.
pub project_id: Option<String>,
}
impl AdapterConfig {
/// Construct a minimal config with just provider ID and auth header.
pub fn new(provider_id: impl Into<String>, 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<Arc<dyn ProviderAdapter>, 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<Route> {
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);

View file

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

View file

@ -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<String>,
#[serde(default)]
pub adapter: Option<String>,
/// 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<CodecKind>,
#[serde(default)]
pub agent_profile: Option<AgentProfileKind>,
#[serde(default)]
@ -69,6 +74,11 @@ pub struct ModelCatalogSettings {
pub provider: Option<String>,
#[serde(default)]
pub api_id: Option<String>,
/// 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<CodecKind>,
#[serde(default)]
pub agent_profile: Option<AgentProfileKind>,
#[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<ProviderAuthConfig>,
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<Speed, ModelCosts>,
@ -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<CodecKind> {
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<CodecKind>,
) -> Result<CodecKind, CatalogBuildError> {
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<CodecKind>,
) -> Result<CodecKind, CatalogBuildError> {
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(

View file

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

View file

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