mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
f5d363237a
commit
d9b00ec8bc
35 changed files with 890 additions and 470 deletions
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
151
lib/crates/fabro-model/src/language_model.rs
Normal file
151
lib/crates/fabro-model/src/language_model.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
110
lib/crates/fabro-model/src/model_ref.rs
Normal file
110
lib/crates/fabro-model/src/model_ref.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue