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:
Bryan Helmkamp 2026-03-23 11:48:55 -04:00
parent d9b00ec8bc
commit c7703fc9a0
No known key found for this signature in database
20 changed files with 200 additions and 244 deletions

View file

@ -6,7 +6,7 @@ use crate::sandbox::Sandbox;
use crate::skills::Skill;
use crate::tool_registry::ToolRegistry;
use crate::tools::{make_edit_file_tool, register_core_tools, WebFetchSummarizer};
use fabro_model::{Catalog, LanguageModel, Provider};
use fabro_model::{Catalog, Provider};
use super::EnvContext;

View file

@ -9,7 +9,7 @@ use crate::tools::{
make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool, register_core_tools,
WebFetchSummarizer,
};
use fabro_model::{Catalog, LanguageModel, Provider};
use fabro_model::{Catalog, Provider};
use super::EnvContext;

View file

@ -7,7 +7,7 @@ use crate::skills::Skill;
use crate::tool_registry::ToolRegistry;
use crate::tools::{register_core_tools, WebFetchSummarizer};
use crate::v4a_patch::make_apply_patch_tool;
use fabro_model::{Catalog, LanguageModel, Provider};
use fabro_model::{Catalog, Provider};
use super::EnvContext;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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