mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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:
parent
a5a2a03f15
commit
3d0e66d625
4 changed files with 443 additions and 0 deletions
204
lib/crates/fabro-model/src/adapter.rs
Normal file
204
lib/crates/fabro-model/src/adapter.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
146
lib/crates/fabro-model/src/ids.rs
Normal file
146
lib/crates/fabro-model/src/ids.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
85
lib/crates/fabro-model/src/reasoning.rs
Normal file
85
lib/crates/fabro-model/src/reasoning.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue