diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 2e97d76ca..abaf712a2 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -6,7 +6,7 @@ use crate::{ }; use clap::{Args, Parser}; use fabro_llm::client::Client; -use fabro_model::{ModelId, Provider}; +use fabro_model::{Catalog, ModelRef, Provider}; use fabro_util::terminal::Styles; use std::io::{IsTerminal, Write}; use std::path::PathBuf; @@ -156,15 +156,20 @@ fn build_tool_approval( }) } -fn summarizer_model_id(provider: Provider) -> ModelId { - match provider { - Provider::OpenAi => ModelId::new(Provider::OpenAi, "gpt-4o-mini"), - Provider::Gemini => ModelId::new(Provider::Gemini, "gemini-2.0-flash"), - Provider::Anthropic => ModelId::new(Provider::Anthropic, "claude-haiku-4-5"), - Provider::Kimi => ModelId::new(Provider::Kimi, "kimi-k2.5"), - Provider::Zai => ModelId::new(Provider::Zai, "glm-4.7"), - Provider::Minimax => ModelId::new(Provider::Minimax, "minimax-m2.5"), - Provider::Inception => ModelId::new(Provider::Inception, "mercury"), +fn summarizer_model_id(provider: Provider) -> ModelRef { + ModelRef::ByName { + provider, + model: match provider { + Provider::OpenAi => "gpt-4o-mini", + Provider::Gemini => "gemini-2.0-flash", + Provider::Anthropic => "claude-haiku-4-5", + Provider::Kimi => "kimi-k2.5", + Provider::Zai => "glm-4.7", + Provider::Minimax => "minimax-m2.5", + Provider::Inception => "mercury", + Provider::OpenAiCompatible => "gpt-4o-mini", + } + .to_string(), } } @@ -187,7 +192,11 @@ fn build_profile( let summarizer = build_summarizer(provider, llm_client); match provider { Provider::OpenAi => Box::new(OpenAiProfile::with_summarizer(model, summarizer)), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => { Box::new(OpenAiProfile::with_summarizer(model, summarizer).with_provider(provider)) } Provider::Gemini => Box::new(GeminiProfile::with_summarizer(model, summarizer)), @@ -386,8 +395,10 @@ pub async fn run_with_args_and_client( // Resolve model and build profile let model = args.model.unwrap_or_else(|| { - fabro_model::default_model_for_provider(provider.as_str()) - .unwrap_or_else(fabro_model::default_model_from_env) + Catalog::builtin() + .default_for_provider(provider) + .cloned() + .unwrap_or_else(|| Catalog::builtin().default_from_env().clone()) .id }); eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); @@ -430,7 +441,11 @@ pub async fn run_with_args_and_client( &factory_model, child_summarizer, )), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => Arc::new( + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => Arc::new( OpenAiProfile::with_summarizer(&factory_model, child_summarizer) .with_provider(provider), ), diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs index 982572b10..b38fa6d46 100644 --- a/lib/crates/fabro-agent/src/memory.rs +++ b/lib/crates/fabro-agent/src/memory.rs @@ -19,7 +19,8 @@ pub async fn discover_memory( | Provider::Kimi | Provider::Zai | Provider::Minimax - | Provider::Inception => { + | Provider::Inception + | Provider::OpenAiCompatible => { vec!["AGENTS.md", ".codex/instructions.md"] } Provider::Gemini => vec!["AGENTS.md", "GEMINI.md"], diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index 7bceb3470..99e4352bb 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::Provider; +use fabro_model::{Catalog, LanguageModel, Provider}; use super::EnvContext; @@ -164,8 +164,9 @@ in the project. Keep changes minimal and focused on the task."; } fn capabilities(&self) -> ProfileCapabilities { - let context_window_size = fabro_model::get_model_info(self.model()) - .map(|info| info.limits.context_window as usize) + let context_window_size = Catalog::builtin() + .get(self.model()) + .map(|info| info.context_window() as usize) .unwrap_or_else(|| { if self.model().contains("opus-4-6") { 1_000_000 diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index 5f93ecbcf..0bc7dee94 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::Provider; +use fabro_model::{Catalog, LanguageModel, Provider}; use super::EnvContext; @@ -199,8 +199,9 @@ in the project."; } fn capabilities(&self) -> ProfileCapabilities { - let context_window_size = fabro_model::get_model_info(self.model()) - .map(|info| info.limits.context_window as usize) + let context_window_size = Catalog::builtin() + .get(self.model()) + .map(|info| info.context_window() as usize) .unwrap_or(1_000_000); ProfileCapabilities { supports_reasoning: true, diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index d5eb54d46..f00849fef 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::Provider; +use fabro_model::{Catalog, LanguageModel, Provider}; use super::EnvContext; @@ -191,8 +191,9 @@ in the project."; } fn capabilities(&self) -> ProfileCapabilities { - let context_window_size = fabro_model::get_model_info(self.model()) - .map(|info| info.limits.context_window as usize) + let context_window_size = Catalog::builtin() + .get(self.model()) + .map(|info| info.context_window() as usize) .unwrap_or(128_000); ProfileCapabilities { supports_reasoning: true, diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index dba09be50..79d0d849b 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -905,8 +905,9 @@ impl Session { temperature: None, top_p: None, max_tokens: self.config.max_tokens.or_else(|| { - fabro_model::get_model_info(self.provider_profile.model()) - .and_then(|m| m.limits.max_output) + fabro_model::Catalog::builtin() + .get(self.provider_profile.model()) + .and_then(fabro_model::LanguageModel::max_output) }), stop_sequences: None, reasoning_effort: self.config.reasoning_effort.clone(), diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/crates/fabro-agent/src/tools.rs index 14d5898e4..e00ad4583 100644 --- a/lib/crates/fabro-agent/src/tools.rs +++ b/lib/crates/fabro-agent/src/tools.rs @@ -3,7 +3,7 @@ use crate::sandbox::GrepOptions; use crate::tool_registry::RegisteredTool; use fabro_llm::client::Client; use fabro_llm::types::{Message, Request, ToolDefinition}; -use fabro_model::ModelId; +use fabro_model::ModelRef; use std::borrow::Cow; use std::fmt::Write; use std::sync::Arc; @@ -14,7 +14,7 @@ const MAX_WEB_FETCH_BYTES: usize = 100 * 1024; #[derive(Clone)] pub struct WebFetchSummarizer { pub client: Client, - pub model_id: ModelId, + pub model_id: ModelRef, } /// Returns true if the input looks like it contains HTML markup. @@ -579,9 +579,9 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg "Content from {url}:\n---\n{content}\n---\n\n{user_prompt}\n\nRespond concisely based only on the content above." ); let request = Request { - model: s.model_id.model.clone(), + model: s.model_id.model_id().to_string(), messages: vec![Message::user(summarization_prompt)], - provider: Some(s.model_id.provider.as_str().to_string()), + provider: Some(s.model_id.provider().as_str().to_string()), tools: None, tool_choice: None, response_format: None, @@ -595,7 +595,7 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg provider_options: None, }; let response = s.client.complete(&request).await.map_err(|e| { - format!("web_fetch summarization (model={}) failed: {e}", s.model_id.model) + format!("web_fetch summarization (model={}) failed: {e}", s.model_id.model_id()) })?; Ok(response.text()) } @@ -1252,7 +1252,10 @@ mod tests { let client = make_client(provider).await; let summarizer = WebFetchSummarizer { client, - model_id: ModelId::new(fabro_model::Provider::Anthropic, "mock-model"), + model_id: ModelRef::ByName { + provider: fabro_model::Provider::Anthropic, + model: "mock-model".to_string(), + }, }; let tool = make_web_fetch_tool(Some(summarizer)); @@ -1339,13 +1342,16 @@ mod tests { let mut providers = HashMap::new(); providers.insert("other_provider".to_string(), default_provider); - // Register under "anthropic" so ModelId { provider: Anthropic, .. } routes here + // Register under "anthropic" so ModelRef { provider: Anthropic, .. } routes here providers.insert("anthropic".to_string(), target_provider); let client = Client::new(providers, Some("other_provider".into()), vec![]); let summarizer = WebFetchSummarizer { client, - model_id: ModelId::new(fabro_model::Provider::Anthropic, "target-model"), + model_id: ModelRef::ByName { + provider: fabro_model::Provider::Anthropic, + model: "target-model".to_string(), + }, }; let tool = make_web_fetch_tool(Some(summarizer)); diff --git a/lib/crates/fabro-agent/tests/guardrails.rs b/lib/crates/fabro-agent/tests/guardrails.rs index e60f3e601..911be84ff 100644 --- a/lib/crates/fabro-agent/tests/guardrails.rs +++ b/lib/crates/fabro-agent/tests/guardrails.rs @@ -1,17 +1,22 @@ use fabro_agent::{AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile}; -use fabro_model as catalog; -use fabro_model::Provider; +use fabro_model::{Catalog, LanguageModel, Provider}; #[test] fn profile_context_window_matches_catalog_for_default_models() { for &provider in Provider::ALL { - let catalog_info = catalog::default_model_for_provider(provider.as_str()) + let catalog_info = Catalog::builtin() + .default_for_provider(provider) + .cloned() .unwrap_or_else(|| panic!("no default model for {:?} in catalog", provider)); let model = &catalog_info.id; let profile: Box = match provider { Provider::OpenAi => Box::new(OpenAiProfile::new(model)), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => { Box::new(OpenAiProfile::new(model).with_provider(provider)) } Provider::Gemini => Box::new(GeminiProfile::new(model)), @@ -20,12 +25,12 @@ fn profile_context_window_matches_catalog_for_default_models() { assert_eq!( profile.context_window_size(), - catalog_info.limits.context_window as usize, + catalog_info.context_window() as usize, "context_window_size mismatch for {:?} model '{}': profile={} catalog={}", provider, model, profile.context_window_size(), - catalog_info.limits.context_window as usize + catalog_info.context_window() as usize ); } } diff --git a/lib/crates/fabro-agent/tests/parity_matrix.rs b/lib/crates/fabro-agent/tests/parity_matrix.rs index 4fef20e15..44c2d1552 100644 --- a/lib/crates/fabro-agent/tests/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/parity_matrix.rs @@ -6,17 +6,28 @@ use fabro_agent::{ SessionConfig, SubAgentManager, WebFetchSummarizer, }; use fabro_llm::client::Client; -use fabro_llm::provider::{ModelId, Provider}; +use fabro_llm::provider::Provider; +use fabro_model::ModelRef; -fn summarizer_model_id(provider: Provider) -> ModelId { +fn summarizer_model_id(provider: Provider) -> ModelRef { match provider { Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax - | Provider::Inception => ModelId::new(Provider::OpenAi, "gpt-5.4-mini"), - Provider::Gemini => ModelId::new(Provider::Gemini, "gemini-3-flash-preview"), - Provider::Anthropic => ModelId::new(Provider::Anthropic, "claude-haiku-4-5"), + | Provider::Inception + | Provider::OpenAiCompatible => ModelRef::ByName { + provider: Provider::OpenAi, + model: "gpt-5.4-mini".to_string(), + }, + Provider::Gemini => ModelRef::ByName { + provider: Provider::Gemini, + model: "gemini-3-flash-preview".to_string(), + }, + Provider::Anthropic => ModelRef::ByName { + provider: Provider::Anthropic, + model: "claude-haiku-4-5".to_string(), + }, } } @@ -32,7 +43,11 @@ fn build_profile(provider: Provider, model: &str, client: &Client) -> Box Box::new(AnthropicProfile::with_summarizer(model, summarizer)), Provider::OpenAi => Box::new(OpenAiProfile::with_summarizer(model, summarizer)), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => { Box::new(OpenAiProfile::with_summarizer(model, summarizer).with_provider(provider)) } Provider::Gemini => Box::new(GeminiProfile::with_summarizer(model, summarizer)), @@ -61,12 +76,14 @@ async fn make_session(provider: Provider, model: &str, cwd: &Path) -> Session { Provider::OpenAi => { Arc::new(OpenAiProfile::with_summarizer(&factory_model, summarizer)) } - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { - Arc::new( - OpenAiProfile::with_summarizer(&factory_model, summarizer) - .with_provider(provider), - ) - } + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => Arc::new( + OpenAiProfile::with_summarizer(&factory_model, summarizer) + .with_provider(provider), + ), Provider::Gemini => { Arc::new(GeminiProfile::with_summarizer(&factory_model, summarizer)) } diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index f26536403..3b879b6da 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -576,7 +576,14 @@ pub async fn list_models( State(_state): State>, Query(pagination): Query, ) -> Response { - paginated_response(fabro_model::list_models(None), &pagination) + paginated_response( + fabro_model::Catalog::builtin() + .list(None) + .into_iter() + .cloned() + .collect::>(), + &pagination, + ) } // ── Settings ─────────────────────────────────────────────────────────── diff --git a/lib/crates/fabro-api/src/serve.rs b/lib/crates/fabro-api/src/serve.rs index 0758a84d8..f3cbb2357 100644 --- a/lib/crates/fabro-api/src/serve.rs +++ b/lib/crates/fabro-api/src/serve.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use std::sync::{Arc, RwLock}; use std::time::Duration; -use fabro_model::Provider; +use fabro_model::{Catalog, Provider}; use fabro_util::terminal::Styles; use tokio::net::TcpListener; use tracing::{error, info, warn}; @@ -291,16 +291,20 @@ fn resolve_model_provider( // Look up default model from catalog for the given provider, // falling back to the best provider with an API key configured. provider_str - .and_then(fabro_model::default_model_for_provider) - .unwrap_or_else(fabro_model::default_model_from_env) + .and_then(|s| s.parse::().ok()) + .and_then(|p| Catalog::builtin().default_for_provider(p)) + .unwrap_or_else(|| Catalog::builtin().default_from_env()) .id + .clone() }); // Resolve model alias through catalog - let (model, provider_str) = match fabro_model::get_model_info(&model) { + let (model, provider_str) = match Catalog::builtin().get(&model) { Some(info) => ( - info.id, - provider_str.map(|s| s.to_string()).or(Some(info.provider)), + info.id.clone(), + provider_str + .map(|s| s.to_string()) + .or(Some(info.provider.clone())), ), 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 3c3321dd2..0112721cc 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -1053,7 +1053,7 @@ async fn test_model( State(state): State>, Path(id): Path, ) -> Response { - let Some(info) = fabro_model::get_model_info(&id) else { + let Some(info) = fabro_model::Catalog::builtin().get(&id) else { return ApiError::not_found(format!("Model not found: {id}")).into_response(); }; @@ -1163,17 +1163,18 @@ async fn create_completion( ) -> Response { // Resolve model let model_id = req.model.unwrap_or_else(|| { - fabro_model::list_models(None) + fabro_model::Catalog::builtin() + .list(None) .first() .map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone()) }); - let catalog_info = fabro_model::get_model_info(&model_id); + let catalog_info = fabro_model::Catalog::builtin().get(&model_id); // Resolve provider: explicit request > catalog > None let provider_name = req .provider - .or_else(|| catalog_info.as_ref().map(|i| i.provider.clone())); + .or_else(|| catalog_info.map(|i| i.provider.clone())); 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 fcff3296a..786e308d8 100644 --- a/lib/crates/fabro-api/src/sessions.rs +++ b/lib/crates/fabro-api/src/sessions.rs @@ -61,12 +61,13 @@ fn generate_title(content: &str) -> String { fn resolve_model(model_arg: Option) -> (String, Option) { let raw = model_arg.unwrap_or_else(|| { - fabro_model::list_models(None) + fabro_model::Catalog::builtin() + .list(None) .first() .map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone()) }); - match fabro_model::get_model_info(&raw) { - Some(info) => (info.id, Some(info.provider)), + match fabro_model::Catalog::builtin().get(&raw) { + Some(info) => (info.id.clone(), Some(info.provider.clone())), None => (raw, None), } } diff --git a/lib/crates/fabro-cli/src/commands/pr.rs b/lib/crates/fabro-cli/src/commands/pr.rs index c66ff5e5f..b414f8b9a 100644 --- a/lib/crates/fabro-cli/src/commands/pr.rs +++ b/lib/crates/fabro-cli/src/commands/pr.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use clap::Args; +use fabro_model::Catalog; use tracing::info; #[derive(Args)] @@ -385,7 +386,7 @@ async fn create_from( let model = args .model - .unwrap_or_else(|| fabro_model::default_model_from_env().id); + .unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone()); let record = fabro_workflows::pull_request::maybe_open_pull_request( &creds, diff --git a/lib/crates/fabro-cli/src/commands/resume.rs b/lib/crates/fabro-cli/src/commands/resume.rs index 57c7a48db..7527799c6 100644 --- a/lib/crates/fabro-cli/src/commands/resume.rs +++ b/lib/crates/fabro-cli/src/commands/resume.rs @@ -9,7 +9,7 @@ use fabro_agent::{DockerSandbox, DockerSandboxConfig, Sandbox, WorktreeConfig, W use fabro_config::run::{RunDefaults, WorkflowRunConfig}; use fabro_graphviz::graph::Graph; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer}; -use fabro_model::Provider; +use fabro_model::{Catalog, Provider}; use fabro_util::terminal::Styles; use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter}; use fabro_workflows::checkpoint::Checkpoint; @@ -1102,7 +1102,7 @@ async fn run_resumed( resolve_fallback_chain(provider_enum, &model, run_cfg.as_ref()) } else { match run_defaults.llm.as_ref().and_then(|l| l.fallbacks.as_ref()) { - Some(map) => fabro_model::build_fallback_chain(provider_enum.as_str(), &model, map), + Some(map) => Catalog::builtin().build_fallback_chain(provider_enum, &model, map), None => Vec::new(), } }; diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index b5c171355..4e66e30c1 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -13,7 +13,7 @@ use fabro_agent::{ use fabro_config::run::{RunDefaults, WorkflowRunConfig}; use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, FileInterviewer, Interviewer}; -use fabro_model::Provider; +use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_util::terminal::Styles; use fabro_validate::Severity; use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter}; @@ -252,16 +252,18 @@ pub(crate) fn resolve_model_provider( .or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str())) .map(String::from) .unwrap_or_else(|| { - provider + let catalog = Catalog::builtin(); + let info = provider .as_deref() - .and_then(fabro_model::default_model_for_provider) - .unwrap_or_else(fabro_model::default_model_from_env) - .id + .and_then(|s| s.parse::().ok()) + .and_then(|p| catalog.default_for_provider(p)) + .unwrap_or_else(|| catalog.default_from_env()); + info.id.clone() }); // Resolve model alias through catalog - match fabro_model::get_model_info(&model) { - Some(info) => (info.id, provider.or(Some(info.provider))), + match Catalog::builtin().get(&model) { + Some(info) => (info.id.clone(), provider.or(Some(info.provider.clone()))), None => (model, provider), } } @@ -407,13 +409,13 @@ pub(crate) fn resolve_fallback_chain( provider: Provider, model: &str, run_cfg: Option<&WorkflowRunConfig>, -) -> Vec { +) -> Vec { let fallbacks = run_cfg .and_then(|c| c.llm.as_ref()) .and_then(|l| l.fallbacks.as_ref()); match fallbacks { - Some(map) => fabro_model::build_fallback_chain(provider.as_str(), model, map), + Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map), None => Vec::new(), } } @@ -2311,8 +2313,8 @@ async fn run_preflight( // Resolve through catalog to get canonical model ID and provider let (resolved_model, resolved_provider) = - if let Some(info) = fabro_model::get_model_info(node_model) { - (info.id, info.provider) + if let Some(info) = Catalog::builtin().get(node_model) { + (info.id.clone(), info.provider.clone()) } else { (node_model.to_string(), node_provider.to_string()) }; @@ -2330,8 +2332,8 @@ async fn run_preflight( // If no LLM nodes found, fall back to the default model/provider if model_providers.is_empty() { let (resolved_model, resolved_provider) = - if let Some(info) = fabro_model::get_model_info(&model) { - (info.id, info.provider) + if let Some(info) = Catalog::builtin().get(&model) { + (info.id.clone(), info.provider.clone()) } else { (model.clone(), default_provider.to_string()) }; diff --git a/lib/crates/fabro-cli/src/doctor.rs b/lib/crates/fabro-cli/src/doctor.rs index 955b6f0b9..45c002ab9 100644 --- a/lib/crates/fabro-cli/src/doctor.rs +++ b/lib/crates/fabro-cli/src/doctor.rs @@ -6,7 +6,7 @@ use std::sync::LazyLock; #[cfg(feature = "server")] use fabro_config::server::{ApiAuthStrategy, AuthProvider}; -use fabro_model::Provider; +use fabro_model::{Catalog, Provider}; pub use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -856,8 +856,9 @@ async fn probe_daytona() -> Option> { } pub(crate) fn probe_model(provider: Provider) -> String { - fabro_model::probe_model_for_provider(provider.as_str()) - .map(|m| m.id) + Catalog::builtin() + .probe_for_provider(provider) + .map(|m| m.id.clone()) .unwrap_or_else(|| format!("unknown-{}", provider.as_str())) } diff --git a/lib/crates/fabro-cli/src/provider_auth.rs b/lib/crates/fabro-cli/src/provider_auth.rs index 3f0fc7bd9..57f95d41d 100644 --- a/lib/crates/fabro-cli/src/provider_auth.rs +++ b/lib/crates/fabro-cli/src/provider_auth.rs @@ -22,6 +22,7 @@ pub(crate) fn provider_key_url(provider: Provider) -> &'static str { "https://platform.minimaxi.com/user-center/basic-information/interface-key" } Provider::Inception => "https://console.inceptionlabs.ai/api-keys", + Provider::OpenAiCompatible => "", } } @@ -34,6 +35,7 @@ pub(crate) fn provider_display_name(provider: Provider) -> &'static str { Provider::Zai => "Zai", Provider::Minimax => "Minimax", Provider::Inception => "Inception", + Provider::OpenAiCompatible => "OpenAI Compatible", } } diff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs index bfb903067..58e44a561 100644 --- a/lib/crates/fabro-hooks/src/executor.rs +++ b/lib/crates/fabro-hooks/src/executor.rs @@ -218,11 +218,8 @@ impl HookExecutorImpl { /// Resolve a model alias (e.g. "haiku") to a concrete model ID. fn resolve_model(model: &Option) -> String { let model_id = model.as_deref().unwrap_or("haiku"); - let model_info = fabro_model::get_model_info(model_id); - model_info - .as_ref() - .map_or(model_id, |m| m.id.as_str()) - .to_string() + let model_info = fabro_model::Catalog::builtin().get(model_id); + model_info.map_or(model_id, |m| m.id.as_str()).to_string() } /// Build the user message for prompt/agent hooks. diff --git a/lib/crates/fabro-llm/src/cli.rs b/lib/crates/fabro-llm/src/cli.rs index edb435f7f..89ce57d1a 100644 --- a/lib/crates/fabro-llm/src/cli.rs +++ b/lib/crates/fabro-llm/src/cli.rs @@ -13,12 +13,11 @@ use serde::Deserialize; use fabro_util::terminal::Styles; -use fabro_model as catalog; +use fabro_model::{Catalog, ModelInfo, Provider}; use crate::generate::{self, GenerateParams}; use crate::tools::Tool; use crate::types::{ContentPart, Message}; -use fabro_model::ModelInfo; pub struct ServerConnection { pub client: reqwest::Client, @@ -203,12 +202,13 @@ fn resolve_prompt(arg: Option, stdin: Option) -> Result /// Returns (`model_id`, provider) from the catalog, falling back to the first catalog model. fn resolve_model(model_arg: Option) -> (String, Option) { let raw = model_arg.unwrap_or_else(|| { - catalog::list_models(None) + Catalog::builtin() + .list(None) .first() .map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone()) }); - match catalog::get_model_info(&raw) { - Some(info) => (info.id, Some(info.provider)), + match Catalog::builtin().get(&raw) { + Some(info) => (info.id.clone(), Some(info.provider.clone())), None => (raw, None), } } @@ -1006,7 +1006,10 @@ pub async fn run_models( Some(s) => { fetch_models_from_server(&s.client, &s.base_url, provider.as_deref()).await? } - None => catalog::list_models(provider.as_deref()), + None => { + let p = provider.as_deref().and_then(|s| s.parse::().ok()); + Catalog::builtin().list(p).into_iter().cloned().collect() + } }; if let Some(q) = &query { @@ -1079,12 +1082,13 @@ async fn test_models( use rand::seq::SliceRandom; let models_to_test = if let Some(model_id) = model { - match catalog::get_model_info(model_id) { - Some(info) => vec![info], + match Catalog::builtin().get(model_id) { + Some(info) => vec![info.clone()], None => bail!("Unknown model: {model_id}"), } } else { - catalog::list_models(provider) + let p = provider.and_then(|s| s.parse::().ok()); + Catalog::builtin().list(p).into_iter().cloned().collect() }; if models_to_test.is_empty() { diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 00b819936..f415c6c48 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -143,8 +143,9 @@ impl Client { /// Resolve the provider for a request. fn resolve_provider(&self, request: &Request) -> Result, SdkError> { - let catalog_provider = - fabro_model::get_model_info(&request.model).map(|info| info.provider); + let catalog_provider = fabro_model::Catalog::builtin() + .get(&request.model) + .map(|info| info.provider.clone()); let provider_name = request .provider diff --git a/lib/crates/fabro-llm/src/lib.rs b/lib/crates/fabro-llm/src/lib.rs index ecc6dfbde..bf028fdce 100644 --- a/lib/crates/fabro-llm/src/lib.rs +++ b/lib/crates/fabro-llm/src/lib.rs @@ -10,5 +10,5 @@ pub mod tools; pub mod types; // Re-export module-level default client helpers (Section 2.5). -pub use fabro_model::{ModelId, Provider}; +pub use fabro_model::{ModelRef, Provider}; pub use generate::set_default_client; diff --git a/lib/crates/fabro-llm/src/provider.rs b/lib/crates/fabro-llm/src/provider.rs index c891f1468..174491ae4 100644 --- a/lib/crates/fabro-llm/src/provider.rs +++ b/lib/crates/fabro-llm/src/provider.rs @@ -3,7 +3,7 @@ use crate::types::{Request, Response, StreamEvent, ToolChoice}; use futures::Stream; use std::pin::Pin; -pub use fabro_model::{ModelId, Provider}; +pub use fabro_model::{ModelRef, Provider}; // --------------------------------------------------------------------------- // ProviderAdapter trait diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 15c4329d2..9a4b7a71a 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -1104,12 +1104,12 @@ fn build_api_request( // Check whether this model supports the `output_config.effort` parameter. // Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with // `budget_tokens` instead. - let model_info = fabro_model::get_model_info(&request.model); - let supports_effort = model_info.as_ref().is_none_or(|m| m.features.effort); + let model_info = fabro_model::Catalog::builtin().get(&request.model); + let supports_effort = model_info.is_none_or(|m| m.features.effort); let mut resolved_max_tokens = request .max_tokens - .or_else(|| model_info.as_ref().and_then(|m| m.limits.max_output)) + .or_else(|| model_info.and_then(|m| m.limits.max_output)) .unwrap_or(65536); let (thinking, output_config) = if let Some(effort) = &request.reasoning_effort { diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 525a0e6ad..fa1d30d5a 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -1,124 +1,16 @@ use std::collections::HashMap; use std::sync::LazyLock; +use crate::provider::Provider; use crate::types::ModelInfo; -/// Built-in model catalog loaded from catalog.json (Section 2.9). -/// The catalog is advisory, not restrictive -- unknown model strings pass through. -static BUILT_IN_MODELS: LazyLock> = LazyLock::new(|| { - serde_json::from_str(include_str!("catalog.json")).expect("embedded catalog.json must be valid") +/// 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")) + .expect("embedded catalog.json must be valid"); + Catalog { models } }); -/// Get model info by model ID (Section 2.9). -#[must_use] -pub fn get_model_info(model_id: &str) -> Option { - BUILT_IN_MODELS - .iter() - .find(|m| m.id == model_id || m.aliases.iter().any(|a| a == model_id)) - .cloned() -} - -/// Normalize provider aliases (e.g. `"open_ai"` → `"openai"`) through -/// `Provider::from_str`. Returns the input unchanged if it's not a known alias. -fn canonical_provider(provider: &str) -> &str { - provider - .parse::() - .map_or(provider, |p| p.as_str()) -} - -/// Get the default model for a provider, as marked in catalog.json. -/// -/// Returns `None` if the provider has no models or none marked as default. -#[must_use] -pub fn default_model_for_provider(provider: &str) -> Option { - let provider = canonical_provider(provider); - BUILT_IN_MODELS - .iter() - .find(|m| m.provider == provider && m.default) - .cloned() -} - -/// Default model for the best-available provider (based on configured API keys), -/// falling back to the global catalog default. -#[must_use] -pub fn default_model_from_env() -> ModelInfo { - let provider = crate::provider::Provider::default_from_env(); - default_model_for_provider(provider.as_str()).unwrap_or_else(default_model) -} - -/// Get the overall default model (the first model marked `default` in catalog.json). -#[must_use] -pub fn default_model() -> ModelInfo { - BUILT_IN_MODELS - .iter() - .find(|m| m.default) - .cloned() - .expect("catalog.json must contain at least one default model") -} - -/// Get the probe model for a provider — the cheapest model known to work for -/// connectivity checks. Falls back to the default model when no explicit -/// override is configured. -#[must_use] -pub fn probe_model_for_provider(provider: &str) -> Option { - let provider = canonical_provider(provider); - // Provider-specific overrides where the cheapest model is unsuitable - // (e.g. gpt-5-mini is rejected by the ChatGPT/Codex backend). - let override_id: Option<&str> = match provider { - "openai" => Some("gpt-5.4-mini"), - _ => None, - }; - if let Some(id) = override_id { - if let Some(info) = get_model_info(id) { - return Some(info); - } - } - default_model_for_provider(provider) -} - -/// List all known models, optionally filtered by provider (Section 2.9). -#[must_use] -pub fn list_models(provider: Option<&str>) -> Vec { - provider.map_or_else( - || BUILT_IN_MODELS.clone(), - |p| { - let p = canonical_provider(p); - BUILT_IN_MODELS - .iter() - .filter(|m| m.provider == p) - .cloned() - .collect() - }, - ) -} - -/// Find the closest model on a target provider that matches the reference model's capabilities. -/// -/// Hard-filters on `features.tools`, `features.vision`, and `features.reasoning`. -/// Among matches, picks the closest by `costs.input_cost_per_mtok` (absolute diff). -/// Returns `None` if no model on the target provider matches all capabilities. -#[must_use] -pub fn closest_model(target_provider: &str, reference: &ModelInfo) -> Option { - let target_provider = canonical_provider(target_provider); - BUILT_IN_MODELS - .iter() - .filter(|m| { - m.provider == target_provider - && m.features.tools == reference.features.tools - && m.features.vision == reference.features.vision - && m.features.reasoning == reference.features.reasoning - }) - .min_by(|a, b| { - let ref_cost = reference.costs.input_cost_per_mtok.unwrap_or(0.0); - let cost_a = (a.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); - let cost_b = (b.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); - cost_a - .partial_cmp(&cost_b) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .cloned() -} - /// A resolved fallback target: provider name + model ID. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FallbackTarget { @@ -126,51 +18,366 @@ pub struct FallbackTarget { pub model: String, } -/// Build an ordered fallback chain for a primary provider/model. +/// Typed model catalog backed by a `Vec`. /// -/// Looks up the primary model in the catalog, then for each fallback provider -/// in the configured order, finds the closest matching model. Providers where -/// no capability match exists are skipped. -/// -/// Returns an empty vec if the primary model is unknown or the provider is not -/// in the fallback map. -#[must_use] -pub fn build_fallback_chain( - primary_provider: &str, - primary_model: &str, - fallbacks: &HashMap>, -) -> Vec { - let reference = match get_model_info(primary_model) { - Some(info) => info, - None => return Vec::new(), - }; +/// Use [`Catalog::builtin()`] for the embedded catalog, or [`Catalog::from_models()`] +/// for testing with custom model sets. +pub struct Catalog { + models: Vec, +} - let fallback_providers = match fallbacks.get(primary_provider) { - Some(providers) => providers, - None => return Vec::new(), - }; +impl Catalog { + /// Returns a reference to the global built-in catalog (loaded once from catalog.json). + #[must_use] + pub fn builtin() -> &'static Catalog { + &GLOBAL_CATALOG + } - fallback_providers - .iter() - .filter_map(|provider| { - closest_model(provider, &reference).map(|m| FallbackTarget { - provider: provider.clone(), - model: m.id, + /// Create a catalog from a custom set of models (useful for testing). + #[must_use] + 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> { + self.models + .iter() + .find(|m| m.id == id || m.aliases.iter().any(|a| a == id)) + } + + /// List all models, optionally filtered by provider. + #[must_use] + pub fn list(&self, provider: Option) -> Vec<&ModelInfo> { + match provider { + None => self.models.iter().collect(), + Some(p) => { + let ps = p.as_str(); + self.models.iter().filter(|m| m.provider == ps).collect() + } + } + } + + /// The overall default model (first model marked `default` in catalog). + /// + /// # Panics + /// Panics if the catalog contains no default model. + #[must_use] + pub fn default_model(&self) -> &ModelInfo { + self.models + .iter() + .find(|m| m.default) + .expect("catalog must contain at least one default model") + } + + /// 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) + } + + /// 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 { + let provider = Provider::default_from_env(); + self.default_for_provider(provider) + .unwrap_or_else(|| self.default_model()) + } + + /// 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> { + let override_id: Option<&str> = match p { + Provider::OpenAi => Some("gpt-5.4-mini"), + _ => None, + }; + if let Some(id) = override_id { + if let Some(info) = self.get(id) { + return Some(info); + } + } + self.default_for_provider(p) + } + + /// Find the closest model on a target provider matching the reference's capabilities. + /// + /// 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(); + self.models + .iter() + .filter(|m| { + m.provider == ps + && m.features.tools == reference.features.tools + && m.features.vision == reference.features.vision + && m.features.reasoning == reference.features.reasoning }) - }) - .collect() + .min_by(|a, b| { + let ref_cost = reference.costs.input_cost_per_mtok.unwrap_or(0.0); + let cost_a = (a.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); + let cost_b = (b.costs.input_cost_per_mtok.unwrap_or(0.0) - ref_cost).abs(); + cost_a + .partial_cmp(&cost_b) + .unwrap_or(std::cmp::Ordering::Equal) + }) + } + + /// Build an ordered fallback chain for a primary provider/model. + /// + /// For each fallback provider, finds the closest matching model. Providers where + /// no capability match exists (or the provider string doesn't parse) are skipped. + #[must_use] + pub fn build_fallback_chain( + &self, + primary: Provider, + model: &str, + fallbacks: &HashMap>, + ) -> Vec { + let reference = match self.get(model) { + Some(info) => info, + None => return Vec::new(), + }; + + let fallback_providers = match fallbacks.get(primary.as_str()) { + Some(providers) => providers, + None => return Vec::new(), + }; + + fallback_providers + .iter() + .filter_map(|provider_str| { + let provider = provider_str.parse::().ok()?; + self.closest(provider, reference).map(|m| FallbackTarget { + provider: provider_str.clone(), + model: m.id.clone(), + }) + }) + .collect() + } } #[cfg(test)] mod tests { use super::*; - use crate::provider::Provider; use std::str::FromStr; + // ---- Catalog struct tests ---- + + #[test] + fn builtin_get_by_id() { + let m = Catalog::builtin().get("claude-opus-4-6").unwrap(); + assert_eq!(m.id, "claude-opus-4-6"); + } + + #[test] + fn builtin_get_by_alias() { + let m = Catalog::builtin().get("opus").unwrap(); + assert_eq!(m.id, "claude-opus-4-6"); + } + + #[test] + fn builtin_get_unknown() { + assert!(Catalog::builtin().get("nonexistent").is_none()); + } + + #[test] + fn builtin_list_all() { + let all = Catalog::builtin().list(None); + assert!(!all.is_empty()); + } + + #[test] + 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")); + } + + #[test] + fn builtin_list_unknown_provider_empty() { + // OpenAiCompatible has no catalog models + let models = Catalog::builtin().list(Some(Provider::OpenAiCompatible)); + assert!(models.is_empty()); + } + + #[test] + fn builtin_default_model() { + let m = Catalog::builtin().default_model(); + assert!(m.default); + } + + #[test] + fn builtin_default_for_provider() { + let m = Catalog::builtin() + .default_for_provider(Provider::Anthropic) + .unwrap(); + assert_eq!(m.id, "claude-sonnet-4-6"); + assert!(m.default); + + let m = Catalog::builtin() + .default_for_provider(Provider::OpenAi) + .unwrap(); + assert_eq!(m.id, "gpt-5.4"); + + let m = Catalog::builtin() + .default_for_provider(Provider::Gemini) + .unwrap(); + assert_eq!(m.id, "gemini-3.1-pro-preview"); + } + + #[test] + fn builtin_probe_openai_returns_override() { + let m = Catalog::builtin() + .probe_for_provider(Provider::OpenAi) + .unwrap(); + assert_eq!(m.id, "gpt-5.4-mini"); + } + + #[test] + fn builtin_probe_anthropic_returns_default() { + let m = Catalog::builtin() + .probe_for_provider(Provider::Anthropic) + .unwrap(); + assert_eq!(m.id, "claude-sonnet-4-6"); + } + + #[test] + fn builtin_probe_gemini_returns_default() { + let m = Catalog::builtin() + .probe_for_provider(Provider::Gemini) + .unwrap(); + assert_eq!(m.id, "gemini-3.1-pro-preview"); + } + + #[test] + fn builtin_closest_opus_to_gemini() { + let opus = Catalog::builtin().get("claude-opus-4-6").unwrap(); + let result = Catalog::builtin().closest(Provider::Gemini, opus).unwrap(); + assert_eq!(result.id, "gemini-3.1-pro-preview"); + } + + #[test] + fn builtin_closest_no_match() { + let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); + assert!(Catalog::builtin() + .closest(Provider::OpenAi, haiku) + .is_none()); + } + + #[test] + fn builtin_build_fallback_chain() { + let fallbacks = HashMap::from([( + "anthropic".to_string(), + vec!["gemini".to_string(), "openai".to_string()], + )]); + let chain = Catalog::builtin().build_fallback_chain( + Provider::Anthropic, + "claude-opus-4-6", + &fallbacks, + ); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0].provider, "gemini"); + assert_eq!(chain[0].model, "gemini-3.1-pro-preview"); + assert_eq!(chain[1].provider, "openai"); + assert_eq!(chain[1].model, "gpt-5.4"); + } + + #[test] + fn builtin_build_fallback_chain_unknown_model() { + let fallbacks = HashMap::from([("anthropic".to_string(), vec!["gemini".to_string()])]); + let chain = + Catalog::builtin().build_fallback_chain(Provider::Anthropic, "unknown-xyz", &fallbacks); + assert!(chain.is_empty()); + } + + #[test] + fn builtin_build_fallback_chain_provider_not_in_map() { + let fallbacks = HashMap::from([("openai".to_string(), vec!["anthropic".to_string()])]); + let chain = Catalog::builtin().build_fallback_chain( + Provider::Anthropic, + "claude-opus-4-6", + &fallbacks, + ); + assert!(chain.is_empty()); + } + + #[test] + fn builtin_build_fallback_chain_skips_no_capability_match() { + let fallbacks = HashMap::from([( + "anthropic".to_string(), + vec!["openai".to_string(), "kimi".to_string()], + )]); + let chain = Catalog::builtin().build_fallback_chain( + Provider::Anthropic, + "claude-haiku-4-5", + &fallbacks, + ); + assert_eq!(chain.len(), 1); + assert_eq!(chain[0].provider, "kimi"); + assert_eq!(chain[0].model, "kimi-k2.5"); + } + + #[test] + fn builtin_build_fallback_chain_empty_map() { + let fallbacks = HashMap::new(); + let chain = Catalog::builtin().build_fallback_chain( + Provider::Anthropic, + "claude-opus-4-6", + &fallbacks, + ); + assert!(chain.is_empty()); + } + + #[test] + fn from_models_custom_catalog() { + use crate::types::{ModelCosts, ModelFeatures, ModelLimits}; + + let models = vec![ModelInfo { + id: "test-model".to_string(), + provider: "anthropic".to_string(), + family: "test".to_string(), + display_name: "Test Model".to_string(), + limits: ModelLimits { + context_window: 100_000, + max_output: Some(4096), + }, + training: None, + features: ModelFeatures { + tools: true, + vision: false, + reasoning: false, + effort: false, + }, + costs: ModelCosts { + input_cost_per_mtok: Some(1.0), + output_cost_per_mtok: Some(5.0), + cache_input_cost_per_mtok: None, + }, + estimated_output_tps: None, + aliases: vec!["test".to_string()], + default: true, + }]; + + let catalog = Catalog::from_models(models); + assert_eq!(catalog.get("test-model").unwrap().id, "test-model"); + assert_eq!(catalog.get("test").unwrap().id, "test-model"); + assert!(catalog.get("nonexistent").is_none()); + assert_eq!(catalog.default_model().id, "test-model"); + assert_eq!(catalog.list(None).len(), 1); + } + + // ---- Provider / catalog data integrity tests ---- + #[test] fn every_provider_has_catalog_models() { for &provider in Provider::ALL { - let models = list_models(Some(provider.as_str())); + let models = Catalog::builtin().list(Some(provider)); assert!( !models.is_empty(), "Provider {:?} has no models in catalog", @@ -182,7 +389,8 @@ mod tests { #[test] fn every_provider_has_exactly_one_default_model() { for &provider in Provider::ALL { - let defaults: Vec<_> = list_models(Some(provider.as_str())) + let defaults: Vec<_> = Catalog::builtin() + .list(Some(provider)) .into_iter() .filter(|m| m.default) .collect(); @@ -197,34 +405,9 @@ mod tests { } } - #[test] - fn default_model_returns_first_catalog_default() { - let m = default_model(); - assert!(m.default); - } - - #[test] - fn default_model_for_provider_returns_correct_model() { - let m = default_model_for_provider("anthropic").unwrap(); - assert_eq!(m.id, "claude-sonnet-4-6"); - assert!(m.default); - - let m = default_model_for_provider("openai").unwrap(); - assert_eq!(m.id, "gpt-5.4"); - - let m = default_model_for_provider("gemini").unwrap(); - assert_eq!(m.id, "gemini-3.1-pro-preview"); - - // Provider aliases are normalized (e.g. "open_ai" → "openai") - let m = default_model_for_provider("open_ai").unwrap(); - assert_eq!(m.id, "gpt-5.4"); - - assert!(default_model_for_provider("nonexistent").is_none()); - } - #[test] fn catalog_provider_strings_roundtrip_through_provider() { - for model in list_models(None) { + for model in Catalog::builtin().list(None) { let parsed = Provider::from_str(&model.provider); assert!( parsed.is_ok(), @@ -248,9 +431,11 @@ mod tests { } } + // ---- Model info snapshot tests ---- + #[test] fn get_model_info_by_id() { - let info = get_model_info("claude-opus-4-6").unwrap(); + let info = Catalog::builtin().get("claude-opus-4-6").unwrap(); insta::assert_debug_snapshot!(info, @r#" ModelInfo { id: "claude-opus-4-6", @@ -297,40 +482,27 @@ mod tests { #[test] fn get_model_info_by_alias() { - let info = get_model_info("opus").unwrap(); - assert_eq!(info.id, "claude-opus-4-6"); - - let info = get_model_info("sonnet").unwrap(); - assert_eq!(info.id, "claude-sonnet-4-6"); - - let info = get_model_info("codex").unwrap(); - assert_eq!(info.id, "gpt-5.3-codex"); + assert_eq!( + Catalog::builtin().get("opus").unwrap().id, + "claude-opus-4-6" + ); + assert_eq!( + Catalog::builtin().get("sonnet").unwrap().id, + "claude-sonnet-4-6" + ); + assert_eq!(Catalog::builtin().get("codex").unwrap().id, "gpt-5.3-codex"); } #[test] fn get_model_info_returns_none_for_unknown() { - assert!(get_model_info("nonexistent-model").is_none()); - } - - #[test] - fn list_models_by_provider() { - let anthropic = list_models(Some("anthropic")); - assert!(!anthropic.is_empty()); - assert!(anthropic.iter().all(|m| m.provider == "anthropic")); - - let openai = list_models(Some("openai")); - assert!(!openai.is_empty()); - - let gemini = list_models(Some("gemini")); - assert!(!gemini.is_empty()); - - let unknown = list_models(Some("unknown")); - assert!(unknown.is_empty()); + assert!(Catalog::builtin().get("nonexistent-model").is_none()); } #[test] fn gemini_3_1_flash_lite_in_catalog() { - let m = get_model_info("gemini-3.1-flash-lite-preview").unwrap(); + let m = Catalog::builtin() + .get("gemini-3.1-flash-lite-preview") + .unwrap(); insta::assert_debug_snapshot!(m, @r#" ModelInfo { id: "gemini-3.1-flash-lite-preview", @@ -377,14 +549,14 @@ mod tests { #[test] fn gemini_flash_lite_alias() { assert_eq!( - get_model_info("gemini-flash-lite").unwrap().id, + Catalog::builtin().get("gemini-flash-lite").unwrap().id, "gemini-3.1-flash-lite-preview" ); } #[test] fn kimi_k2_5_in_catalog() { - let m = get_model_info("kimi-k2.5").unwrap(); + let m = Catalog::builtin().get("kimi-k2.5").unwrap(); insta::assert_debug_snapshot!(m, @r#" ModelInfo { id: "kimi-k2.5", @@ -428,24 +600,24 @@ mod tests { #[test] fn kimi_alias() { - assert_eq!(get_model_info("kimi").unwrap().id, "kimi-k2.5"); + assert_eq!(Catalog::builtin().get("kimi").unwrap().id, "kimi-k2.5"); } #[test] fn glm_4_7_in_catalog() { - let m = get_model_info("glm-4.7").unwrap(); + let m = Catalog::builtin().get("glm-4.7").unwrap(); assert_eq!(m.provider, "zai"); } #[test] fn minimax_m2_5_in_catalog() { - let m = get_model_info("minimax-m2.5").unwrap(); + let m = Catalog::builtin().get("minimax-m2.5").unwrap(); assert_eq!(m.provider, "minimax"); } #[test] fn mercury_2_in_catalog() { - let m = get_model_info("mercury-2").unwrap(); + let m = Catalog::builtin().get("mercury-2").unwrap(); insta::assert_debug_snapshot!(m, @r#" ModelInfo { id: "mercury-2", @@ -487,12 +659,12 @@ mod tests { #[test] fn mercury_alias_resolves_to_mercury_2() { - assert_eq!(get_model_info("mercury").unwrap().id, "mercury-2"); + assert_eq!(Catalog::builtin().get("mercury").unwrap().id, "mercury-2"); } #[test] fn gpt_5_4_in_catalog() { - let m = get_model_info("gpt-5.4").unwrap(); + let m = Catalog::builtin().get("gpt-5.4").unwrap(); insta::assert_debug_snapshot!(m, @r#" ModelInfo { id: "gpt-5.4", @@ -539,7 +711,7 @@ mod tests { #[test] fn gpt_5_4_pro_in_catalog() { - let m = get_model_info("gpt-5.4-pro").unwrap(); + let m = Catalog::builtin().get("gpt-5.4-pro").unwrap(); insta::assert_debug_snapshot!(m, @r#" ModelInfo { id: "gpt-5.4-pro", @@ -586,27 +758,33 @@ mod tests { #[test] fn gpt54_alias() { - assert_eq!(get_model_info("gpt54").unwrap().id, "gpt-5.4"); + assert_eq!(Catalog::builtin().get("gpt54").unwrap().id, "gpt-5.4"); } #[test] fn gpt_54_hyphenated_alias() { - assert_eq!(get_model_info("gpt-54").unwrap().id, "gpt-5.4"); + assert_eq!(Catalog::builtin().get("gpt-54").unwrap().id, "gpt-5.4"); } #[test] fn gpt_54_pro_hyphenated_alias() { - assert_eq!(get_model_info("gpt-54-pro").unwrap().id, "gpt-5.4-pro"); + assert_eq!( + Catalog::builtin().get("gpt-54-pro").unwrap().id, + "gpt-5.4-pro" + ); } #[test] fn gpt_54_mini_hyphenated_alias() { - assert_eq!(get_model_info("gpt-54-mini").unwrap().id, "gpt-5.4-mini"); + assert_eq!( + Catalog::builtin().get("gpt-54-mini").unwrap().id, + "gpt-5.4-mini" + ); } #[test] fn gpt_5_3_codex_spark_in_catalog() { - let m = get_model_info("gpt-5.3-codex-spark").unwrap(); + let m = Catalog::builtin().get("gpt-5.3-codex-spark").unwrap(); insta::assert_debug_snapshot!(m, @r#" ModelInfo { id: "gpt-5.3-codex-spark", @@ -647,129 +825,44 @@ mod tests { #[test] fn codex_spark_alias() { assert_eq!( - get_model_info("codex-spark").unwrap().id, + Catalog::builtin().get("codex-spark").unwrap().id, "gpt-5.3-codex-spark" ); } - #[test] - fn probe_model_openai_returns_override() { - let m = probe_model_for_provider("openai").unwrap(); - assert_eq!(m.id, "gpt-5.4-mini"); - } - - #[test] - fn probe_model_anthropic_returns_default() { - let m = probe_model_for_provider("anthropic").unwrap(); - assert_eq!(m.id, "claude-sonnet-4-6"); - } - - #[test] - fn probe_model_gemini_returns_default() { - let m = probe_model_for_provider("gemini").unwrap(); - assert_eq!(m.id, "gemini-3.1-pro-preview"); - } - - #[test] - fn closest_model_opus_to_gemini() { - let opus = get_model_info("claude-opus-4-6").unwrap(); - let result = closest_model("gemini", &opus).unwrap(); - // Opus ($15) → closest reasoning+vision+tools gemini model by cost - assert_eq!(result.id, "gemini-3.1-pro-preview"); - } + // ---- Closest model tests ---- #[test] fn closest_model_sonnet_to_gemini() { - let sonnet = get_model_info("claude-sonnet-4-5").unwrap(); - let result = closest_model("gemini", &sonnet).unwrap(); - // Sonnet ($3) → gemini-3.1-pro ($2) is closer than gemini-3-flash ($0.50) + let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); + let result = Catalog::builtin() + .closest(Provider::Gemini, sonnet) + .unwrap(); assert_eq!(result.id, "gemini-3.1-pro-preview"); } - #[test] - fn closest_model_haiku_to_openai_none() { - let haiku = get_model_info("claude-haiku-4-5").unwrap(); - // Haiku has reasoning=false; all openai models have reasoning=true - assert!(closest_model("openai", &haiku).is_none()); - } - #[test] fn closest_model_haiku_to_kimi() { - let haiku = get_model_info("claude-haiku-4-5").unwrap(); - let result = closest_model("kimi", &haiku).unwrap(); - // kimi-k2.5: no reasoning, vision, tools — matches haiku's caps + let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); + let result = Catalog::builtin().closest(Provider::Kimi, haiku).unwrap(); assert_eq!(result.id, "kimi-k2.5"); } - #[test] - fn closest_model_unknown_provider() { - let opus = get_model_info("claude-opus-4-6").unwrap(); - assert!(closest_model("nonexistent", &opus).is_none()); - } - #[test] fn closest_model_no_capability_match() { - // glm-4.7 has features: tools=true, vision=false, reasoning=false - // No gemini model matches vision=false (all gemini models have vision=true) - let glm = get_model_info("glm-4.7").unwrap(); - assert!(closest_model("gemini", &glm).is_none()); + let glm = Catalog::builtin().get("glm-4.7").unwrap(); + assert!(Catalog::builtin().closest(Provider::Gemini, glm).is_none()); } - #[test] - fn build_fallback_chain_opus_anthropic() { - let fallbacks = HashMap::from([( - "anthropic".to_string(), - vec!["gemini".to_string(), "openai".to_string()], - )]); - let chain = build_fallback_chain("anthropic", "claude-opus-4-6", &fallbacks); - assert_eq!(chain.len(), 2); - assert_eq!(chain[0].provider, "gemini"); - assert_eq!(chain[0].model, "gemini-3.1-pro-preview"); - assert_eq!(chain[1].provider, "openai"); - assert_eq!(chain[1].model, "gpt-5.4"); - } - - #[test] - fn build_fallback_chain_provider_not_in_map() { - let fallbacks = HashMap::from([("openai".to_string(), vec!["anthropic".to_string()])]); - let chain = build_fallback_chain("anthropic", "claude-opus-4-6", &fallbacks); - assert!(chain.is_empty()); - } - - #[test] - fn build_fallback_chain_skips_no_capability_match() { - // Haiku (no reasoning) → openai should be skipped (all have reasoning) - let fallbacks = HashMap::from([( - "anthropic".to_string(), - vec!["openai".to_string(), "kimi".to_string()], - )]); - let chain = build_fallback_chain("anthropic", "claude-haiku-4-5", &fallbacks); - assert_eq!(chain.len(), 1); - assert_eq!(chain[0].provider, "kimi"); - assert_eq!(chain[0].model, "kimi-k2.5"); - } - - #[test] - fn build_fallback_chain_empty_map() { - let fallbacks = HashMap::new(); - let chain = build_fallback_chain("anthropic", "claude-opus-4-6", &fallbacks); - assert!(chain.is_empty()); - } - - #[test] - fn build_fallback_chain_unknown_primary_model() { - let fallbacks = HashMap::from([("anthropic".to_string(), vec!["gemini".to_string()])]); - let chain = build_fallback_chain("anthropic", "unknown-model-xyz", &fallbacks); - assert!(chain.is_empty()); - } + // ---- Cost tests ---- #[test] fn model_info_costs() { - let claude = get_model_info("claude-opus-4-6").unwrap(); + let claude = Catalog::builtin().get("claude-opus-4-6").unwrap(); assert_eq!(claude.costs.input_cost_per_mtok, Some(15.0)); assert_eq!(claude.costs.output_cost_per_mtok, Some(75.0)); - let sonnet = get_model_info("claude-sonnet-4-5").unwrap(); + let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); assert_eq!(sonnet.costs.input_cost_per_mtok, Some(3.0)); } } diff --git a/lib/crates/fabro-model/src/language_model.rs b/lib/crates/fabro-model/src/language_model.rs new file mode 100644 index 000000000..a09d3b8d4 --- /dev/null +++ b/lib/crates/fabro-model/src/language_model.rs @@ -0,0 +1,151 @@ +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 8913772e3..40d0c5de7 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -1,10 +1,11 @@ pub mod catalog; +pub mod language_model; +pub mod model_ref; pub mod provider; pub mod types; -pub use catalog::{ - build_fallback_chain, closest_model, default_model, default_model_for_provider, - default_model_from_env, get_model_info, list_models, probe_model_for_provider, FallbackTarget, -}; -pub use provider::{ModelId, Provider}; +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}; diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/crates/fabro-model/src/model_ref.rs new file mode 100644 index 000000000..f0b37f27e --- /dev/null +++ b/lib/crates/fabro-model/src/model_ref.rs @@ -0,0 +1,110 @@ +use std::fmt; +use std::sync::Arc; + +use crate::language_model::LanguageModel; +use crate::provider::Provider; + +/// A reference to a model — either a fully resolved `LanguageModel` 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), + /// An unresolved provider:model pair (e.g. from CLI input or config). + ByName { provider: Provider, model: String }, +} + +impl ModelRef { + /// The model identifier string (e.g. `"claude-opus-4-6"`). + #[must_use] + pub fn model_id(&self) -> &str { + match self { + Self::Resolved(m) => m.id(), + Self::ByName { model, .. } => model, + } + } + + /// The provider for this model. + #[must_use] + pub fn provider(&self) -> Provider { + match self { + Self::Resolved(m) => m.provider(), + Self::ByName { provider, .. } => *provider, + } + } +} + +impl fmt::Display for ModelRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.provider(), self.model_id()) + } +} + +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::ByName { provider, model } => f + .debug_struct("ModelRef::ByName") + .field("provider", provider) + .field("model", model) + .finish(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn by_name_display() { + let r = ModelRef::ByName { + provider: Provider::Anthropic, + model: "claude-opus-4-6".to_string(), + }; + assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); + } + + #[test] + fn by_name_accessors() { + let r = ModelRef::ByName { + provider: Provider::OpenAi, + model: "gpt-5.4".to_string(), + }; + assert_eq!(r.model_id(), "gpt-5.4"); + assert_eq!(r.provider(), Provider::OpenAi); + } + + #[test] + fn resolved_display() { + let info = crate::catalog::Catalog::builtin() + .get("claude-opus-4-6") + .unwrap() + .clone(); + let r = ModelRef::Resolved(Arc::new(info)); + assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); + } + + #[test] + fn resolved_accessors() { + let info = crate::catalog::Catalog::builtin() + .get("gpt-5.4") + .unwrap() + .clone(); + let r = ModelRef::Resolved(Arc::new(info)); + assert_eq!(r.model_id(), "gpt-5.4"); + assert_eq!(r.provider(), Provider::OpenAi); + } + + #[test] + fn debug_format() { + let r = ModelRef::ByName { + provider: Provider::Gemini, + model: "gemini-3.1-pro-preview".to_string(), + }; + let debug = format!("{r:?}"); + assert!(debug.contains("ByName")); + assert!(debug.contains("Gemini")); + } +} diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/crates/fabro-model/src/provider.rs index e7f330933..5803b9cb7 100644 --- a/lib/crates/fabro-model/src/provider.rs +++ b/lib/crates/fabro-model/src/provider.rs @@ -17,6 +17,7 @@ pub enum Provider { Zai, Minimax, Inception, + OpenAiCompatible, } impl Provider { @@ -43,6 +44,7 @@ impl Provider { Self::Zai => &["ZAI_API_KEY"], Self::Minimax => &["MINIMAX_API_KEY"], Self::Inception => &["INCEPTION_API_KEY"], + Self::OpenAiCompatible => &[], } } @@ -86,6 +88,7 @@ impl Provider { Self::Zai => "zai", Self::Minimax => "minimax", Self::Inception => "inception", + Self::OpenAiCompatible => "openai_compatible", } } } @@ -108,39 +111,12 @@ impl FromStr for Provider { "zai" => Ok(Self::Zai), "minimax" => Ok(Self::Minimax), "inception" | "inception_labs" => Ok(Self::Inception), + "openai_compatible" => Ok(Self::OpenAiCompatible), other => Err(format!("unknown provider: {other}")), } } } -// --------------------------------------------------------------------------- -// ModelId — bundles a provider with a model name -// --------------------------------------------------------------------------- - -/// A model identifier that pairs a [`Provider`] with the provider-specific -/// model name (e.g. `"claude-opus-4-6"` or `"gpt-4o-mini"`). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ModelId { - pub provider: Provider, - pub model: String, -} - -impl ModelId { - #[must_use] - pub fn new(provider: Provider, model: impl Into) -> Self { - Self { - provider, - model: model.into(), - } - } -} - -impl fmt::Display for ModelId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{}", self.provider, self.model) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/lib/crates/fabro-retro/src/retro_agent.rs b/lib/crates/fabro-retro/src/retro_agent.rs index 89ee3168f..b6252fb32 100644 --- a/lib/crates/fabro-retro/src/retro_agent.rs +++ b/lib/crates/fabro-retro/src/retro_agent.rs @@ -326,9 +326,11 @@ fn spawn_retro_event_forwarder( fn build_profile(provider: Provider, model: &str) -> Box { match provider { Provider::OpenAi => Box::new(OpenAiProfile::new(model)), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { - Box::new(OpenAiProfile::new(model).with_provider(provider)) - } + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => Box::new(OpenAiProfile::new(model).with_provider(provider)), Provider::Gemini => Box::new(GeminiProfile::new(model)), Provider::Anthropic => Box::new(AnthropicProfile::new(model)), } diff --git a/lib/crates/fabro-validate/src/rules.rs b/lib/crates/fabro-validate/src/rules.rs index d2c9057e7..a95dffd7d 100644 --- a/lib/crates/fabro-validate/src/rules.rs +++ b/lib/crates/fabro-validate/src/rules.rs @@ -907,7 +907,7 @@ fn check_model_known( context: &str, node_id: Option, ) -> Option { - if fabro_model::get_model_info(model).is_some() { + if fabro_model::Catalog::builtin().get(model).is_some() { return None; } Some(Diagnostic { diff --git a/lib/crates/fabro-workflows/src/backend/api.rs b/lib/crates/fabro-workflows/src/backend/api.rs index d54ccb73a..732132e9b 100644 --- a/lib/crates/fabro-workflows/src/backend/api.rs +++ b/lib/crates/fabro-workflows/src/backend/api.rs @@ -23,9 +23,11 @@ use fabro_graphviz::graph::Node; fn build_profile(model: &str, provider: Provider) -> Box { match provider { Provider::OpenAi => Box::new(OpenAiProfile::new(model)), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { - Box::new(OpenAiProfile::new(model).with_provider(provider)) - } + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => Box::new(OpenAiProfile::new(model).with_provider(provider)), Provider::Gemini => Box::new(GeminiProfile::new(model)), Provider::Anthropic => Box::new(AnthropicProfile::new(model)), } @@ -214,7 +216,11 @@ impl AgentApiBackend { let factory: SessionFactory = Arc::new(move || { let child_profile: Arc = match provider { Provider::OpenAi => Arc::new(OpenAiProfile::new(&factory_model)), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { + Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => { Arc::new(OpenAiProfile::new(&factory_model).with_provider(provider)) } Provider::Gemini => Arc::new(GeminiProfile::new(&factory_model)), @@ -269,9 +275,11 @@ impl CodergenBackend for AgentApiBackend { .map(String::from) .or_else(|| Some(self.provider.as_str().to_string())); - let max_tokens = node - .max_tokens() - .or_else(|| fabro_model::get_model_info(model).and_then(|m| m.limits.max_output)); + let max_tokens = node.max_tokens().or_else(|| { + fabro_model::Catalog::builtin() + .get(model) + .and_then(|m| m.limits.max_output) + }); let mut messages = Vec::new(); if let Some(sys) = system_prompt { @@ -345,7 +353,9 @@ impl CodergenBackend for AgentApiBackend { ); let max_tokens = node.max_tokens().or_else(|| { - fabro_model::get_model_info(&target.model).and_then(|m| m.limits.max_output) + fabro_model::Catalog::builtin() + .get(&target.model) + .and_then(|m| m.limits.max_output) }); let fallback_request = fabro_llm::types::Request { diff --git a/lib/crates/fabro-workflows/src/backend/cli.rs b/lib/crates/fabro-workflows/src/backend/cli.rs index 4e66884c7..aab7bcb22 100644 --- a/lib/crates/fabro-workflows/src/backend/cli.rs +++ b/lib/crates/fabro-workflows/src/backend/cli.rs @@ -32,7 +32,8 @@ impl AgentCli { | Provider::Kimi | Provider::Zai | Provider::Minimax - | Provider::Inception => Self::Codex, + | Provider::Inception + | Provider::OpenAiCompatible => Self::Codex, } } @@ -174,7 +175,8 @@ pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &s | Provider::Kimi | Provider::Zai | Provider::Minimax - | Provider::Inception => { + | Provider::Inception + | Provider::OpenAiCompatible => { format!(" -m {model}") } Provider::Anthropic => format!(" --model {model}"), @@ -185,7 +187,12 @@ pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &s // redirects in nested shells. A pipe creates an explicit new stdin. match provider { // --full-auto: sandboxed auto-execution, escalates on request - Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => { + Provider::OpenAi + | Provider::Kimi + | Provider::Zai + | Provider::Minimax + | Provider::Inception + | Provider::OpenAiCompatible => { format!("cat {prompt_file} | codex exec --json --full-auto{model_flag}") } // --yolo: auto-approve all tool calls @@ -349,7 +356,8 @@ pub fn parse_cli_response(provider: Provider, output: &str) -> Option parse_codex_ndjson(output), + | Provider::Inception + | Provider::OpenAiCompatible => parse_codex_ndjson(output), Provider::Gemini => parse_gemini_json(output), Provider::Anthropic => parse_claude_ndjson(output), } diff --git a/lib/crates/fabro-workflows/src/cost.rs b/lib/crates/fabro-workflows/src/cost.rs index 0020e19a6..51c2b6a57 100644 --- a/lib/crates/fabro-workflows/src/cost.rs +++ b/lib/crates/fabro-workflows/src/cost.rs @@ -3,7 +3,7 @@ use crate::outcome::StageUsage; /// Compute the dollar cost for a stage's token usage, if pricing is available. #[must_use] pub fn compute_stage_cost(usage: &StageUsage) -> Option { - let info = fabro_model::get_model_info(&usage.model)?; + let info = fabro_model::Catalog::builtin().get(&usage.model)?; let input_rate = info.costs.input_cost_per_mtok?; let output_rate = info.costs.output_cost_per_mtok?; let multiplier = if usage.speed.as_deref() == Some("fast") { diff --git a/lib/crates/fabro-workflows/src/transform.rs b/lib/crates/fabro-workflows/src/transform.rs index f9337951b..a34df462d 100644 --- a/lib/crates/fabro-workflows/src/transform.rs +++ b/lib/crates/fabro-workflows/src/transform.rs @@ -124,9 +124,9 @@ impl Transform for ModelResolutionTransform { .and_then(AttrValue::as_str) .map(String::from); if let Some(model) = model { - if let Some(info) = fabro_model::get_model_info(&model) { - let canonical_id = info.id; - let provider = info.provider; + if let Some(info) = fabro_model::Catalog::builtin().get(&model) { + let canonical_id = info.id.clone(); + let provider = info.provider.clone(); // Resolve alias to canonical model ID if model != canonical_id { node.attrs