Canonical models for Providers (#5694)
This commit is contained in:
@@ -576,7 +576,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
|
||||
let models_res = {
|
||||
let temp_model_config = ModelConfig::new(&provider_meta.default_model)?;
|
||||
let temp_provider = create(provider_name, temp_model_config).await?;
|
||||
temp_provider.fetch_supported_models().await
|
||||
temp_provider.fetch_recommended_models().await
|
||||
};
|
||||
spin.stop(style("Model fetch complete").green());
|
||||
|
||||
|
||||
@@ -392,7 +392,9 @@ pub async fn get_provider_models(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
match provider.fetch_supported_models().await {
|
||||
let models_result = provider.fetch_recommended_models().await;
|
||||
|
||||
match models_result {
|
||||
Ok(Some(models)) => Ok(Json(models)),
|
||||
Ok(None) => Ok(Json(Vec::new())),
|
||||
Err(provider_error) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
/// Build canonical models from OpenRouter API
|
||||
///
|
||||
/// This script fetches models from OpenRouter and converts them to canonical format.
|
||||
/// Usage:
|
||||
/// cargo run --example build_canonical_models
|
||||
///
|
||||
use anyhow::{Context, Result};
|
||||
use goose::providers::canonical::{
|
||||
canonical_name, CanonicalModel, CanonicalModelRegistry, Pricing,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/models";
|
||||
const ALLOWED_PROVIDERS: &[&str] = &[
|
||||
"anthropic",
|
||||
"google",
|
||||
"openai",
|
||||
"meta-llama",
|
||||
"mistralai",
|
||||
"x-ai",
|
||||
"deepseek",
|
||||
"cohere",
|
||||
"ai21",
|
||||
"qwen",
|
||||
];
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
println!("Fetching models from OpenRouter API...");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(OPENROUTER_API_URL)
|
||||
.header("User-Agent", "goose/canonical-builder")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch from OpenRouter API")?;
|
||||
|
||||
let json: Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse OpenRouter response")?;
|
||||
|
||||
let models = json["data"]
|
||||
.as_array()
|
||||
.context("Expected 'data' array in OpenRouter response")?
|
||||
.clone();
|
||||
|
||||
println!("Processing {} models from OpenRouter...", models.len());
|
||||
|
||||
// First pass: Group models by canonical ID and track the one with shortest name
|
||||
let mut canonical_groups: HashMap<String, &Value> = HashMap::new();
|
||||
let mut shortest_names: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for model in &models {
|
||||
let id = model["id"].as_str().unwrap();
|
||||
let name = model["name"].as_str().context("Model missing id field")?;
|
||||
|
||||
// Skip OpenRouter-specific pricing variants (:free, :nitro)
|
||||
// Keep :extended since it has different context length
|
||||
if id.contains(":free") || id.contains(":nitro") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let canonical_id = canonical_name("openrouter", id);
|
||||
|
||||
let provider = canonical_id.split('/').next().unwrap_or("");
|
||||
if !ALLOWED_PROVIDERS.contains(&provider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prompt_cost = model
|
||||
.get("pricing")
|
||||
.and_then(|p| p.get("prompt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let completion_cost = model
|
||||
.get("pricing")
|
||||
.and_then(|p| p.get("completion"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let has_paid_pricing = prompt_cost > 0.0 || completion_cost > 0.0;
|
||||
|
||||
if let Some(existing_model) = canonical_groups.get(&canonical_id) {
|
||||
let existing_name = shortest_names.get(&canonical_id).unwrap();
|
||||
|
||||
let existing_prompt = existing_model
|
||||
.get("pricing")
|
||||
.and_then(|p| p.get("prompt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let existing_completion = existing_model
|
||||
.get("pricing")
|
||||
.and_then(|p| p.get("completion"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let existing_has_paid = existing_prompt > 0.0 || existing_completion > 0.0;
|
||||
|
||||
let should_replace = if has_paid_pricing != existing_has_paid {
|
||||
has_paid_pricing // Prefer the one with paid pricing
|
||||
} else {
|
||||
name.len() < existing_name.len() // Both same pricing tier, prefer shorter name
|
||||
};
|
||||
|
||||
if should_replace {
|
||||
println!(
|
||||
" Updating {} from '{}' (paid: {}) to '{}' (paid: {})",
|
||||
canonical_id,
|
||||
existing_model["id"].as_str().unwrap(),
|
||||
existing_has_paid,
|
||||
id,
|
||||
has_paid_pricing
|
||||
);
|
||||
shortest_names.insert(canonical_id.clone(), name.to_string());
|
||||
canonical_groups.insert(canonical_id, model);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" Adding: {} (from {}, paid: {})",
|
||||
canonical_id, id, has_paid_pricing
|
||||
);
|
||||
shortest_names.insert(canonical_id.clone(), name.to_string());
|
||||
canonical_groups.insert(canonical_id, model);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out beta/preview variants if non-beta version exists
|
||||
let beta_suffixes = ["-beta", "-preview", "-alpha"];
|
||||
let mut to_remove = Vec::new();
|
||||
|
||||
for canonical_id in canonical_groups.keys() {
|
||||
for suffix in &beta_suffixes {
|
||||
if canonical_id.ends_with(suffix) {
|
||||
// Check if non-beta version exists
|
||||
let base_id = canonical_id.strip_suffix(suffix).unwrap();
|
||||
if canonical_groups.contains_key(base_id) {
|
||||
println!(
|
||||
" Filtering out {} (non-beta version {} exists)",
|
||||
canonical_id, base_id
|
||||
);
|
||||
to_remove.push(canonical_id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id in to_remove {
|
||||
canonical_groups.remove(&id);
|
||||
shortest_names.remove(&id);
|
||||
}
|
||||
|
||||
// Second pass: Build the registry with the selected models
|
||||
let mut registry = CanonicalModelRegistry::new();
|
||||
|
||||
for (canonical_id, model) in canonical_groups.iter() {
|
||||
let name = shortest_names.get(canonical_id).unwrap();
|
||||
|
||||
let context_length = model["context_length"].as_u64().unwrap_or(128_000) as usize;
|
||||
|
||||
let max_completion_tokens = model
|
||||
.get("top_provider")
|
||||
.and_then(|tp| tp.get("max_completion_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as usize);
|
||||
|
||||
let input_modalities: Vec<String> = model
|
||||
.get("architecture")
|
||||
.and_then(|arch| arch.get("input_modalities"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec!["text".to_string()]);
|
||||
|
||||
let output_modalities: Vec<String> = model
|
||||
.get("architecture")
|
||||
.and_then(|arch| arch.get("output_modalities"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec!["text".to_string()]);
|
||||
|
||||
let supports_tools = model
|
||||
.get("supported_parameters")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|params| params.iter().any(|param| param.as_str() == Some("tools")))
|
||||
.unwrap_or(false);
|
||||
|
||||
let pricing_obj = model
|
||||
.get("pricing")
|
||||
.context("Model missing pricing field")?;
|
||||
let pricing = Pricing {
|
||||
prompt: pricing_obj
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok()),
|
||||
completion: pricing_obj
|
||||
.get("completion")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok()),
|
||||
request: pricing_obj
|
||||
.get("request")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok()),
|
||||
image: pricing_obj
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok()),
|
||||
};
|
||||
|
||||
let canonical_model = CanonicalModel {
|
||||
id: canonical_id.clone(),
|
||||
name: name.to_string(),
|
||||
context_length,
|
||||
max_completion_tokens,
|
||||
input_modalities,
|
||||
output_modalities,
|
||||
supports_tools,
|
||||
pricing,
|
||||
};
|
||||
|
||||
registry.register(canonical_model);
|
||||
}
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/providers/canonical/data/canonical_models.json");
|
||||
registry.to_file(&output_path)?;
|
||||
println!(
|
||||
"\n✓ Wrote {} models to {}",
|
||||
registry.count(),
|
||||
output_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/// Canonical Model Checker
|
||||
///
|
||||
/// This script checks which models from top providers are properly mapped to canonical models.
|
||||
/// It maintains a lock file of mappings and detects changes between runs.
|
||||
///
|
||||
/// Outputs:
|
||||
/// - Models that are NOT mapped to canonical models
|
||||
/// - Full list of (provider, model) <-> canonical-model mappings
|
||||
/// - Diff report showing mapping changes since last run:
|
||||
/// * Changed mappings (model now maps to a different canonical model)
|
||||
/// * Added mappings (model gained a canonical mapping)
|
||||
/// * Removed mappings (model lost its canonical mapping)
|
||||
///
|
||||
/// Output File:
|
||||
/// - src/providers/canonical/data/canonical_mapping_report.json
|
||||
/// Contains full report with mapping data (acts as a lock file)
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo run --example canonical_model_checker -- [--output custom_path.json]
|
||||
///
|
||||
use anyhow::{Context, Result};
|
||||
use goose::providers::{canonical::ModelMapping, create_with_named_model};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
struct ProviderModelPair {
|
||||
provider: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MappingEntry {
|
||||
provider: String,
|
||||
model: String,
|
||||
canonical: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MappingReport {
|
||||
/// Timestamp of this report
|
||||
timestamp: String,
|
||||
|
||||
/// Models that are NOT mapped to canonical models
|
||||
unmapped_models: Vec<ProviderModelPair>,
|
||||
|
||||
/// All mappings: (provider, model) -> canonical model
|
||||
/// Stored per provider for backward compatibility
|
||||
all_mappings: HashMap<String, Vec<ModelMapping>>,
|
||||
|
||||
/// Flat list of all mappings for easier comparison (lock file format)
|
||||
mapped_models: Vec<MappingEntry>,
|
||||
|
||||
/// Total models checked per provider
|
||||
model_counts: HashMap<String, usize>,
|
||||
|
||||
/// Canonical models referenced
|
||||
canonical_models_used: HashSet<String>,
|
||||
}
|
||||
|
||||
impl MappingReport {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
unmapped_models: Vec::new(),
|
||||
all_mappings: HashMap::new(),
|
||||
mapped_models: Vec::new(),
|
||||
model_counts: HashMap::new(),
|
||||
canonical_models_used: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_provider_results(
|
||||
&mut self,
|
||||
provider_name: &str,
|
||||
fetched_models: Vec<String>,
|
||||
mappings: Vec<ModelMapping>,
|
||||
) {
|
||||
let mapping_map: HashMap<String, String> = mappings
|
||||
.iter()
|
||||
.map(|m| (m.provider_model.clone(), m.canonical_model.clone()))
|
||||
.collect();
|
||||
|
||||
for model in &fetched_models {
|
||||
if !mapping_map.contains_key(model) {
|
||||
self.unmapped_models.push(ProviderModelPair {
|
||||
provider: provider_name.to_string(),
|
||||
model: model.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (model, canonical) in &mapping_map {
|
||||
self.canonical_models_used.insert(canonical.clone());
|
||||
self.mapped_models.push(MappingEntry {
|
||||
provider: provider_name.to_string(),
|
||||
model: model.clone(),
|
||||
canonical: canonical.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.all_mappings
|
||||
.insert(provider_name.to_string(), mappings);
|
||||
self.model_counts
|
||||
.insert(provider_name.to_string(), fetched_models.len());
|
||||
}
|
||||
|
||||
fn print_summary(&self) {
|
||||
println!("\n{}", "=".repeat(80));
|
||||
println!("CANONICAL MODEL MAPPING REPORT");
|
||||
println!("{}", "=".repeat(80));
|
||||
println!("\nGenerated: {}\n", self.timestamp);
|
||||
|
||||
println!("Models Checked Per Provider:");
|
||||
println!("{}", "-".repeat(80));
|
||||
let mut providers: Vec<_> = self.model_counts.iter().collect();
|
||||
providers.sort_by_key(|(name, _)| *name);
|
||||
for (provider, count) in providers {
|
||||
let mapped = self
|
||||
.all_mappings
|
||||
.get(provider)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let unmapped = count - mapped;
|
||||
println!(
|
||||
" {:<20} Total: {:>3} Mapped: {:>3} Unmapped: {:>3}",
|
||||
provider, count, mapped, unmapped
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
println!("UNMAPPED MODELS ({})", self.unmapped_models.len());
|
||||
println!("{}", "=".repeat(80));
|
||||
|
||||
if self.unmapped_models.is_empty() {
|
||||
println!("✓ All models are mapped to canonical models!");
|
||||
} else {
|
||||
let mut unmapped_by_provider: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for pair in &self.unmapped_models {
|
||||
unmapped_by_provider
|
||||
.entry(pair.provider.as_str())
|
||||
.or_default()
|
||||
.push(pair.model.as_str());
|
||||
}
|
||||
|
||||
let mut providers: Vec<_> = unmapped_by_provider.keys().collect();
|
||||
providers.sort();
|
||||
|
||||
for provider in providers {
|
||||
println!("\n{}:", provider);
|
||||
let mut models = unmapped_by_provider[provider].to_vec();
|
||||
models.sort();
|
||||
for model in models {
|
||||
println!(" - {}", model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
println!(
|
||||
"CANONICAL MODELS REFERENCED ({})",
|
||||
self.canonical_models_used.len()
|
||||
);
|
||||
println!("{}", "=".repeat(80));
|
||||
if self.canonical_models_used.is_empty() {
|
||||
println!(" (none yet)");
|
||||
} else {
|
||||
let mut canonical: Vec<_> = self.canonical_models_used.iter().collect();
|
||||
canonical.sort();
|
||||
for model in canonical {
|
||||
println!(" - {}", model);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
}
|
||||
|
||||
fn compare_with_previous(&self, previous: &MappingReport) {
|
||||
println!("\n{}", "=".repeat(80));
|
||||
println!("CHANGES SINCE PREVIOUS RUN");
|
||||
println!("{}", "=".repeat(80));
|
||||
|
||||
let mut prev_map: HashMap<(String, String), String> = HashMap::new();
|
||||
for entry in &previous.mapped_models {
|
||||
prev_map.insert(
|
||||
(entry.provider.clone(), entry.model.clone()),
|
||||
entry.canonical.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut curr_map: HashMap<(String, String), String> = HashMap::new();
|
||||
for entry in &self.mapped_models {
|
||||
curr_map.insert(
|
||||
(entry.provider.clone(), entry.model.clone()),
|
||||
entry.canonical.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut changed_mappings = Vec::new();
|
||||
let mut added_mappings = Vec::new();
|
||||
let mut removed_mappings = Vec::new();
|
||||
|
||||
for (key @ (provider, model), canonical) in &curr_map {
|
||||
match prev_map.get(key) {
|
||||
Some(prev_canonical) if prev_canonical != canonical => {
|
||||
changed_mappings.push((
|
||||
provider.clone(),
|
||||
model.clone(),
|
||||
prev_canonical.clone(),
|
||||
canonical.clone(),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
added_mappings.push((provider.clone(), model.clone(), canonical.clone()));
|
||||
}
|
||||
_ => {
|
||||
// No change
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (key @ (provider, model), canonical) in &prev_map {
|
||||
if !curr_map.contains_key(key) {
|
||||
removed_mappings.push((provider.clone(), model.clone(), canonical.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if changed_mappings.is_empty() && added_mappings.is_empty() && removed_mappings.is_empty() {
|
||||
println!("\nNo changes in model mappings.");
|
||||
} else {
|
||||
if !changed_mappings.is_empty() {
|
||||
println!("\n⚠ Changed Mappings ({}):", changed_mappings.len());
|
||||
println!(" (Models that now map to a different canonical model)");
|
||||
for (provider, model, old_canonical, new_canonical) in changed_mappings {
|
||||
println!(" {} / {}", provider, model);
|
||||
println!(" WAS: {}", old_canonical);
|
||||
println!(" NOW: {}", new_canonical);
|
||||
}
|
||||
}
|
||||
|
||||
if !added_mappings.is_empty() {
|
||||
println!("\n✓ Added Mappings ({}):", added_mappings.len());
|
||||
println!(" (Models that gained a canonical mapping)");
|
||||
for (provider, model, canonical) in added_mappings {
|
||||
println!(" {} / {} -> {}", provider, model, canonical);
|
||||
}
|
||||
}
|
||||
|
||||
if !removed_mappings.is_empty() {
|
||||
println!("\n✗ Removed Mappings ({}):", removed_mappings.len());
|
||||
println!(" (Models that lost their canonical mapping)");
|
||||
for (provider, model, canonical) in removed_mappings {
|
||||
println!(" {} / {} (was: {})", provider, model, canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
}
|
||||
|
||||
fn save_to_file(&self, path: &PathBuf) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(self).context("Failed to serialize report")?;
|
||||
std::fs::write(path, json).context("Failed to write report file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_from_file(path: &PathBuf) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path).context("Failed to read report file")?;
|
||||
let report: MappingReport =
|
||||
serde_json::from_str(&content).context("Failed to parse report file")?;
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_provider(
|
||||
provider_name: &str,
|
||||
model_for_init: &str,
|
||||
) -> Result<(Vec<String>, Vec<ModelMapping>)> {
|
||||
println!("Checking provider: {}", provider_name);
|
||||
|
||||
let provider = match create_with_named_model(provider_name, model_for_init).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!(" ⚠ Failed to create provider: {}", e);
|
||||
println!(" This is expected if credentials are not configured.");
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
};
|
||||
|
||||
let fetched_models = match provider.fetch_supported_models().await {
|
||||
Ok(Some(models)) => {
|
||||
println!(" ✓ Fetched {} models", models.len());
|
||||
models
|
||||
}
|
||||
Ok(None) => {
|
||||
println!(" ⚠ Provider does not support model listing");
|
||||
Vec::new()
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ⚠ Failed to fetch models: {}", e);
|
||||
println!(" This is expected if credentials are not configured.");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut mappings = Vec::new();
|
||||
for model in &fetched_models {
|
||||
match provider.map_to_canonical_model(model).await {
|
||||
Ok(Some(canonical)) => {
|
||||
mappings.push(ModelMapping::new(model.clone(), canonical));
|
||||
}
|
||||
Ok(None) => {
|
||||
// No mapping found for this model
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ⚠ Failed to map model '{}': {}", model, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" ✓ Found {} mappings", mappings.len());
|
||||
|
||||
Ok((fetched_models, mappings))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
println!("Canonical Model Checker");
|
||||
println!("Checking model mappings for top providers...\n");
|
||||
|
||||
// Define providers to check with their default models
|
||||
let providers = vec![
|
||||
("anthropic", "claude-3-5-sonnet-20241022"),
|
||||
("openai", "gpt-4"),
|
||||
("openrouter", "anthropic/claude-3.5-sonnet"),
|
||||
("google", "gemini-1.5-pro-002"),
|
||||
("tetrate", "claude-3-5-sonnet-computer-use"),
|
||||
("xai", "grok-code-fast-1"),
|
||||
];
|
||||
|
||||
let mut report = MappingReport::new();
|
||||
|
||||
for (provider_name, default_model) in providers {
|
||||
let (fetched, mappings) = check_provider(provider_name, default_model).await?;
|
||||
report.add_provider_results(provider_name, fetched, mappings);
|
||||
println!();
|
||||
}
|
||||
|
||||
report.print_summary();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let output_path = if args.len() > 2 && args[1] == "--output" {
|
||||
PathBuf::from(&args[2])
|
||||
} else {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/providers/canonical/data/canonical_mapping_report.json")
|
||||
};
|
||||
|
||||
if output_path.exists() {
|
||||
if let Ok(previous) = MappingReport::load_from_file(&output_path) {
|
||||
report.compare_with_previous(&previous);
|
||||
}
|
||||
}
|
||||
|
||||
report.save_to_file(&output_path)?;
|
||||
println!("\n✓ Report saved to: {}", output_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use anyhow::Result;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::canonical::{map_to_canonical_model, CanonicalModelRegistry};
|
||||
use super::errors::ProviderError;
|
||||
use super::retry::RetryConfig;
|
||||
use crate::config::base::ConfigValue;
|
||||
@@ -415,6 +416,52 @@ pub trait Provider: Send + Sync {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Fetch models filtered by canonical registry and usability
|
||||
async fn fetch_recommended_models(&self) -> Result<Option<Vec<String>>, ProviderError> {
|
||||
let all_models = match self.fetch_supported_models().await? {
|
||||
Some(models) => models,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let registry = CanonicalModelRegistry::bundled().map_err(|e| {
|
||||
ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e))
|
||||
})?;
|
||||
|
||||
let provider_name = self.get_name();
|
||||
|
||||
let recommended_models: Vec<String> = all_models
|
||||
.iter()
|
||||
.filter(|model| {
|
||||
map_to_canonical_model(provider_name, model, registry)
|
||||
.and_then(|canonical_id| registry.get(&canonical_id))
|
||||
.map(|m| m.input_modalities.contains(&"text".to_string()))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if recommended_models.is_empty() {
|
||||
Ok(Some(all_models))
|
||||
} else {
|
||||
Ok(Some(recommended_models))
|
||||
}
|
||||
}
|
||||
|
||||
async fn map_to_canonical_model(
|
||||
&self,
|
||||
provider_model: &str,
|
||||
) -> Result<Option<String>, ProviderError> {
|
||||
let registry = CanonicalModelRegistry::bundled().map_err(|e| {
|
||||
ProviderError::ExecutionError(format!("Failed to load canonical registry: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(map_to_canonical_model(
|
||||
self.get_name(),
|
||||
provider_model,
|
||||
registry,
|
||||
))
|
||||
}
|
||||
|
||||
fn supports_embeddings(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Canonical Model System
|
||||
|
||||
Provides a unified view of model metadata (pricing, capabilities, context limits) across different LLM providers.
|
||||
Normalizes provider-specific model names (e.g., `claude-3-5-sonnet-20241022`)
|
||||
to canonical IDs (e.g., `anthropic/claude-3.5-sonnet`).
|
||||
|
||||
## Scripts
|
||||
|
||||
### Build Canonical Models
|
||||
Fetches latest model metadata from OpenRouter and updates the registry:
|
||||
```bash
|
||||
cargo run --example build_canonical_models
|
||||
```
|
||||
Writes to: `src/providers/canonical/data/canonical_models.json`
|
||||
|
||||
### Check Model Mappings
|
||||
Tests provider model mappings and tracks changes over time:
|
||||
```bash
|
||||
cargo run --example canonical_model_checker
|
||||
```
|
||||
- Reports unmapped models
|
||||
- Compares with previous runs (like a lock file)
|
||||
- Shows changed/added/removed mappings
|
||||
- Writes to: `src/providers/canonical/data/canonical_mapping_report.json`
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
mod model;
|
||||
mod name_builder;
|
||||
mod registry;
|
||||
|
||||
pub use model::{CanonicalModel, Pricing};
|
||||
pub use name_builder::{canonical_name, map_to_canonical_model, strip_version_suffix};
|
||||
pub use registry::CanonicalModelRegistry;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModelMapping {
|
||||
pub provider_model: String,
|
||||
pub canonical_model: String,
|
||||
}
|
||||
|
||||
impl ModelMapping {
|
||||
pub fn new(provider_model: impl Into<String>, canonical_model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider_model: provider_model.into(),
|
||||
canonical_model: canonical_model.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pricing information for a model (all costs in USD per token)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pricing {
|
||||
/// Cost per prompt token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<f64>,
|
||||
|
||||
/// Cost per completion token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion: Option<f64>,
|
||||
|
||||
/// Cost per request
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request: Option<f64>,
|
||||
|
||||
/// Cost per image
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<f64>,
|
||||
}
|
||||
|
||||
/// Canonical representation of a model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanonicalModel {
|
||||
/// Model identifier (e.g., "anthropic/claude-3-5-sonnet" or "openai/gpt-4o:extended")
|
||||
pub id: String,
|
||||
|
||||
/// Human-readable name (e.g., "Claude 3.5 Sonnet")
|
||||
pub name: String,
|
||||
|
||||
/// Maximum context window size in tokens
|
||||
pub context_length: usize,
|
||||
|
||||
/// Maximum completion tokens
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<usize>,
|
||||
|
||||
/// Input modalities supported (e.g., ["text", "image"])
|
||||
#[serde(default)]
|
||||
pub input_modalities: Vec<String>,
|
||||
|
||||
/// Output modalities supported (e.g., ["text"])
|
||||
#[serde(default)]
|
||||
pub output_modalities: Vec<String>,
|
||||
|
||||
/// Whether the model supports tool calling
|
||||
#[serde(default)]
|
||||
pub supports_tools: bool,
|
||||
|
||||
/// Pricing for this model
|
||||
pub pricing: Pricing,
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
static NORMALIZE_VERSION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"-(\d)-(\d)(-|$)").unwrap());
|
||||
|
||||
static STRIP_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
|
||||
vec![
|
||||
Regex::new(r"-latest$").unwrap(),
|
||||
Regex::new(r"-preview(-\d+)*$").unwrap(),
|
||||
Regex::new(r"-exp(-\d+)*$").unwrap(),
|
||||
Regex::new(r":exacto$").unwrap(),
|
||||
Regex::new(r"-\d{8}$").unwrap(),
|
||||
Regex::new(r"-\d{4}-\d{2}-\d{2}$").unwrap(),
|
||||
Regex::new(r"-v\d+(\.\d+)*$").unwrap(),
|
||||
Regex::new(r"-\d{3,}$").unwrap(),
|
||||
Regex::new(r"-bedrock$").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
static CLAUDE_PATTERNS: Lazy<Vec<(Regex, Regex, &'static str)>> = Lazy::new(|| {
|
||||
["sonnet", "opus", "haiku"]
|
||||
.iter()
|
||||
.map(|&size| {
|
||||
(
|
||||
Regex::new(&format!("claude-([0-9.-]+)-{}", size)).unwrap(),
|
||||
Regex::new(&format!("claude-{}-([0-9.-]+)", size)).unwrap(),
|
||||
size,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// Build canonical model name from provider and model identifiers
|
||||
pub fn canonical_name(provider: &str, model: &str) -> String {
|
||||
let model_base = strip_version_suffix(model);
|
||||
|
||||
// OpenRouter models are already in canonical format
|
||||
if provider == "openrouter" {
|
||||
model_base
|
||||
} else {
|
||||
format!("{}/{}", provider, model_base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to build a canonical name and check if it exists in the registry
|
||||
fn try_canonical(
|
||||
provider: &str,
|
||||
model: &str,
|
||||
registry: &super::CanonicalModelRegistry,
|
||||
) -> Option<String> {
|
||||
let candidate = canonical_name(provider, model);
|
||||
registry.get(&candidate).map(|_| candidate)
|
||||
}
|
||||
|
||||
/// Try to map a provider/model pair to a canonical model
|
||||
pub fn map_to_canonical_model(
|
||||
provider: &str,
|
||||
model: &str,
|
||||
registry: &super::CanonicalModelRegistry,
|
||||
) -> Option<String> {
|
||||
// Try direct mapping first
|
||||
if let Some(candidate) = try_canonical(provider, model, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
// Try with common prefixes stripped
|
||||
let model_stripped = strip_common_prefixes(model);
|
||||
if model_stripped != model {
|
||||
if let Some(candidate) = try_canonical(provider, &model_stripped, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Try word-order swapping for Claude models (claude-4-opus ↔ claude-opus-4)
|
||||
if let Some(swapped) = swap_claude_word_order(&model_stripped) {
|
||||
if let Some(candidate) = try_canonical(provider, &swapped, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
if is_hosting_provider(provider) {
|
||||
if let Some(inferred) = infer_provider_from_model(&swapped) {
|
||||
if let Some(candidate) = try_canonical(inferred, &swapped, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For hosting providers, try to infer the real provider from model name patterns
|
||||
if is_hosting_provider(provider) {
|
||||
if let Some(inferred_provider) = infer_provider_from_model(&model_stripped) {
|
||||
if let Some(candidate) = try_canonical(inferred_provider, &model_stripped, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(inferred) = infer_provider_from_model(model) {
|
||||
if let Some(candidate) = try_canonical(inferred, model, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For provider-prefixed models like "databricks-meta-llama-3-1-70b"
|
||||
if let Some((extracted_provider, extracted_model)) = extract_provider_prefix(&model_stripped) {
|
||||
if let Some(candidate) = try_canonical(extracted_provider, extracted_model, registry) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Swap word order for Claude models to handle both naming conventions
|
||||
fn swap_claude_word_order(model: &str) -> Option<String> {
|
||||
if !model.starts_with("claude-") {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (forward_re, reverse_re, size) in CLAUDE_PATTERNS.iter() {
|
||||
if let Some(captures) = forward_re.captures(model) {
|
||||
let version = &captures[1];
|
||||
return Some(format!("claude-{}-{}", size, version));
|
||||
}
|
||||
|
||||
if let Some(captures) = reverse_re.captures(model) {
|
||||
let version = &captures[1];
|
||||
return Some(format!("claude-{}-{}", version, size));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_hosting_provider(provider: &str) -> bool {
|
||||
matches!(provider, "databricks" | "openrouter" | "azure" | "bedrock")
|
||||
}
|
||||
|
||||
/// Infer the real provider from model name patterns
|
||||
fn infer_provider_from_model(model: &str) -> Option<&'static str> {
|
||||
let model_lower = model.to_lowercase();
|
||||
|
||||
if model_lower.contains("claude") {
|
||||
return Some("anthropic");
|
||||
}
|
||||
|
||||
if model_lower.starts_with("gpt-")
|
||||
|| model_lower.starts_with("o1")
|
||||
|| model_lower.starts_with("o3")
|
||||
|| model_lower.starts_with("o4")
|
||||
|| model_lower.starts_with("chatgpt-")
|
||||
{
|
||||
return Some("openai");
|
||||
}
|
||||
|
||||
if model_lower.starts_with("gemini-") || model_lower.starts_with("gemma-") {
|
||||
return Some("google");
|
||||
}
|
||||
|
||||
if model_lower.contains("llama") {
|
||||
return Some("meta-llama");
|
||||
}
|
||||
|
||||
if model_lower.starts_with("mistral")
|
||||
|| model_lower.starts_with("mixtral")
|
||||
|| model_lower.starts_with("codestral")
|
||||
|| model_lower.starts_with("ministral")
|
||||
|| model_lower.starts_with("pixtral")
|
||||
|| model_lower.starts_with("devstral")
|
||||
|| model_lower.starts_with("voxtral")
|
||||
{
|
||||
return Some("mistralai");
|
||||
}
|
||||
|
||||
if model_lower.contains("deepseek") {
|
||||
return Some("deepseek");
|
||||
}
|
||||
|
||||
if model_lower.contains("qwen") {
|
||||
return Some("qwen");
|
||||
}
|
||||
|
||||
if model_lower.contains("grok") {
|
||||
return Some("x-ai");
|
||||
}
|
||||
|
||||
if model_lower.contains("jamba") {
|
||||
return Some("ai21");
|
||||
}
|
||||
|
||||
if model_lower.contains("command") {
|
||||
return Some("cohere");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip common prefixes from model names using pattern matching
|
||||
/// Looks for known model family patterns and strips everything before them
|
||||
fn strip_common_prefixes(model: &str) -> String {
|
||||
let model_patterns = [
|
||||
"claude-",
|
||||
"gpt-",
|
||||
"gemini-",
|
||||
"gemma-",
|
||||
"o1-",
|
||||
"o1",
|
||||
"o3-",
|
||||
"o3",
|
||||
"o4-",
|
||||
"llama-",
|
||||
"mistral-",
|
||||
"mixtral-",
|
||||
"chatgpt-",
|
||||
"deepseek-",
|
||||
"qwen-",
|
||||
"grok-",
|
||||
"jamba-",
|
||||
"command-",
|
||||
"codestral",
|
||||
"ministral-",
|
||||
"pixtral-",
|
||||
"devstral-",
|
||||
];
|
||||
|
||||
let mut earliest_pos = None;
|
||||
|
||||
for pattern in &model_patterns {
|
||||
if let Some(pos) = model.to_lowercase().find(pattern) {
|
||||
if earliest_pos.is_none() || pos < earliest_pos.unwrap() {
|
||||
earliest_pos = Some(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a pattern, strip everything before it
|
||||
if let Some(pos) = earliest_pos {
|
||||
return model.get(pos..).unwrap_or(model).to_string();
|
||||
}
|
||||
|
||||
model.to_string()
|
||||
}
|
||||
|
||||
/// Try to extract provider prefix from model names like "databricks-meta-llama-3-1-70b"
|
||||
/// Returns (provider, model) tuple if found
|
||||
fn extract_provider_prefix(model: &str) -> Option<(&'static str, &str)> {
|
||||
let known_providers = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google",
|
||||
"meta-llama",
|
||||
"mistralai",
|
||||
"cohere",
|
||||
"ai21",
|
||||
"amazon",
|
||||
"deepseek",
|
||||
"qwen",
|
||||
"x-ai",
|
||||
"nvidia",
|
||||
"microsoft",
|
||||
"perplexity",
|
||||
];
|
||||
|
||||
for provider in &known_providers {
|
||||
let prefix = format!("{}-", provider);
|
||||
if model.starts_with(&prefix) {
|
||||
if let Some(model_part) = model.strip_prefix(&prefix) {
|
||||
return Some((provider, model_part));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip version suffixes from model names and normalize version numbers
|
||||
pub fn strip_version_suffix(model: &str) -> String {
|
||||
let mut result = NORMALIZE_VERSION_RE
|
||||
.replace_all(model, "-$1.$2$3")
|
||||
.to_string();
|
||||
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
let before = result.clone();
|
||||
for pattern in STRIP_PATTERNS.iter() {
|
||||
result = pattern.replace(&result, "").to_string();
|
||||
}
|
||||
changed = result != before;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_map_to_canonical_model() {
|
||||
let r = super::super::CanonicalModelRegistry::bundled().unwrap();
|
||||
|
||||
// === Direct provider (non-hosting) ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("anthropic", "claude-3-5-sonnet-20241022", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("openai", "gpt-4o-latest", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("openai", "gpt-4-turbo-2024-04-09", r),
|
||||
Some("openai/gpt-4-turbo".to_string())
|
||||
);
|
||||
|
||||
// === OpenRouter (already canonical format) ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("openrouter", "anthropic/claude-3.5-sonnet", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
|
||||
// === Anthropic Claude - basic ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-3-5-sonnet", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-3-5-sonnet-20241022", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-3-5-sonnet-latest", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
|
||||
// === Claude word-order swapping (3.x series) ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-haiku-3-5", r),
|
||||
Some("anthropic/claude-3.5-haiku".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-sonnet-3-7", r),
|
||||
Some("anthropic/claude-3.7-sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "ng-tools-claude-haiku-3-5", r),
|
||||
Some("anthropic/claude-3.5-haiku".to_string())
|
||||
);
|
||||
|
||||
// === Claude word-order swapping (4.x series) ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-4-opus", r),
|
||||
Some("anthropic/claude-opus-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-4-sonnet", r),
|
||||
Some("anthropic/claude-sonnet-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "raml-claude-opus-4-5", r),
|
||||
Some("anthropic/claude-opus-4.5".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "databricks-claude-sonnet-4-5", r),
|
||||
Some("anthropic/claude-sonnet-4.5".to_string())
|
||||
);
|
||||
|
||||
// === Claude with custom prefixes ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "goose-claude-4-opus", r),
|
||||
Some("anthropic/claude-opus-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "kgoose-claude-4-sonnet", r),
|
||||
Some("anthropic/claude-sonnet-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "headless-goose-claude-4-sonnet", r),
|
||||
Some("anthropic/claude-sonnet-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "kgoose-cashapp-claude-4-sonnet", r),
|
||||
Some("anthropic/claude-sonnet-4".to_string())
|
||||
);
|
||||
|
||||
// === Claude with platform suffixes ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "claude-4-sonnet-bedrock", r),
|
||||
Some("anthropic/claude-sonnet-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "goose-claude-4-sonnet-bedrock", r),
|
||||
Some("anthropic/claude-sonnet-4".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("bedrock", "claude-3-5-sonnet", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
|
||||
// === OpenAI GPT ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "gpt-4o", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "gpt-4o-2024-11-20", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "gpt-4o-latest", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "kgoose-gpt-4o", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("azure", "gpt-4o", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
|
||||
// === OpenAI O-series ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "goose-o1", r),
|
||||
Some("openai/o1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "kgoose-o3", r),
|
||||
Some("openai/o3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "headless-goose-o3-mini", r),
|
||||
Some("openai/o3-mini".to_string())
|
||||
);
|
||||
|
||||
// === Google Gemini ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "gemini-2-5-flash", r),
|
||||
Some("google/gemini-2.5-flash".to_string())
|
||||
);
|
||||
|
||||
// === Meta Llama ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "meta-llama-3-1-70b-instruct", r),
|
||||
Some("meta-llama/llama-3.1-70b-instruct".to_string())
|
||||
);
|
||||
|
||||
// === Mistral variants ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "codestral", r),
|
||||
Some("mistralai/codestral".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "ministral-8b", r),
|
||||
Some("mistralai/ministral-8b".to_string())
|
||||
);
|
||||
|
||||
// === DeepSeek ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "databricks-deepseek-chat", r),
|
||||
Some("deepseek/deepseek-chat".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "deepseek-r1", r),
|
||||
Some("deepseek/deepseek-r1".to_string())
|
||||
);
|
||||
|
||||
// === Qwen ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "qwen-2-5-72b-instruct", r),
|
||||
Some("qwen/qwen-2.5-72b-instruct".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "goose-qwen-2-5-72b-instruct", r),
|
||||
Some("qwen/qwen-2.5-72b-instruct".to_string())
|
||||
);
|
||||
|
||||
// === Grok (X.AI) ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "grok-3", r),
|
||||
Some("x-ai/grok-3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "databricks-grok-4-fast", r),
|
||||
Some("x-ai/grok-4-fast".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "kgoose-grok-4-fast", r),
|
||||
Some("x-ai/grok-4-fast".to_string())
|
||||
);
|
||||
|
||||
// === Jamba (AI21) ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "jamba-large-1-7", r),
|
||||
Some("ai21/jamba-large-1.7".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "databricks-jamba-large-1-7", r),
|
||||
Some("ai21/jamba-large-1.7".to_string())
|
||||
);
|
||||
|
||||
// === Cohere Command ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "command-r-plus-08", r),
|
||||
Some("cohere/command-r-plus-08".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "goose-command-r-08", r),
|
||||
Some("cohere/command-r-08".to_string())
|
||||
);
|
||||
|
||||
// === Provider-prefixed extraction ===
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "anthropic-claude-3-5-sonnet", r),
|
||||
Some("anthropic/claude-3.5-sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "openai-gpt-4o", r),
|
||||
Some("openai/gpt-4o".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "google-gemini-2-5-flash", r),
|
||||
Some("google/gemini-2.5-flash".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "mistralai-mistral-large", r),
|
||||
Some("mistralai/mistral-large".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "deepseek-deepseek-chat", r),
|
||||
Some("deepseek/deepseek-chat".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "qwen-qwen-2-5-72b-instruct", r),
|
||||
Some("qwen/qwen-2.5-72b-instruct".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_canonical_model("databricks", "x-ai-grok-3", r),
|
||||
Some("x-ai/grok-3".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use super::CanonicalModel;
|
||||
use anyhow::{Context, Result};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Cached bundled canonical model registry
|
||||
static BUNDLED_REGISTRY: Lazy<Result<CanonicalModelRegistry>> = Lazy::new(|| {
|
||||
const CANONICAL_MODELS_JSON: &str = include_str!("data/canonical_models.json");
|
||||
|
||||
let models: Vec<CanonicalModel> = serde_json::from_str(CANONICAL_MODELS_JSON)
|
||||
.context("Failed to parse bundled canonical models JSON")?;
|
||||
|
||||
let mut registry = CanonicalModelRegistry::new();
|
||||
for model in models {
|
||||
registry.register(model);
|
||||
}
|
||||
|
||||
Ok(registry)
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CanonicalModelRegistry {
|
||||
models: HashMap<String, CanonicalModel>,
|
||||
}
|
||||
|
||||
impl CanonicalModelRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
models: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bundled() -> Result<&'static Self> {
|
||||
BUNDLED_REGISTRY
|
||||
.as_ref()
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||
}
|
||||
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.context("Failed to read canonical models file")?;
|
||||
|
||||
let models: Vec<CanonicalModel> =
|
||||
serde_json::from_str(&content).context("Failed to parse canonical models JSON")?;
|
||||
|
||||
let mut registry = Self::new();
|
||||
for model in models {
|
||||
registry.register(model);
|
||||
}
|
||||
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
pub fn to_file(&self, path: impl AsRef<Path>) -> Result<()> {
|
||||
let mut models: Vec<&CanonicalModel> = self.models.values().collect();
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
let json = serde_json::to_string_pretty(&models)
|
||||
.context("Failed to serialize canonical models")?;
|
||||
|
||||
std::fs::write(path.as_ref(), json).context("Failed to write canonical models file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register(&mut self, model: CanonicalModel) {
|
||||
self.models.insert(model.id.clone(), model);
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&CanonicalModel> {
|
||||
self.models.get(name)
|
||||
}
|
||||
|
||||
pub fn all_models(&self) -> Vec<&CanonicalModel> {
|
||||
self.models.values().collect()
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.models.len()
|
||||
}
|
||||
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.models.contains_key(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanonicalModelRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod azure;
|
||||
pub mod azureauth;
|
||||
pub mod base;
|
||||
pub mod bedrock;
|
||||
pub mod canonical;
|
||||
pub mod claude_code;
|
||||
pub mod cursor_agent;
|
||||
pub mod databricks;
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ProviderModelsResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches models for all active providers in parallel.
|
||||
* Fetches recommended models for all active providers in parallel.
|
||||
* Falls back to known_models if fetching fails or returns no models.
|
||||
*/
|
||||
export async function fetchModelsForProviders(
|
||||
|
||||
@@ -197,7 +197,7 @@ export const SwitchModelModal = ({
|
||||
|
||||
setLoadingModels(true);
|
||||
|
||||
// Fetching models for all providers
|
||||
// Fetching models for all providers (always recommended)
|
||||
const results = await fetchModelsForProviders(activeProviders, getProviderModels);
|
||||
|
||||
// Process results and build grouped options
|
||||
|
||||
Reference in New Issue
Block a user