Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
54ae4f5
First pass
katzdave Nov 11, 2025
a72ba12
v2
katzdave Nov 11, 2025
f180860
data fix
katzdave Nov 11, 2025
1f86c03
Big refactor
katzdave Nov 11, 2025
c0c970f
Swap to openrouter approach
katzdave Nov 11, 2025
614e5b0
Extra filtering/merging
katzdave Nov 12, 2025
239743a
rm varieants
katzdave Nov 12, 2025
dd5c916
more mapping improvements
katzdave Nov 12, 2025
0b442b3
more parsing fixes
katzdave Nov 12, 2025
2233f11
del old script
katzdave Nov 13, 2025
9662095
move models
katzdave Nov 13, 2025
0d3948c
atest mapping run
katzdave Nov 13, 2025
5ac213e
fix exacto bug
katzdave Nov 13, 2025
aad353f
rm useless reports
katzdave Nov 13, 2025
894e979
pick up gpt5
katzdave Nov 13, 2025
1a80ee1
Cleanup diff script
katzdave Nov 14, 2025
19d65b5
nuke a bunch of comments
katzdave Nov 14, 2025
38134bb
more cleanup
katzdave Nov 14, 2025
20806bb
fmt
katzdave Nov 14, 2025
ae23e1d
fix clippy
katzdave Nov 15, 2025
b14f89f
Merge branch 'main' of github.com:block/goose into dkatz/canonical-model
katzdave Dec 9, 2025
2db336a
refetch canonical models
katzdave Dec 9, 2025
01ddf1e
mapping changes audit
katzdave Dec 9, 2025
74a4746
Fix input modalities
katzdave Dec 10, 2025
43ae0d3
fuzzy matching and pricing comparison
katzdave Dec 10, 2025
413f41f
add meta
katzdave Dec 10, 2025
cb4442e
mistra look goods
katzdave Dec 10, 2025
b89306a
grok + beta filtering
katzdave Dec 10, 2025
8b431d6
Deepseek
katzdave Dec 10, 2025
494551f
add cohrere
katzdave Dec 10, 2025
43224b6
add jamba
katzdave Dec 10, 2025
3817601
add qwen
katzdave Dec 10, 2025
ffacaf4
yi isnt there
katzdave Dec 10, 2025
5671b46
moappring rerun
katzdave Dec 10, 2025
e43cdf1
more parsing improvements
katzdave Dec 10, 2025
deca713
Merge branch 'main' of github.com:block/goose into dkatz/canonical-model
katzdave Dec 10, 2025
c406cf3
show only recommendeded
katzdave Dec 11, 2025
be5aa8f
fix env var
katzdave Dec 11, 2025
b6c3f53
Speedup with tokio
katzdave Dec 11, 2025
c7390ee
rm temp tests
katzdave Dec 11, 2025
292498b
fmt
katzdave Dec 11, 2025
6c9a1ee
copilot suggesttions. REgex impactful
katzdave Dec 11, 2025
b6949ee
rm complex parallel code
katzdave Dec 11, 2025
946ab6a
clean up pt 1 + rerun checker
katzdave Dec 11, 2025
167c207
cleanup pt 2
katzdave Dec 11, 2025
a32d670
Cleanup pt 3 name builder
katzdave Dec 11, 2025
ed42475
fmt
katzdave Dec 11, 2025
56961a3
fix clippy
katzdave Dec 11, 2025
877dba0
rebuild
katzdave Dec 12, 2025
d743598
rm bool flag, force show recommended
katzdave Dec 12, 2025
0b6fa4a
minior clean
katzdave Dec 12, 2025
01e0024
Clean up regex and redundant checks
katzdave Dec 12, 2025
a077db5
fuse tests
katzdave Dec 12, 2025
c77bf78
fmt
katzdave Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,413 changes: 4,413 additions & 0 deletions crates/goose/canonical_mapping_report.json

Large diffs are not rendered by default.

247 changes: 247 additions & 0 deletions crates/goose/examples/build_canonical_models.rs
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();

Copilot AI Dec 10, 2025

Copy link

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 an id field. Use proper error handling with context: model["id"].as_str().context("Model missing id field")?.

Suggested change
let id = model["id"].as_str().unwrap();
let id = model["id"].as_str().context("Model missing id field")?;

Copilot uses AI. Check for mistakes.
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(),

Copilot AI Dec 10, 2025

Copy link

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 an id field. Use proper error handling with context: existing_model["id"].as_str().context("Model missing id field")?.

Suggested change
existing_model["id"].as_str().unwrap(),
existing_model["id"].as_str().context("Model missing id field")?,

Copilot uses AI. Check for mistakes.
existing_has_paid,
id,
has_paid_pricing
);
if name.len() >= existing_name.len() {
} else {
shortest_names.insert(canonical_id.clone(), name.to_string());
}

Copilot AI Dec 10, 2025

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
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);
}
}

// 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("supported_parameters")
.and_then(|v| v.as_array())
.map(|arr| {
let mut mods = vec!["text".to_string()];
for param in arr {
if let Some(s) = param.as_str() {
match s {
"image" | "image_url" => {
if !mods.contains(&"image".to_string()) {
mods.push("image".to_string());
}
}
"audio" => {
if !mods.contains(&"audio".to_string()) {
mods.push("audio".to_string());
}
}
"video" => {
if !mods.contains(&"video".to_string()) {
mods.push("video".to_string());
}
}
_ => {}
}
}
}
if model
.get("architecture")
.and_then(|a| a.get("multimodality"))
.is_some()
&& !mods.contains(&"file".to_string())
{
mods.push("file".to_string());
}
mods
})
.unwrap_or_else(|| vec!["text".to_string()]);

let output_modalities = vec!["text".to_string()];

let tokenizer = if canonical_id.starts_with("anthropic/") {
"Claude"
} else if canonical_id.starts_with("openai/") {
"GPT"
} else if canonical_id.starts_with("google/") {
"Gemini"
} else {
"Unknown"
}
.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").unwrap();

Copilot AI Dec 10, 2025

Copy link

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")?.

Suggested change
let pricing_obj = model.get("pricing").unwrap();
let pricing_obj = model
.get("pricing")
.context("Model missing pricing field")?;

Copilot uses AI. Check for mistakes.
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,
tokenizer,
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(())
}
Loading
Loading