Redesign fabro-model with LanguageModel trait, Catalog struct, and ModelRef (#121)

Introduce OOP API for the model catalog: LanguageModel trait with blanket
impl on ModelInfo, Catalog struct with typed methods (get, list,
default_for_provider, closest, build_fallback_chain, etc.), ModelRef enum
replacing ModelId, and Provider::OpenAiCompatible variant. Migrate all
callers across the workspace to use Catalog::builtin() and remove the old
free-function API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-23 11:15:40 -04:00
parent f5d363237a
commit d9b00ec8bc
No known key found for this signature in database
35 changed files with 890 additions and 470 deletions

View file

@ -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),
),

View file

@ -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"],

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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(),

View file

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

View file

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

View file

@ -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<dyn Pr
match provider {
Provider::Anthropic => 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))
}

View file

@ -576,7 +576,14 @@ pub async fn list_models(
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
paginated_response(fabro_model::list_models(None), &pagination)
paginated_response(
fabro_model::Catalog::builtin()
.list(None)
.into_iter()
.cloned()
.collect::<Vec<_>>(),
&pagination,
)
}
// ── Settings ───────────────────────────────────────────────────────────

View file

@ -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::<Provider>().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())),
};

View file

@ -1053,7 +1053,7 @@ async fn test_model(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> 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");

View file

@ -61,12 +61,13 @@ fn generate_title(content: &str) -> String {
fn resolve_model(model_arg: Option<String>) -> (String, Option<String>) {
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),
}
}

View file

@ -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,

View file

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

View file

@ -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::<Provider>().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<fabro_model::FallbackTarget> {
) -> Vec<FallbackTarget> {
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())
};

View file

@ -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<Result<(), String>> {
}
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()))
}

View file

@ -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",
}
}

View file

@ -218,11 +218,8 @@ impl HookExecutorImpl {
/// Resolve a model alias (e.g. "haiku") to a concrete model ID.
fn resolve_model(model: &Option<String>) -> 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.

View file

@ -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<String>, stdin: Option<String>) -> Result<String>
/// Returns (`model_id`, provider) from the catalog, falling back to the first catalog model.
fn resolve_model(model_arg: Option<String>) -> (String, Option<String>) {
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::<Provider>().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::<Provider>().ok());
Catalog::builtin().list(p).into_iter().cloned().collect()
};
if models_to_test.is_empty() {

View file

@ -143,8 +143,9 @@ impl Client {
/// Resolve the provider for a request.
fn resolve_provider(&self, request: &Request) -> Result<Arc<dyn ProviderAdapter>, 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

View file

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

View file

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

View file

@ -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 {

View file

@ -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<Vec<ModelInfo>> = 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<Catalog> = LazyLock::new(|| {
let models: Vec<ModelInfo> = 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<ModelInfo> {
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::<crate::provider::Provider>()
.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<ModelInfo> {
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<ModelInfo> {
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<ModelInfo> {
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<ModelInfo> {
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<ModelInfo>`.
///
/// 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<String, Vec<String>>,
) -> Vec<FallbackTarget> {
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<ModelInfo>,
}
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<ModelInfo>) -> 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<Provider>) -> 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<String, Vec<String>>,
) -> Vec<FallbackTarget> {
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::<Provider>().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));
}
}

View file

@ -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<i64>;
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<f64>;
fn output_cost_per_mtok(&self) -> Option<f64>;
fn cache_input_cost_per_mtok(&self) -> Option<f64>;
fn estimated_output_tps(&self) -> Option<f64>;
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::<Provider>()
.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<i64> {
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<f64> {
self.costs.input_cost_per_mtok
}
fn output_cost_per_mtok(&self) -> Option<f64> {
self.costs.output_cost_per_mtok
}
fn cache_input_cost_per_mtok(&self) -> Option<f64> {
self.costs.cache_input_cost_per_mtok
}
fn estimated_output_tps(&self) -> Option<f64> {
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<dyn LanguageModel> = 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);
}
}

View file

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

View file

@ -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<dyn LanguageModel>),
/// 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"));
}
}

View file

@ -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<String>) -> 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::*;

View file

@ -326,9 +326,11 @@ fn spawn_retro_event_forwarder(
fn build_profile(provider: Provider, model: &str) -> Box<dyn ProviderProfile> {
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)),
}

View file

@ -907,7 +907,7 @@ fn check_model_known(
context: &str,
node_id: Option<String>,
) -> Option<Diagnostic> {
if fabro_model::get_model_info(model).is_some() {
if fabro_model::Catalog::builtin().get(model).is_some() {
return None;
}
Some(Diagnostic {

View file

@ -23,9 +23,11 @@ use fabro_graphviz::graph::Node;
fn build_profile(model: &str, provider: Provider) -> Box<dyn ProviderProfile> {
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<dyn ProviderProfile> = 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 {

View file

@ -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<CliRespons
| Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception => parse_codex_ndjson(output),
| Provider::Inception
| Provider::OpenAiCompatible => parse_codex_ndjson(output),
Provider::Gemini => parse_gemini_json(output),
Provider::Anthropic => parse_claude_ndjson(output),
}

View file

@ -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<f64> {
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") {

View file

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