-
Notifications
You must be signed in to change notification settings - Fork 5.9k
Canonical models for Providers #5694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 20 commits
54ae4f5
a72ba12
f180860
1f86c03
c0c970f
614e5b0
239743a
dd5c916
0b442b3
2233f11
9662095
0d3948c
5ac213e
aad353f
894e979
1a80ee1
19d65b5
38134bb
20806bb
ae23e1d
b14f89f
2db336a
01ddf1e
74a4746
43ae0d3
413f41f
cb4442e
b89306a
8b431d6
494551f
43224b6
3817601
ffacaf4
5671b46
e43cdf1
deca713
c406cf3
be5aa8f
b6c3f53
c7390ee
292498b
6c9a1ee
b6949ee
946ab6a
167c207
a32d670
ed42475
56961a3
877dba0
d743598
0b6fa4a
01e0024
a077db5
c77bf78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,247 @@ | ||||||||||||
| /// 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"]; | ||||||||||||
|
|
||||||||||||
| #[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().unwrap_or(id); | ||||||||||||
|
|
||||||||||||
| // 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_model["id"].as_str().unwrap(), | |
| existing_model["id"].as_str().context("Model missing id field")?, |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic is incorrect. When should_replace is true and we're updating the model, we should always update shortest_names if we're replacing. The condition if name.len() >= existing_name.len() with an empty block is dead code. This should be: shortest_names.insert(canonical_id.clone(), name.to_string()); without the condition, since we already determined we should replace.
| if name.len() >= existing_name.len() { | |
| } else { | |
| shortest_names.insert(canonical_id.clone(), name.to_string()); | |
| } | |
| shortest_names.insert(canonical_id.clone(), name.to_string()); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.unwrap() will panic if a model is missing the pricing field. Use proper error handling: model.get("pricing").context("Model missing pricing field")?.
| let pricing_obj = model.get("pricing").unwrap(); | |
| let pricing_obj = model | |
| .get("pricing") | |
| .context("Model missing pricing field")?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.unwrap()will panic if the API returns a model without anidfield. Use proper error handling with context:model["id"].as_str().context("Model missing id field")?.