diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs new file mode 100644 index 000000000..c1edaa443 --- /dev/null +++ b/lib/crates/fabro-model/src/adapter.rs @@ -0,0 +1,204 @@ +//! Adapter metadata vocabulary shared by the model catalog and LLM factories. +//! +//! Adapters are Rust-owned: each registered adapter key maps to a static +//! [`AdapterMetadata`] describing how the adapter dispatches agent profiles, +//! formats API key headers, and which native control values it supports. +//! +//! Provider/model catalog rows reference adapters by key. Both the catalog +//! (in `fabro-model`) and the LLM factory registry (in `fabro-llm`) must agree +//! on the same set of adapter keys; the parity is enforced by tests. + +use crate::reasoning::ReasoningEffort; +use crate::Speed; + +/// Internal dispatch key that `fabro-agent` maps to a concrete agent profile. +/// +/// This is **not** a settings field. The agent profile is inferred from the +/// adapter, never set directly in TOML. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AgentProfileKind { + Anthropic, + OpenAi, + Gemini, +} + +/// How an API key for the adapter is converted into an HTTP authentication +/// header. +/// +/// Carries no secret values — the actual key is supplied at request time by +/// `fabro-auth::build_api_key_header(policy, key)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiKeyHeaderPolicy { + /// Standard `Authorization: Bearer ` header. + Bearer, + /// Custom header name carrying the raw key as its value, e.g. Anthropic's + /// `x-api-key`. + Custom { name: &'static str }, +} + +/// Native control values an adapter knows how to send through its provider +/// API. +#[derive(Debug, Clone, Copy)] +pub struct AdapterControlCapabilities { + /// Reasoning-effort values that can be sent through the provider's native + /// effort field. Models declaring `features.effort = true` may declare + /// `controls.reasoning_effort` only as a non-empty subset of this list. + pub native_reasoning_effort: &'static [ReasoningEffort], + /// Additional speeds (beyond `Speed::Standard`, which is implicit) the + /// adapter supports. Models may declare `controls.speed` only as a + /// subset of this list. + pub additional_speeds: &'static [Speed], +} + +/// Static metadata for a single adapter implementation. +#[derive(Debug, Clone, Copy)] +pub struct AdapterMetadata { + /// Stable adapter key referenced from `[llm.providers.] adapter = + /// "..."`. + pub key: &'static str, + /// Default agent profile dispatched for providers that use this adapter. + pub default_profile: AgentProfileKind, + /// How API keys for this adapter are converted into auth headers. + pub api_key_header: ApiKeyHeaderPolicy, + /// Native control values the adapter can transmit. + pub controls: AdapterControlCapabilities, +} + +const FULL_REASONING_EFFORTS: &[ReasoningEffort] = &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, +]; + +const FAST_SPEEDS: &[Speed] = &[Speed::Fast]; + +/// Anthropic — `anthropic` adapter. +pub const ANTHROPIC: AdapterMetadata = AdapterMetadata { + key: "anthropic", + default_profile: AgentProfileKind::Anthropic, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, + controls: AdapterControlCapabilities { + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: FAST_SPEEDS, + }, +}; + +/// OpenAI — `openai` adapter. +pub const OPENAI: AdapterMetadata = AdapterMetadata { + key: "openai", + default_profile: AgentProfileKind::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: &[], + }, +}; + +/// Google Gemini — `gemini` adapter. +pub const GEMINI: AdapterMetadata = AdapterMetadata { + key: "gemini", + default_profile: AgentProfileKind::Gemini, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-goog-api-key" }, + controls: AdapterControlCapabilities { + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: &[], + }, +}; + +/// OpenAI-compatible — `openai_compatible` adapter, used by Kimi/Zai/etc. +/// Routes through the OpenAI agent profile but accepts arbitrary `base_url` +/// per provider settings. +pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata { + key: "openai_compatible", + default_profile: AgentProfileKind::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { + // `openai_compatible` providers vary widely; the catalog requires + // models declaring `features.effort = true` to enumerate exactly + // which effort values their endpoint accepts. + native_reasoning_effort: FULL_REASONING_EFFORTS, + additional_speeds: &[], + }, +}; + +/// All built-in adapter metadata, in stable iteration order. +pub const ALL_ADAPTERS: &[AdapterMetadata] = &[ANTHROPIC, OPENAI, GEMINI, OPENAI_COMPATIBLE]; + +/// Look up adapter metadata by stable key. +#[must_use] +pub fn get(key: &str) -> Option<&'static AdapterMetadata> { + ALL_ADAPTERS.iter().find(|a| a.key == key) +} + +/// Iterate every registered adapter key. +pub fn keys() -> impl Iterator { + ALL_ADAPTERS.iter().map(|a| a.key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_by_known_key() { + assert_eq!(get("anthropic").unwrap().key, "anthropic"); + assert_eq!(get("openai").unwrap().key, "openai"); + assert_eq!(get("gemini").unwrap().key, "gemini"); + assert_eq!(get("openai_compatible").unwrap().key, "openai_compatible"); + } + + #[test] + fn lookup_unknown_key_returns_none() { + assert!(get("does_not_exist").is_none()); + } + + #[test] + fn keys_are_unique_and_match_all_adapters() { + let keys: Vec<&'static str> = keys().collect(); + let mut sorted = keys.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), keys.len(), "duplicate adapter key"); + assert_eq!(sorted.len(), ALL_ADAPTERS.len()); + } + + #[test] + fn anthropic_uses_custom_x_api_key_header() { + match ANTHROPIC.api_key_header { + ApiKeyHeaderPolicy::Custom { name } => assert_eq!(name, "x-api-key"), + ApiKeyHeaderPolicy::Bearer => panic!("expected custom header for anthropic"), + } + } + + #[test] + fn openai_uses_bearer_header() { + assert!(matches!(OPENAI.api_key_header, ApiKeyHeaderPolicy::Bearer)); + } + + #[test] + fn anthropic_supports_fast_speed() { + assert!(ANTHROPIC.controls.additional_speeds.contains(&Speed::Fast)); + } + + #[test] + fn openai_compatible_uses_openai_profile() { + assert_eq!( + OPENAI_COMPATIBLE.default_profile, + AgentProfileKind::OpenAi + ); + } + + #[test] + fn every_adapter_supports_full_native_reasoning_effort() { + for adapter in ALL_ADAPTERS { + assert_eq!( + adapter.controls.native_reasoning_effort.len(), + FULL_REASONING_EFFORTS.len(), + "adapter {} should expose all reasoning-effort values", + adapter.key, + ); + } + } +} diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs new file mode 100644 index 000000000..68f8d9817 --- /dev/null +++ b/lib/crates/fabro-model/src/ids.rs @@ -0,0 +1,146 @@ +//! String-backed provider and model identifiers. +//! +//! Provider and model identity are catalog data, not closed enums. These +//! newtypes give catalog/auth/server seams a single, type-safe wrapper while +//! keeping wire format compatible with plain strings. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Stable provider identifier referenced from settings, vault, and request +/// routing. +/// +/// Wraps a `String` because the set of providers is open-ended and supplied +/// by `[llm.providers]` settings rather than compiled into a Rust enum. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProviderId(String); + +impl ProviderId { + /// Construct a provider ID from any string-like value without validation. + /// Catalog construction is responsible for canonicalisation; consumers + /// only need a wrapper for type clarity. + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + /// Borrow the inner string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume the wrapper and return the inner `String`. + #[must_use] + pub fn into_inner(self) -> String { + self.0 + } +} + +impl fmt::Display for ProviderId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for ProviderId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for ProviderId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl AsRef for ProviderId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Stable model identifier — either the canonical catalog ID or one of its +/// declared aliases. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ModelId(String); + +impl ModelId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn into_inner(self) -> String { + self.0 + } +} + +impl fmt::Display for ModelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for ModelId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for ModelId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl AsRef for ModelId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_id_is_transparent_string_in_json() { + let id = ProviderId::new("kimi"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"kimi\""); + let back: ProviderId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + } + + #[test] + fn model_id_is_transparent_string_in_json() { + let id = ModelId::new("kimi-k2.5"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"kimi-k2.5\""); + let back: ModelId = serde_json::from_str(&json).unwrap(); + assert_eq!(back, id); + } + + #[test] + fn display_writes_inner_string() { + assert_eq!(ProviderId::new("anthropic").to_string(), "anthropic"); + assert_eq!(ModelId::new("claude-opus-4-7").to_string(), "claude-opus-4-7"); + } + + #[test] + fn ord_is_lexicographic() { + let mut v = vec![ProviderId::new("zai"), ProviderId::new("anthropic")]; + v.sort(); + assert_eq!(v[0].as_str(), "anthropic"); + assert_eq!(v[1].as_str(), "zai"); + } +} diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 9a95e5815..481929cf2 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -1,10 +1,16 @@ +pub mod adapter; pub mod billing; pub mod catalog; +pub mod ids; pub mod model_ref; pub mod model_test; pub mod provider; +pub mod reasoning; pub mod types; +pub use adapter::{ + AdapterControlCapabilities, AdapterMetadata, AgentProfileKind, ApiKeyHeaderPolicy, +}; pub use billing::{ AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment, @@ -12,7 +18,9 @@ pub use billing::{ OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros, }; pub use catalog::{Catalog, FallbackTarget}; +pub use ids::{ModelId, ProviderId}; pub use model_ref::ModelHandle; pub use model_test::ModelTestMode; pub use provider::Provider; +pub use reasoning::ReasoningEffort; pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits}; diff --git a/lib/crates/fabro-model/src/reasoning.rs b/lib/crates/fabro-model/src/reasoning.rs new file mode 100644 index 000000000..2ea61e8e4 --- /dev/null +++ b/lib/crates/fabro-model/src/reasoning.rs @@ -0,0 +1,85 @@ +//! Shared reasoning-effort enum. +//! +//! `ReasoningEffort` is a Rust-owned vocabulary type. Catalog data, request +//! validation, OpenAPI replacement types, and the LLM client all share one +//! enum so that adding a new effort value remains a Rust change. + +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, + strum::VariantArray, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReasoningEffort { + Low, + Medium, + High, + XHigh, + Max, +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use strum::VariantArray; + + use super::*; + + #[test] + fn parses_canonical_lowercase_strings() { + assert_eq!(ReasoningEffort::from_str("low").unwrap(), ReasoningEffort::Low); + assert_eq!( + ReasoningEffort::from_str("medium").unwrap(), + ReasoningEffort::Medium + ); + assert_eq!(ReasoningEffort::from_str("high").unwrap(), ReasoningEffort::High); + assert_eq!( + ReasoningEffort::from_str("xhigh").unwrap(), + ReasoningEffort::XHigh + ); + assert_eq!(ReasoningEffort::from_str("max").unwrap(), ReasoningEffort::Max); + } + + #[test] + fn rejects_unknown_strings() { + assert!(ReasoningEffort::from_str("none").is_err()); + assert!(ReasoningEffort::from_str("").is_err()); + assert!(ReasoningEffort::from_str("HIGH").is_err()); + } + + #[test] + fn display_matches_serde_lowercase() { + assert_eq!(ReasoningEffort::XHigh.to_string(), "xhigh"); + assert_eq!(<&'static str>::from(ReasoningEffort::Max), "max"); + } + + #[test] + fn variants_in_ordered_progression() { + let v = ReasoningEffort::VARIANTS; + assert_eq!(v[0], ReasoningEffort::Low); + assert_eq!(v[v.len() - 1], ReasoningEffort::Max); + } + + #[test] + fn round_trip_through_json() { + let json = serde_json::to_string(&ReasoningEffort::High).unwrap(); + assert_eq!(json, "\"high\""); + let parsed: ReasoningEffort = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ReasoningEffort::High); + } +}