mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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:
parent
f1b021b6ab
commit
d10fcd5e91
8 changed files with 612 additions and 58 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
110
lib/crates/fabro-model/src/codec.rs
Normal file
110
lib/crates/fabro-model/src/codec.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue