feat(model): add ProviderId/ModelId, ReasoningEffort, adapter metadata

Foundation types for the settings-driven LLM provider/model catalog plan.
Additive; existing fabro_model::Provider remains for compatibility.
This commit is contained in:
fabro-bot 2026-05-04 17:59:26 +00:00
parent a5a2a03f15
commit 3d0e66d625
4 changed files with 443 additions and 0 deletions

View file

@ -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 <key>` 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.<id>] 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<Item = &'static str> {
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,
);
}
}
}

View file

@ -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<String>) -> 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<String> for ProviderId {
fn from(s: String) -> Self {
Self(s)
}
}
impl AsRef<str> 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<String>) -> 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<String> for ModelId {
fn from(s: String) -> Self {
Self(s)
}
}
impl AsRef<str> 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");
}
}

View file

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

View file

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