diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index 99e4352bb..b08926bcd 100644 --- a/lib/crates/fabro-agent/src/profiles/anthropic.rs +++ b/lib/crates/fabro-agent/src/profiles/anthropic.rs @@ -6,7 +6,7 @@ use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{make_edit_file_tool, register_core_tools, WebFetchSummarizer}; -use fabro_model::{Catalog, LanguageModel, Provider}; +use fabro_model::{Catalog, Provider}; use super::EnvContext; diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index 0bc7dee94..c4b977352 100644 --- a/lib/crates/fabro-agent/src/profiles/gemini.rs +++ b/lib/crates/fabro-agent/src/profiles/gemini.rs @@ -9,7 +9,7 @@ use crate::tools::{ make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool, register_core_tools, WebFetchSummarizer, }; -use fabro_model::{Catalog, LanguageModel, Provider}; +use fabro_model::{Catalog, Provider}; use super::EnvContext; diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index f00849fef..3465d33bb 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/fabro-agent/src/profiles/openai.rs @@ -7,7 +7,7 @@ use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{register_core_tools, WebFetchSummarizer}; use crate::v4a_patch::make_apply_patch_tool; -use fabro_model::{Catalog, LanguageModel, Provider}; +use fabro_model::{Catalog, Provider}; use super::EnvContext; diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 79d0d849b..53ccb3ddf 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -907,7 +907,7 @@ impl Session { max_tokens: self.config.max_tokens.or_else(|| { fabro_model::Catalog::builtin() .get(self.provider_profile.model()) - .and_then(fabro_model::LanguageModel::max_output) + .and_then(fabro_model::Model::max_output) }), stop_sequences: None, reasoning_effort: self.config.reasoning_effort.clone(), diff --git a/lib/crates/fabro-agent/tests/guardrails.rs b/lib/crates/fabro-agent/tests/guardrails.rs index 911be84ff..f1d778cae 100644 --- a/lib/crates/fabro-agent/tests/guardrails.rs +++ b/lib/crates/fabro-agent/tests/guardrails.rs @@ -1,5 +1,5 @@ use fabro_agent::{AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile}; -use fabro_model::{Catalog, LanguageModel, Provider}; +use fabro_model::{Catalog, Provider}; #[test] fn profile_context_window_matches_catalog_for_default_models() { diff --git a/lib/crates/fabro-api/src/serve.rs b/lib/crates/fabro-api/src/serve.rs index f3cbb2357..33b944446 100644 --- a/lib/crates/fabro-api/src/serve.rs +++ b/lib/crates/fabro-api/src/serve.rs @@ -304,7 +304,7 @@ fn resolve_model_provider( info.id.clone(), provider_str .map(|s| s.to_string()) - .or(Some(info.provider.clone())), + .or(Some(info.provider.to_string())), ), None => (model, provider_str.map(|s| s.to_string())), }; diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index 0112721cc..301a860a5 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -1066,7 +1066,7 @@ async fn test_model( } let params = fabro_llm::generate::GenerateParams::new(&info.id) - .provider(&info.provider) + .provider(info.provider.as_str()) .prompt("Say OK") .max_tokens(16); @@ -1174,7 +1174,7 @@ async fn create_completion( // Resolve provider: explicit request > catalog > None let provider_name = req .provider - .or_else(|| catalog_info.map(|i| i.provider.clone())); + .or_else(|| catalog_info.map(|i| i.provider.to_string())); info!(model = %model_id, provider = ?provider_name, "Completion request received"); diff --git a/lib/crates/fabro-api/src/sessions.rs b/lib/crates/fabro-api/src/sessions.rs index 786e308d8..3c35ee999 100644 --- a/lib/crates/fabro-api/src/sessions.rs +++ b/lib/crates/fabro-api/src/sessions.rs @@ -67,7 +67,7 @@ fn resolve_model(model_arg: Option) -> (String, Option) { .map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone()) }); match fabro_model::Catalog::builtin().get(&raw) { - Some(info) => (info.id.clone(), Some(info.provider.clone())), + Some(info) => (info.id.clone(), Some(info.provider.to_string())), None => (raw, None), } } diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index 4e66e30c1..db8ebb41c 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -263,7 +263,10 @@ pub(crate) fn resolve_model_provider( // Resolve model alias through catalog match Catalog::builtin().get(&model) { - Some(info) => (info.id.clone(), provider.or(Some(info.provider.clone()))), + Some(info) => ( + info.id.clone(), + provider.or(Some(info.provider.to_string())), + ), None => (model, provider), } } @@ -2314,7 +2317,7 @@ async fn run_preflight( // Resolve through catalog to get canonical model ID and provider let (resolved_model, resolved_provider) = if let Some(info) = Catalog::builtin().get(node_model) { - (info.id.clone(), info.provider.clone()) + (info.id.clone(), info.provider.to_string()) } else { (node_model.to_string(), node_provider.to_string()) }; @@ -2333,7 +2336,7 @@ async fn run_preflight( if model_providers.is_empty() { let (resolved_model, resolved_provider) = if let Some(info) = Catalog::builtin().get(&model) { - (info.id.clone(), info.provider.clone()) + (info.id.clone(), info.provider.to_string()) } else { (model.clone(), default_provider.to_string()) }; diff --git a/lib/crates/fabro-llm/README.md b/lib/crates/fabro-llm/README.md index 92f0ed792..cce0c9818 100644 --- a/lib/crates/fabro-llm/README.md +++ b/lib/crates/fabro-llm/README.md @@ -197,7 +197,7 @@ let best_reasoner = get_latest_model("anthropic", Some("reasoning")); | `ToolChoice` | Auto, None, Required, or Named tool selection | | `Usage` | Token counts including input, output, reasoning, and cache tokens | | `RetryPolicy` | Configurable retry with exponential backoff, jitter, and max delay | -| `ModelInfo` | Metadata about a model (context window, capabilities, costs) | +| `Model` | Metadata about a model (context window, capabilities, costs) | ## Error handling diff --git a/lib/crates/fabro-llm/src/cli.rs b/lib/crates/fabro-llm/src/cli.rs index 89ce57d1a..39c739147 100644 --- a/lib/crates/fabro-llm/src/cli.rs +++ b/lib/crates/fabro-llm/src/cli.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use fabro_util::terminal::Styles; -use fabro_model::{Catalog, ModelInfo, Provider}; +use fabro_model::{Catalog, Model, Provider}; use crate::generate::{self, GenerateParams}; use crate::tools::Tool; @@ -123,7 +123,7 @@ fn color_if(use_color: bool, color: Color) -> Option { } } -fn model_row(model: &ModelInfo, use_color: bool) -> Vec { +fn model_row(model: &Model, use_color: bool) -> Vec { let aliases = model.aliases.join(", "); let cost = format!( "{} / {}", @@ -134,7 +134,6 @@ fn model_row(model: &ModelInfo, use_color: bool) -> Vec { model.id.clone().cell().bold(use_color), model .provider - .clone() .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), aliases @@ -162,7 +161,7 @@ fn models_title() -> Vec { ] } -fn print_models_table(models: &[crate::types::ModelInfo], s: &Styles) { +fn print_models_table(models: &[crate::types::Model], s: &Styles) { let use_color = s.use_color; let rows: Vec> = models.iter().map(|m| model_row(m, use_color)).collect(); let table = rows @@ -208,7 +207,7 @@ fn resolve_model(model_arg: Option) -> (String, Option) { .map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone()) }); match Catalog::builtin().get(&raw) { - Some(info) => (info.id.clone(), Some(info.provider.clone())), + Some(info) => (info.id.clone(), Some(info.provider.to_string())), None => (raw, None), } } @@ -766,7 +765,7 @@ pub async fn run_chat_via_server(args: ChatArgs, server: &ServerConnection) -> R #[derive(Deserialize)] struct PaginatedModelsResponse { - data: Vec, + data: Vec, } #[derive(Deserialize)] @@ -779,7 +778,7 @@ async fn fetch_models_from_server( client: &reqwest::Client, base_url: &str, provider: Option<&str>, -) -> Result> { +) -> Result> { let url = format!("{base_url}/models?page[limit]=100"); tracing::debug!(url = %url, "Fetching models from server"); @@ -804,7 +803,7 @@ async fn fetch_models_from_server( tracing::debug!(model_count = models.len(), "Models received from server"); if let Some(p) = provider { - models.retain(|m| m.provider == p); + models.retain(|m| m.provider.as_str() == p); } Ok(models) @@ -834,7 +833,7 @@ async fn test_model_via_server( .context("Failed to parse model test response from server") } -fn build_deep_test_params(info: &ModelInfo) -> Option { +fn build_deep_test_params(info: &Model) -> Option { if !info.features.tools { return None; } @@ -858,7 +857,7 @@ fn build_deep_test_params(info: &ModelInfo) -> Option { ); let mut params = GenerateParams::new(&info.id) - .provider(&info.provider) + .provider(info.provider.as_str()) .prompt( "I have three numbers: 15, 27, and 42. \ First use the add tool to compute 15 + 27, \ @@ -878,7 +877,7 @@ fn build_deep_test_params(info: &ModelInfo) -> Option { fn validate_deep_result( result: &crate::types::GenerateResult, - info: &ModelInfo, + info: &Model, ) -> (cli_table::Color, String) { // Check tool use: need at least 2 steps (tool call + follow-up) if result.steps.len() < 2 { @@ -1043,7 +1042,7 @@ pub async fn run_models( Ok(()) } -async fn test_one_model(info: &ModelInfo, deep: bool) -> (Color, String) { +async fn test_one_model(info: &Model, deep: bool) -> (Color, String) { if deep { match build_deep_test_params(info) { None => (Color::Yellow, "deep: skipped (no tool support)".to_string()), @@ -1059,7 +1058,7 @@ async fn test_one_model(info: &ModelInfo, deep: bool) -> (Color, String) { } } else { let params = GenerateParams::new(&info.id) - .provider(&info.provider) + .provider(info.provider.as_str()) .prompt("Say OK") .max_tokens(16); @@ -1106,7 +1105,7 @@ async fn test_models( pb.enable_steady_tick(Duration::from_millis(100)); // Build (original_index, model_info) pairs, then shuffle for provider spread - let mut indexed: Vec<(usize, &ModelInfo)> = models_to_test.iter().enumerate().collect(); + let mut indexed: Vec<(usize, &Model)> = models_to_test.iter().enumerate().collect(); indexed.shuffle(&mut rand::thread_rng()); // Run tests concurrently, 6 at a time @@ -1439,7 +1438,7 @@ mod tests { .body(serde_json::json!({ "data": [{ "id": "test-model", - "provider": "test-provider", + "provider": "anthropic", "family": "test", "display_name": "Test Model", "limits": { "context_window": 128000, "max_output": 4096 }, @@ -1462,7 +1461,7 @@ mod tests { mock.assert_async().await; assert_eq!(models.len(), 1); assert_eq!(models[0].id, "test-model"); - assert_eq!(models[0].provider, "test-provider"); + assert_eq!(models[0].provider, fabro_model::Provider::Anthropic); } #[tokio::test] @@ -1478,7 +1477,7 @@ mod tests { "data": [ { "id": "model-a", - "provider": "alpha", + "provider": "anthropic", "family": "a", "display_name": "Model A", "limits": { "context_window": 8000 }, @@ -1489,7 +1488,7 @@ mod tests { }, { "id": "model-b", - "provider": "beta", + "provider": "openai", "family": "b", "display_name": "Model B", "limits": { "context_window": 8000 }, @@ -1507,7 +1506,7 @@ mod tests { .await; let client = reqwest::Client::new(); - let models = fetch_models_from_server(&client, &server.url(""), Some("alpha")) + let models = fetch_models_from_server(&client, &server.url(""), Some("anthropic")) .await .unwrap(); diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index f415c6c48..ebe62d44a 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -145,7 +145,7 @@ impl Client { fn resolve_provider(&self, request: &Request) -> Result, SdkError> { let catalog_provider = fabro_model::Catalog::builtin() .get(&request.model) - .map(|info| info.provider.clone()); + .map(|info| info.provider.to_string()); let provider_name = request .provider diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index 54bdf2c89..b2a3ca6bd 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -640,9 +640,9 @@ impl StreamEvent { } } -// --- 2.9 ModelInfo (re-exported from fabro-model) --- +// --- 2.9 Model (re-exported from fabro-model) --- -pub use fabro_model::{ModelCosts, ModelFeatures, ModelInfo, ModelLimits}; +pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits}; // --- 4.7 Timeouts --- diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index fa1d30d5a..62def1dd7 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -2,11 +2,11 @@ use std::collections::HashMap; use std::sync::LazyLock; use crate::provider::Provider; -use crate::types::ModelInfo; +use crate::types::Model; /// Global singleton catalog parsed from embedded catalog.json. static GLOBAL_CATALOG: LazyLock = LazyLock::new(|| { - let models: Vec = serde_json::from_str(include_str!("catalog.json")) + let models: Vec = serde_json::from_str(include_str!("catalog.json")) .expect("embedded catalog.json must be valid"); Catalog { models } }); @@ -18,12 +18,12 @@ pub struct FallbackTarget { pub model: String, } -/// Typed model catalog backed by a `Vec`. +/// Typed model catalog backed by a `Vec`. /// /// Use [`Catalog::builtin()`] for the embedded catalog, or [`Catalog::from_models()`] /// for testing with custom model sets. pub struct Catalog { - models: Vec, + models: Vec, } impl Catalog { @@ -35,13 +35,13 @@ impl Catalog { /// Create a catalog from a custom set of models (useful for testing). #[must_use] - pub fn from_models(models: Vec) -> Self { + pub fn from_models(models: Vec) -> Self { Self { models } } /// Look up a model by ID or alias. #[must_use] - pub fn get(&self, id: &str) -> Option<&ModelInfo> { + pub fn get(&self, id: &str) -> Option<&Model> { self.models .iter() .find(|m| m.id == id || m.aliases.iter().any(|a| a == id)) @@ -49,13 +49,10 @@ impl Catalog { /// List all models, optionally filtered by provider. #[must_use] - pub fn list(&self, provider: Option) -> Vec<&ModelInfo> { + pub fn list(&self, provider: Option) -> Vec<&Model> { match provider { None => self.models.iter().collect(), - Some(p) => { - let ps = p.as_str(); - self.models.iter().filter(|m| m.provider == ps).collect() - } + Some(p) => self.models.iter().filter(|m| m.provider == p).collect(), } } @@ -64,7 +61,7 @@ impl Catalog { /// # Panics /// Panics if the catalog contains no default model. #[must_use] - pub fn default_model(&self) -> &ModelInfo { + pub fn default_model(&self) -> &Model { self.models .iter() .find(|m| m.default) @@ -73,15 +70,14 @@ impl Catalog { /// The default model for a specific provider. #[must_use] - pub fn default_for_provider(&self, p: Provider) -> Option<&ModelInfo> { - let ps = p.as_str(); - self.models.iter().find(|m| m.provider == ps && m.default) + pub fn default_for_provider(&self, p: Provider) -> Option<&Model> { + self.models.iter().find(|m| m.provider == p && m.default) } /// Default model for the best-available provider (based on API keys), /// falling back to the global catalog default. #[must_use] - pub fn default_from_env(&self) -> &ModelInfo { + pub fn default_from_env(&self) -> &Model { let provider = Provider::default_from_env(); self.default_for_provider(provider) .unwrap_or_else(|| self.default_model()) @@ -90,7 +86,7 @@ impl Catalog { /// Probe model for a provider — the cheapest model suitable for connectivity checks. /// Falls back to the provider's default when no explicit override is configured. #[must_use] - pub fn probe_for_provider(&self, p: Provider) -> Option<&ModelInfo> { + pub fn probe_for_provider(&self, p: Provider) -> Option<&Model> { let override_id: Option<&str> = match p { Provider::OpenAi => Some("gpt-5.4-mini"), _ => None, @@ -108,12 +104,11 @@ impl Catalog { /// Hard-filters on `features.tools`, `features.vision`, and `features.reasoning`. /// Among matches, picks the closest by `costs.input_cost_per_mtok` (absolute diff). #[must_use] - pub fn closest(&self, target: Provider, reference: &ModelInfo) -> Option<&ModelInfo> { - let ps = target.as_str(); + pub fn closest(&self, target: Provider, reference: &Model) -> Option<&Model> { self.models .iter() .filter(|m| { - m.provider == ps + m.provider == target && m.features.tools == reference.features.tools && m.features.vision == reference.features.vision && m.features.reasoning == reference.features.reasoning @@ -165,6 +160,7 @@ impl Catalog { #[cfg(test)] mod tests { use super::*; + use crate::provider::Provider; use std::str::FromStr; // ---- Catalog struct tests ---- @@ -196,7 +192,7 @@ mod tests { fn builtin_list_by_provider() { let anthropic = Catalog::builtin().list(Some(Provider::Anthropic)); assert!(!anthropic.is_empty()); - assert!(anthropic.iter().all(|m| m.provider == "anthropic")); + assert!(anthropic.iter().all(|m| m.provider == Provider::Anthropic)); } #[test] @@ -336,11 +332,11 @@ mod tests { #[test] fn from_models_custom_catalog() { - use crate::types::{ModelCosts, ModelFeatures, ModelLimits}; + use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits}; - let models = vec![ModelInfo { + let models = vec![Model { id: "test-model".to_string(), - provider: "anthropic".to_string(), + provider: Provider::Anthropic, family: "test".to_string(), display_name: "Test Model".to_string(), limits: ModelLimits { @@ -406,12 +402,13 @@ mod tests { } #[test] - fn catalog_provider_strings_roundtrip_through_provider() { + fn catalog_providers_roundtrip_through_as_str() { for model in Catalog::builtin().list(None) { - let parsed = Provider::from_str(&model.provider); - assert!( - parsed.is_ok(), - "catalog model '{}' has provider '{}' which does not parse as Provider", + let roundtripped = Provider::from_str(model.provider.as_str()); + assert_eq!( + roundtripped, + Ok(model.provider), + "catalog model '{}' provider {:?} does not roundtrip through as_str", model.id, model.provider ); @@ -437,9 +434,9 @@ mod tests { fn get_model_info_by_id() { let info = Catalog::builtin().get("claude-opus-4-6").unwrap(); insta::assert_debug_snapshot!(info, @r#" - ModelInfo { + Model { id: "claude-opus-4-6", - provider: "anthropic", + provider: Anthropic, family: "claude-4", display_name: "Claude Opus 4.6", limits: ModelLimits { @@ -504,9 +501,9 @@ mod tests { .get("gemini-3.1-flash-lite-preview") .unwrap(); insta::assert_debug_snapshot!(m, @r#" - ModelInfo { + Model { id: "gemini-3.1-flash-lite-preview", - provider: "gemini", + provider: Gemini, family: "gemini-3", display_name: "Gemini 3.1 Flash Lite (Preview)", limits: ModelLimits { @@ -558,9 +555,9 @@ mod tests { fn kimi_k2_5_in_catalog() { let m = Catalog::builtin().get("kimi-k2.5").unwrap(); insta::assert_debug_snapshot!(m, @r#" - ModelInfo { + Model { id: "kimi-k2.5", - provider: "kimi", + provider: Kimi, family: "kimi-k2", display_name: "Kimi K2.5", limits: ModelLimits { @@ -606,22 +603,22 @@ mod tests { #[test] fn glm_4_7_in_catalog() { let m = Catalog::builtin().get("glm-4.7").unwrap(); - assert_eq!(m.provider, "zai"); + assert_eq!(m.provider, Provider::Zai); } #[test] fn minimax_m2_5_in_catalog() { let m = Catalog::builtin().get("minimax-m2.5").unwrap(); - assert_eq!(m.provider, "minimax"); + assert_eq!(m.provider, Provider::Minimax); } #[test] fn mercury_2_in_catalog() { let m = Catalog::builtin().get("mercury-2").unwrap(); insta::assert_debug_snapshot!(m, @r#" - ModelInfo { + Model { id: "mercury-2", - provider: "inception", + provider: Inception, family: "mercury", display_name: "Mercury 2", limits: ModelLimits { @@ -666,9 +663,9 @@ mod tests { fn gpt_5_4_in_catalog() { let m = Catalog::builtin().get("gpt-5.4").unwrap(); insta::assert_debug_snapshot!(m, @r#" - ModelInfo { + Model { id: "gpt-5.4", - provider: "openai", + provider: OpenAi, family: "gpt-5", display_name: "GPT-5.4", limits: ModelLimits { @@ -713,9 +710,9 @@ mod tests { fn gpt_5_4_pro_in_catalog() { let m = Catalog::builtin().get("gpt-5.4-pro").unwrap(); insta::assert_debug_snapshot!(m, @r#" - ModelInfo { + Model { id: "gpt-5.4-pro", - provider: "openai", + provider: OpenAi, family: "gpt-5", display_name: "GPT-5.4 Pro", limits: ModelLimits { @@ -786,9 +783,9 @@ mod tests { fn gpt_5_3_codex_spark_in_catalog() { let m = Catalog::builtin().get("gpt-5.3-codex-spark").unwrap(); insta::assert_debug_snapshot!(m, @r#" - ModelInfo { + Model { id: "gpt-5.3-codex-spark", - provider: "openai", + provider: OpenAi, family: "gpt-5", display_name: "GPT-5.3 Codex Spark", limits: ModelLimits { diff --git a/lib/crates/fabro-model/src/language_model.rs b/lib/crates/fabro-model/src/language_model.rs deleted file mode 100644 index a09d3b8d4..000000000 --- a/lib/crates/fabro-model/src/language_model.rs +++ /dev/null @@ -1,151 +0,0 @@ -use crate::provider::Provider; -use crate::types::ModelInfo; - -/// Trait abstracting over model metadata. Implemented by `ModelInfo` via blanket impl, -/// and intended as the primary interface for querying model capabilities. -pub trait LanguageModel: Send + Sync + std::fmt::Debug { - fn id(&self) -> &str; - fn provider(&self) -> Provider; - fn family(&self) -> &str; - fn display_name(&self) -> &str; - fn context_window(&self) -> i64; - fn max_output(&self) -> Option; - fn supports_tools(&self) -> bool; - fn supports_vision(&self) -> bool; - fn supports_reasoning(&self) -> bool; - fn supports_effort(&self) -> bool; - fn training(&self) -> Option<&str>; - fn input_cost_per_mtok(&self) -> Option; - fn output_cost_per_mtok(&self) -> Option; - fn cache_input_cost_per_mtok(&self) -> Option; - fn estimated_output_tps(&self) -> Option; - fn aliases(&self) -> &[String]; - fn is_default(&self) -> bool; - fn to_model_info(&self) -> ModelInfo; -} - -impl LanguageModel for ModelInfo { - fn id(&self) -> &str { - &self.id - } - - fn provider(&self) -> Provider { - self.provider - .parse::() - .unwrap_or(Provider::Anthropic) - } - - fn family(&self) -> &str { - &self.family - } - - fn display_name(&self) -> &str { - &self.display_name - } - - fn context_window(&self) -> i64 { - self.limits.context_window - } - - fn max_output(&self) -> Option { - self.limits.max_output - } - - fn supports_tools(&self) -> bool { - self.features.tools - } - - fn supports_vision(&self) -> bool { - self.features.vision - } - - fn supports_reasoning(&self) -> bool { - self.features.reasoning - } - - fn supports_effort(&self) -> bool { - self.features.effort - } - - fn training(&self) -> Option<&str> { - self.training.as_deref() - } - - fn input_cost_per_mtok(&self) -> Option { - self.costs.input_cost_per_mtok - } - - fn output_cost_per_mtok(&self) -> Option { - self.costs.output_cost_per_mtok - } - - fn cache_input_cost_per_mtok(&self) -> Option { - self.costs.cache_input_cost_per_mtok - } - - fn estimated_output_tps(&self) -> Option { - self.estimated_output_tps - } - - fn aliases(&self) -> &[String] { - &self.aliases - } - - fn is_default(&self) -> bool { - self.default - } - - fn to_model_info(&self) -> ModelInfo { - self.clone() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::catalog::Catalog; - - #[test] - fn trait_is_object_safe() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone(); - let boxed: Box = Box::new(info); - assert_eq!(boxed.id(), "claude-opus-4-6"); - } - - #[test] - fn blanket_impl_returns_correct_values() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap(); - assert_eq!(info.id(), "claude-opus-4-6"); - assert_eq!(info.provider(), Provider::Anthropic); - assert_eq!(info.family(), "claude-4"); - assert_eq!(info.display_name(), "Claude Opus 4.6"); - assert_eq!(info.context_window(), 1_000_000); - assert_eq!(info.max_output(), Some(128_000)); - assert!(info.supports_tools()); - assert!(info.supports_vision()); - assert!(info.supports_reasoning()); - assert!(info.supports_effort()); - assert_eq!(info.training(), Some("2025-08-01")); - assert_eq!(info.input_cost_per_mtok(), Some(15.0)); - assert_eq!(info.output_cost_per_mtok(), Some(75.0)); - assert_eq!(info.cache_input_cost_per_mtok(), Some(1.5)); - assert_eq!(info.estimated_output_tps(), Some(25.0)); - assert!(!info.aliases().is_empty()); - assert!(!info.is_default()); - } - - #[test] - fn all_catalog_providers_roundtrip() { - for model in Catalog::builtin().list(None) { - // Should not panic — every catalog model's provider string must parse - let _ = model.provider(); - } - } - - #[test] - fn to_model_info_roundtrips() { - let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone(); - let roundtripped = info.to_model_info(); - assert_eq!(info, roundtripped); - } -} diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index 40d0c5de7..bf77eb0d0 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -1,11 +1,9 @@ pub mod catalog; -pub mod language_model; pub mod model_ref; pub mod provider; pub mod types; pub use catalog::{Catalog, FallbackTarget}; -pub use language_model::LanguageModel; pub use model_ref::ModelRef; pub use provider::Provider; -pub use types::{ModelCosts, ModelFeatures, ModelInfo, ModelLimits}; +pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits}; diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/crates/fabro-model/src/model_ref.rs index f0b37f27e..2390e86bf 100644 --- a/lib/crates/fabro-model/src/model_ref.rs +++ b/lib/crates/fabro-model/src/model_ref.rs @@ -1,15 +1,15 @@ use std::fmt; use std::sync::Arc; -use crate::language_model::LanguageModel; use crate::provider::Provider; +use crate::types::Model; -/// A reference to a model — either a fully resolved `LanguageModel` or a +/// A reference to a model — either a fully resolved `Model` or a /// provider + model-name pair that hasn't been looked up yet. #[derive(Clone)] pub enum ModelRef { /// A model whose metadata has been resolved from the catalog. - Resolved(Arc), + Resolved(Arc), /// An unresolved provider:model pair (e.g. from CLI input or config). ByName { provider: Provider, model: String }, } @@ -19,7 +19,7 @@ impl ModelRef { #[must_use] pub fn model_id(&self) -> &str { match self { - Self::Resolved(m) => m.id(), + Self::Resolved(m) => &m.id, Self::ByName { model, .. } => model, } } @@ -28,7 +28,7 @@ impl ModelRef { #[must_use] pub fn provider(&self) -> Provider { match self { - Self::Resolved(m) => m.provider(), + Self::Resolved(m) => m.provider, Self::ByName { provider, .. } => *provider, } } @@ -43,7 +43,7 @@ impl fmt::Display for ModelRef { impl fmt::Debug for ModelRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id()), + Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id), Self::ByName { provider, model } => f .debug_struct("ModelRef::ByName") .field("provider", provider) diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/crates/fabro-model/src/provider.rs index 5803b9cb7..5ca290adc 100644 --- a/lib/crates/fabro-model/src/provider.rs +++ b/lib/crates/fabro-model/src/provider.rs @@ -11,12 +11,14 @@ use std::str::FromStr; #[serde(rename_all = "snake_case")] pub enum Provider { Anthropic, + #[serde(rename = "openai", alias = "open_ai")] OpenAi, Gemini, Kimi, Zai, Minimax, Inception, + #[serde(rename = "openai_compatible", alias = "open_ai_compatible")] OpenAiCompatible, } diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs index 0e478b396..9531c1282 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/fabro-model/src/types.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; -// --- 2.9 ModelInfo --- +use crate::provider::Provider; + +// --- 2.9 Model --- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ModelLimits { @@ -29,9 +31,9 @@ pub struct ModelCosts { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ModelInfo { +pub struct Model { pub id: String, - pub provider: String, + pub provider: Provider, pub family: String, pub display_name: String, pub limits: ModelLimits, @@ -43,3 +45,109 @@ pub struct ModelInfo { #[serde(default)] pub default: bool, } + +impl Model { + pub fn id(&self) -> &str { + &self.id + } + + pub fn provider(&self) -> Provider { + self.provider + } + + pub fn family(&self) -> &str { + &self.family + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn context_window(&self) -> i64 { + self.limits.context_window + } + + pub fn max_output(&self) -> Option { + self.limits.max_output + } + + pub fn supports_tools(&self) -> bool { + self.features.tools + } + + pub fn supports_vision(&self) -> bool { + self.features.vision + } + + pub fn supports_reasoning(&self) -> bool { + self.features.reasoning + } + + pub fn supports_effort(&self) -> bool { + self.features.effort + } + + pub fn training(&self) -> Option<&str> { + self.training.as_deref() + } + + pub fn input_cost_per_mtok(&self) -> Option { + self.costs.input_cost_per_mtok + } + + pub fn output_cost_per_mtok(&self) -> Option { + self.costs.output_cost_per_mtok + } + + pub fn cache_input_cost_per_mtok(&self) -> Option { + self.costs.cache_input_cost_per_mtok + } + + pub fn estimated_output_tps(&self) -> Option { + self.estimated_output_tps + } + + pub fn aliases(&self) -> &[String] { + &self.aliases + } + + pub fn is_default(&self) -> bool { + self.default + } +} + +#[cfg(test)] +mod tests { + use crate::catalog::Catalog; + use crate::provider::Provider; + + #[test] + fn inherent_methods_return_correct_values() { + let info = Catalog::builtin().get("claude-opus-4-6").unwrap(); + assert_eq!(info.id(), "claude-opus-4-6"); + assert_eq!(info.provider(), Provider::Anthropic); + assert_eq!(info.family(), "claude-4"); + assert_eq!(info.display_name(), "Claude Opus 4.6"); + assert_eq!(info.context_window(), 1_000_000); + assert_eq!(info.max_output(), Some(128_000)); + assert!(info.supports_tools()); + assert!(info.supports_vision()); + assert!(info.supports_reasoning()); + assert!(info.supports_effort()); + assert_eq!(info.training(), Some("2025-08-01")); + assert_eq!(info.input_cost_per_mtok(), Some(15.0)); + assert_eq!(info.output_cost_per_mtok(), Some(75.0)); + assert_eq!(info.cache_input_cost_per_mtok(), Some(1.5)); + assert_eq!(info.estimated_output_tps(), Some(25.0)); + assert!(!info.aliases().is_empty()); + assert!(!info.is_default()); + } + + #[test] + fn all_catalog_providers_are_valid() { + for model in Catalog::builtin().list(None) { + // provider() just returns the Provider enum, no parsing needed + let _ = model.provider(); + } + } +} diff --git a/lib/crates/fabro-workflows/src/transform.rs b/lib/crates/fabro-workflows/src/transform.rs index a34df462d..e92c866ad 100644 --- a/lib/crates/fabro-workflows/src/transform.rs +++ b/lib/crates/fabro-workflows/src/transform.rs @@ -126,7 +126,7 @@ impl Transform for ModelResolutionTransform { if let Some(model) = model { if let Some(info) = fabro_model::Catalog::builtin().get(&model) { let canonical_id = info.id.clone(); - let provider = info.provider.clone(); + let provider = info.provider.to_string(); // Resolve alias to canonical model ID if model != canonical_id { node.attrs