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