diff --git a/src/client/catalog.rs b/src/client/catalog.rs index 27e0cda..4d18162 100644 --- a/src/client/catalog.rs +++ b/src/client/catalog.rs @@ -1,17 +1,549 @@ -use super::model::ModelData; +use super::model::{ModelData, ProviderModels}; use super::oauth::OAuthConfig; +use crate::utils::fetch; + +use anyhow::{Result, bail}; use fancy_regex::Regex; use log::warn; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use std::sync::LazyLock; pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml"); +#[allow(dead_code)] pub static ALL_QUIRKS: LazyLock> = LazyLock::new(|| serde_yaml::from_str(QUIRKS_YAML).expect("quirks.yaml must be valid")); +const MODELSDEV_API_URL: &str = "https://models.dev/api.json"; +const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/models"; + +/// Upstream ids for one coyote provider. The table order is the fixed output +/// order of the built catalog. Providers with neither upstream id are fully +/// quirks-owned; `openrouter` is special-cased in the merge: its models come +/// from the OpenRouter list natively instead of via a prefix join. +struct SourceIds { + provider: &'static str, + modelsdev: Option<&'static str>, + openrouter: Option<&'static str>, +} + +const PROVIDER_SOURCES: [SourceIds; 24] = [ + SourceIds { + provider: "openai", + modelsdev: Some("openai"), + openrouter: Some("openai"), + }, + SourceIds { + provider: "gemini", + modelsdev: Some("google"), + openrouter: Some("google"), + }, + SourceIds { + provider: "claude", + modelsdev: Some("anthropic"), + openrouter: Some("anthropic"), + }, + SourceIds { + provider: "mistral", + modelsdev: Some("mistral"), + openrouter: Some("mistralai"), + }, + SourceIds { + provider: "ai21", + modelsdev: None, + openrouter: None, + }, + SourceIds { + provider: "cohere", + modelsdev: Some("cohere"), + openrouter: Some("cohere"), + }, + SourceIds { + provider: "xai", + modelsdev: Some("xai"), + openrouter: Some("x-ai"), + }, + SourceIds { + provider: "perplexity", + modelsdev: Some("perplexity"), + openrouter: Some("perplexity"), + }, + SourceIds { + provider: "groq", + modelsdev: Some("groq"), + openrouter: Some("groq"), + }, + SourceIds { + provider: "vertexai", + modelsdev: Some("google-vertex"), + openrouter: Some("google"), + }, + SourceIds { + provider: "bedrock", + modelsdev: Some("amazon-bedrock"), + openrouter: Some("amazon"), + }, + SourceIds { + provider: "cloudflare", + modelsdev: Some("cloudflare-workers-ai"), + openrouter: None, + }, + SourceIds { + provider: "ernie", + modelsdev: None, + openrouter: None, + }, + SourceIds { + provider: "qianwen", + modelsdev: Some("alibaba"), + openrouter: Some("qwen"), + }, + SourceIds { + provider: "hunyuan", + modelsdev: None, + openrouter: None, + }, + SourceIds { + provider: "moonshot", + modelsdev: Some("moonshotai"), + openrouter: Some("moonshotai"), + }, + SourceIds { + provider: "deepseek", + modelsdev: Some("deepseek"), + openrouter: Some("deepseek"), + }, + SourceIds { + provider: "zhipuai", + modelsdev: Some("zhipuai"), + openrouter: Some("zhipuai"), + }, + SourceIds { + provider: "minimax", + modelsdev: Some("minimax"), + openrouter: Some("minimax"), + }, + SourceIds { + provider: "openrouter", + modelsdev: None, + openrouter: None, + }, + SourceIds { + provider: "github", + modelsdev: Some("github-copilot"), + openrouter: Some("github"), + }, + SourceIds { + provider: "deepinfra", + modelsdev: Some("deepinfra"), + openrouter: None, + }, + SourceIds { + provider: "jina", + modelsdev: None, + openrouter: None, + }, + SourceIds { + provider: "voyageai", + modelsdev: None, + openrouter: None, + }, +]; + +/// Raw payloads from the two upstream catalogs. Each source is independently +/// fallible; `None` means that fetch failed and the merge degrades gracefully. +pub struct FetchedSources { + pub modelsdev: Option, + pub openrouter: Option, +} + +/// Fetches both upstream catalogs concurrently. A failed fetch is logged and +/// surfaces as `None` rather than an error. +#[allow(dead_code)] +pub async fn fetch_sources() -> FetchedSources { + let (modelsdev, openrouter) = tokio::join!(fetch(MODELSDEV_API_URL), fetch(OPENROUTER_API_URL)); + FetchedSources { + modelsdev: modelsdev + .inspect_err(|err| warn!("Failed to fetch {MODELSDEV_API_URL}: {err}")) + .ok(), + openrouter: openrouter + .inspect_err(|err| warn!("Failed to fetch {OPENROUTER_API_URL}: {err}")) + .ok(), + } +} + +/// An API-sourced model plus the release date used for ordering. The date is +/// deliberately kept out of [`ModelData`] and never serialized. +struct SourcedModel { + data: ModelData, + release_date: Option, +} + +fn clamp_input_tokens(value: u64) -> Option { + (1..=50_000_000).contains(&value).then_some(value as usize) +} + +fn clamp_output_tokens(value: i64) -> Option { + (1..=10_000_000).contains(&value).then_some(value as isize) +} + +fn clamp_price(value: f64) -> Option { + (0.0..=10_000.0).contains(&value).then_some(value) +} + +/// Extracts the models of one models.dev provider. Tolerant by design: +/// missing or malformed fields are skipped while the model is kept, and +/// out-of-range numbers are dropped field-wise. +fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec { + let Some(models) = api[provider_id]["models"].as_object() else { + return Vec::new(); + }; + models + .iter() + .map(|(name, entry)| { + let mut data = ModelData::new(name); + data.max_input_tokens = entry["limit"]["context"] + .as_u64() + .and_then(clamp_input_tokens); + data.max_output_tokens = entry["limit"]["output"] + .as_i64() + .and_then(clamp_output_tokens); + data.input_price = entry["cost"]["input"].as_f64().and_then(clamp_price); + data.output_price = entry["cost"]["output"].as_f64().and_then(clamp_price); + data.supports_vision = entry["modalities"]["input"] + .as_array() + .is_some_and(|modes| modes.iter().any(|mode| mode.as_str() == Some("image"))); + data.supports_function_calling = entry["tool_call"].as_bool().unwrap_or(false); + data.reasoning_levels = entry["reasoning_options"] + .as_array() + .and_then(|options| { + options + .iter() + .find(|option| option["type"].as_str() == Some("effort")) + }) + .and_then(|option| option["values"].as_array()) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let release_date = entry["release_date"].as_str().map(str::to_string); + SourcedModel { data, release_date } + }) + .collect() +} + +/// OpenRouter prices are USD per token; the catalog stores USD per million +/// tokens, rounded to 4 decimals. +fn openrouter_price(value: &Value) -> Option { + let per_token: f64 = value.as_str()?.parse().ok()?; + clamp_price((per_token * 1_000_000.0 * 10_000.0).round() / 10_000.0) +} + +/// Maps one OpenRouter list entry onto [`ModelData`], with the same field-wise +/// tolerance and clamping as the models.dev adapter. +fn openrouter_model_data(name: &str, entry: &Value) -> ModelData { + let mut data = ModelData::new(name); + data.max_input_tokens = entry["context_length"] + .as_u64() + .and_then(clamp_input_tokens); + data.max_output_tokens = entry["top_provider"]["max_completion_tokens"] + .as_i64() + .and_then(clamp_output_tokens); + data.input_price = openrouter_price(&entry["pricing"]["prompt"]); + data.output_price = openrouter_price(&entry["pricing"]["completion"]); + data.supports_vision = entry["architecture"]["modality"] + .as_str() + .is_some_and(|modality| modality.contains("image")); + data.supports_function_calling = entry["supported_parameters"] + .as_array() + .is_some_and(|params| params.iter().any(|param| param.as_str() == Some("tools"))); + data +} + +fn openrouter_index(payload: &Value) -> HashMap<&str, &Value> { + payload["data"] + .as_array() + .map(|entries| { + entries + .iter() + .filter_map(|entry| Some((entry["id"].as_str()?, entry))) + .collect() + }) + .unwrap_or_default() +} + +/// Every OpenRouter list entry as a model of the `openrouter` provider, named +/// by its full id. OpenRouter publishes no release date, so ordering falls +/// back to name-ascending. +fn openrouter_native_models(payload: &Value) -> Vec { + payload["data"] + .as_array() + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let id = entry["id"].as_str()?; + Some(SourcedModel { + data: openrouter_model_data(id, entry), + release_date: None, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Fills fields that models.dev left unset on the same model: OpenRouter data +/// never overrides a present models.dev value. +fn fill_from_openrouter(data: &mut ModelData, entry: &Value) { + let filler = openrouter_model_data(&data.name, entry); + if data.max_input_tokens.is_none() { + data.max_input_tokens = filler.max_input_tokens; + } + if data.max_output_tokens.is_none() { + data.max_output_tokens = filler.max_output_tokens; + } + if data.input_price.is_none() { + data.input_price = filler.input_price; + } + if data.output_price.is_none() { + data.output_price = filler.output_price; + } + if !data.supports_vision { + data.supports_vision = filler.supports_vision; + } + if !data.supports_function_calling { + data.supports_function_calling = filler.supports_function_calling; + } +} + +/// Outcome of a catalog merge. `degraded` lists API-mapped providers that fell +/// back to their hand-owned quirks models because API data was unavailable or +/// empty; `failed` lists providers that emitted nothing at all. +pub struct CatalogBuild { + pub providers: Vec, + pub degraded: Vec, + pub failed: Vec, +} + +/// Merges API-sourced model data with hand-owned quirks into the full +/// provider catalog, in the fixed provider order of [`PROVIDER_SOURCES`]. +/// +/// Per provider: API models (models.dev primary, OpenRouter gap-fill) are +/// curated by `exclude` globs and `rules`, hand-owned `models:` entries are +/// merged in field-wise (hand-owned wins) and placed first, API models are +/// sorted newest-first by release date (name-ascending tiebreak, undated +/// last), and `variants` clones are inserted immediately after their base +/// model. Providers without usable data are routed to `degraded` or `failed` +/// instead of erroring; a provider never emits zero models. +pub fn build_catalog(sources: &FetchedSources, quirks: &[ProviderQuirks]) -> CatalogBuild { + let modelsdev: Option = sources + .modelsdev + .as_deref() + .and_then(|payload| serde_json::from_str(payload).ok()); + let openrouter: Option = sources + .openrouter + .as_deref() + .and_then(|payload| serde_json::from_str(payload).ok()); + let or_index = openrouter + .as_ref() + .map(openrouter_index) + .unwrap_or_default(); + + let mut providers = Vec::new(); + let mut degraded = Vec::new(); + let mut failed = Vec::new(); + + for source in &PROVIDER_SOURCES { + let provider_quirks = quirks_for(quirks, source.provider); + let api_mapped = source.provider == "openrouter" || source.modelsdev.is_some(); + + let mut api_models: Vec = if source.provider == "openrouter" { + openrouter + .as_ref() + .map(openrouter_native_models) + .unwrap_or_default() + } else if let (Some(id), Some(api)) = (source.modelsdev, modelsdev.as_ref()) { + let mut models = modelsdev_provider_models(api, id); + if let Some(prefix) = source.openrouter { + for model in &mut models { + if let Some(entry) = + or_index.get(format!("{prefix}/{}", model.data.name).as_str()) + { + fill_from_openrouter(&mut model.data, entry); + } + } + } + models + } else { + Vec::new() + }; + + if let Some(provider_quirks) = provider_quirks { + api_models.retain(|model| { + !provider_quirks + .exclude + .iter() + .any(|pattern| glob_matches(pattern, &model.data.name)) + }); + for model in &mut api_models { + apply_rules(provider_quirks, &mut model.data); + } + } + + let hand_owned = provider_quirks + .map(|provider_quirks| provider_quirks.models.clone()) + .unwrap_or_default(); + + if api_models.is_empty() { + if hand_owned.is_empty() { + failed.push(source.provider.to_string()); + continue; + } + if api_mapped { + degraded.push(source.provider.to_string()); + } + } + + let mut merged_hand = Vec::with_capacity(hand_owned.len()); + for hand in hand_owned { + match api_models + .iter() + .position(|model| model.data.name == hand.name) + { + Some(position) => { + let api = api_models.remove(position); + merged_hand.push(overlay_hand_owned(hand, api.data)); + } + None => merged_hand.push(hand), + } + } + + api_models.sort_by(|a, b| match (&a.release_date, &b.release_date) { + (Some(a_date), Some(b_date)) => b_date + .cmp(a_date) + .then_with(|| a.data.name.cmp(&b.data.name)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.data.name.cmp(&b.data.name), + }); + + let bases = merged_hand + .into_iter() + .chain(api_models.into_iter().map(|model| model.data)); + + let variants = provider_quirks + .map(|provider_quirks| provider_quirks.variants.as_slice()) + .unwrap_or_default(); + let mut models = Vec::new(); + for base in bases { + let mut clones = Vec::new(); + for variant in variants { + if !glob_matches(&variant.r#match, &base.name) { + continue; + } + let mut clone = base.clone(); + clone.name = format!("{}{}", base.name, variant.suffix); + // Without an explicit real_name the clone would send its + // suffixed name on the wire; default to the base's wire name. + if clone.real_name.is_none() { + clone.real_name = Some(base.name.clone()); + } + apply_variant(variant, &mut clone); + clones.push(clone); + } + models.push(base); + models.append(&mut clones); + } + + providers.push(ProviderModels { + provider: source.provider.to_string(), + oauth: provider_quirks.and_then(|provider_quirks| provider_quirks.oauth.clone()), + models, + }); + } + + CatalogBuild { + providers, + degraded, + failed, + } +} + +/// Like [`build_catalog`], but errors unless every provider was assembled +/// from live API data (or is quirks-owned by design). +#[allow(dead_code)] +pub fn build_catalog_strict( + sources: &FetchedSources, + quirks: &[ProviderQuirks], +) -> Result> { + let build = build_catalog(sources, quirks); + if !build.degraded.is_empty() || !build.failed.is_empty() { + bail!( + "model catalog build incomplete: degraded providers [{}], failed providers [{}]", + build.degraded.join(", "), + build.failed.join(", ") + ); + } + Ok(build.providers) +} + +/// Fraction of `old` names that also appear in `new`; empty `old` yields 1.0. +/// Used to guard against upstream id-shape drift: a low overlap means the +/// upstream catalog renamed its models out from under us. +#[allow(dead_code)] +pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 { + if old.is_empty() { + return 1.0; + } + let new_names: std::collections::HashSet<&str> = new.iter().map(String::as_str).collect(); + let kept = old + .iter() + .filter(|name| new_names.contains(name.as_str())) + .count(); + kept as f64 / old.len() as f64 +} + +/// Overlays a hand-owned quirks entry onto the API-sourced model of the same +/// name. Every field the hand-owned entry carries (Some, true, or non-empty) +/// wins; absent fields keep the API value. `model_type` is authoritative on +/// the hand-owned entry. +fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData { + ModelData { + name: hand.name, + model_type: hand.model_type, + real_name: hand.real_name.or(api.real_name), + max_input_tokens: hand.max_input_tokens.or(api.max_input_tokens), + input_price: hand.input_price.or(api.input_price), + output_price: hand.output_price.or(api.output_price), + patch: hand.patch.or(api.patch), + max_output_tokens: hand.max_output_tokens.or(api.max_output_tokens), + require_max_tokens: hand.require_max_tokens || api.require_max_tokens, + supports_vision: hand.supports_vision || api.supports_vision, + supports_function_calling: hand.supports_function_calling || api.supports_function_calling, + reasoning_levels: if hand.reasoning_levels.is_empty() { + api.reasoning_levels + } else { + hand.reasoning_levels + }, + default_reasoning_effort: hand + .default_reasoning_effort + .or(api.default_reasoning_effort), + no_stream: hand.no_stream || api.no_stream, + no_system_message: hand.no_system_message || api.no_system_message, + system_prompt_prefix: hand.system_prompt_prefix.or(api.system_prompt_prefix), + max_tokens_per_chunk: hand.max_tokens_per_chunk.or(api.max_tokens_per_chunk), + default_chunk_size: hand.default_chunk_size.or(api.default_chunk_size), + max_batch_size: hand.max_batch_size.or(api.max_batch_size), + } +} + /// Hand-owned catalog overrides for a single provider: oauth configuration, /// curation excludes, field overlays for API-sourced models, suffixed model /// variants, and fully hand-owned model entries. @@ -109,7 +641,6 @@ pub struct QuirkVariant { /// Applies every rule whose glob matches `data.name`, in file order; later /// rules overwrite earlier ones on the same field. -#[allow(dead_code)] pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) { for rule in &provider_quirks.rules { if !glob_matches(&rule.r#match, &data.name) { @@ -160,16 +691,65 @@ pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) { } } +/// Overlays a variant's explicitly present fields onto a cloned model; same +/// field semantics as [`apply_rules`]. +fn apply_variant(variant: &QuirkVariant, data: &mut ModelData) { + if let Some(real_name) = &variant.real_name { + data.real_name = Some(real_name.clone()); + } + if let Some(max_input_tokens) = variant.max_input_tokens { + data.max_input_tokens = Some(max_input_tokens); + } + if let Some(max_output_tokens) = variant.max_output_tokens { + data.max_output_tokens = Some(max_output_tokens); + } + if let Some(input_price) = variant.input_price { + data.input_price = Some(input_price); + } + if let Some(output_price) = variant.output_price { + data.output_price = Some(output_price); + } + if let Some(patch) = &variant.patch { + data.patch = Some(patch.clone()); + } + if let Some(require_max_tokens) = variant.require_max_tokens { + data.require_max_tokens = require_max_tokens; + } + if let Some(supports_vision) = variant.supports_vision { + data.supports_vision = supports_vision; + } + if let Some(supports_function_calling) = variant.supports_function_calling { + data.supports_function_calling = supports_function_calling; + } + if let Some(reasoning_levels) = &variant.reasoning_levels { + data.reasoning_levels = reasoning_levels.clone(); + } + if let Some(default_reasoning_effort) = &variant.default_reasoning_effort { + data.default_reasoning_effort = Some(default_reasoning_effort.clone()); + } + if let Some(no_stream) = variant.no_stream { + data.no_stream = no_stream; + } + if let Some(no_system_message) = variant.no_system_message { + data.no_system_message = no_system_message; + } + if let Some(system_prompt_prefix) = &variant.system_prompt_prefix { + data.system_prompt_prefix = Some(system_prompt_prefix.clone()); + } +} + /// Finds the quirks for a client: an exact provider match wins, otherwise a /// client whose name starts with the provider name (openai-compatible clients /// are conventionally named after the provider they wrap). -#[allow(dead_code)] -pub fn quirks_for(client_name: &str) -> Option<&'static ProviderQuirks> { - ALL_QUIRKS +pub fn quirks_for<'a>( + quirks: &'a [ProviderQuirks], + client_name: &str, +) -> Option<&'a ProviderQuirks> { + quirks .iter() .find(|quirks| quirks.provider == client_name) .or_else(|| { - ALL_QUIRKS + quirks .iter() .find(|quirks| client_name.starts_with(&quirks.provider)) }) @@ -198,6 +778,8 @@ fn compile_glob(pattern: &str) -> Regex { mod tests { use super::*; + use serde_json::json; + const KNOWN_PROVIDERS: [&str; 24] = [ "openai", "gemini", @@ -225,6 +807,40 @@ mod tests { "voyageai", ]; + const MODELSDEV_FIXTURE: &str = include_str!("testdata/modelsdev_fixture.json"); + const OPENROUTER_FIXTURE: &str = include_str!("testdata/openrouter_fixture.json"); + + fn make_sources(modelsdev: Option<&str>, openrouter: Option<&str>) -> FetchedSources { + FetchedSources { + modelsdev: modelsdev.map(str::to_string), + openrouter: openrouter.map(str::to_string), + } + } + + fn fixture_sources() -> FetchedSources { + make_sources(Some(MODELSDEV_FIXTURE), Some(OPENROUTER_FIXTURE)) + } + + fn parse_quirks(yaml: &str) -> Vec { + serde_yaml::from_str(yaml).unwrap() + } + + fn find_provider<'a>(build: &'a CatalogBuild, provider: &str) -> &'a ProviderModels { + build + .providers + .iter() + .find(|candidate| candidate.provider == provider) + .unwrap_or_else(|| panic!("provider '{provider}' missing")) + } + + fn find_model<'a>(provider: &'a ProviderModels, name: &str) -> &'a ModelData { + provider + .models + .iter() + .find(|model| model.name == name) + .unwrap_or_else(|| panic!("model '{name}' missing")) + } + #[test] fn quirks_yaml_parses() { assert!(!ALL_QUIRKS.is_empty()); @@ -237,6 +853,15 @@ mod tests { } } + #[test] + fn provider_sources_match_known_providers() { + let order: Vec<&str> = PROVIDER_SOURCES + .iter() + .map(|source| source.provider) + .collect(); + assert_eq!(order, KNOWN_PROVIDERS); + } + #[test] fn quirks_models_no_dropped_keys() { let skipped_when_default = [ @@ -324,4 +949,478 @@ rules: assert!(glob_matches("o3", "o3")); assert!(!glob_matches("o3", "o3-mini")); } + + #[test] + fn modelsdev_adapter_maps_fields() { + let api: Value = serde_json::from_str(MODELSDEV_FIXTURE).unwrap(); + + let openai = modelsdev_provider_models(&api, "openai"); + let gpt56 = openai + .iter() + .find(|model| model.data.name == "gpt-5.6") + .unwrap(); + assert_eq!(gpt56.data.max_input_tokens, Some(1_050_000)); + assert_eq!(gpt56.data.max_output_tokens, Some(128_000)); + assert_eq!(gpt56.data.input_price, Some(4.0)); + assert_eq!(gpt56.data.output_price, Some(20.0)); + assert!(gpt56.data.supports_vision); + assert!(gpt56.data.supports_function_calling); + assert_eq!( + gpt56.data.reasoning_levels, + ["none", "low", "medium", "high", "xhigh", "max"] + ); + assert_eq!(gpt56.release_date.as_deref(), Some("2026-07-09")); + + let alibaba = modelsdev_provider_models(&api, "alibaba"); + assert_eq!(alibaba.len(), 1); + let qwen = &alibaba[0]; + assert_eq!(qwen.data.name, "qwen-max"); + assert_eq!(qwen.data.max_input_tokens, Some(32_768)); + assert_eq!(qwen.data.max_output_tokens, Some(8_192)); + assert_eq!(qwen.data.input_price, Some(1.6)); + assert_eq!(qwen.data.output_price, Some(6.4)); + assert!(!qwen.data.supports_vision); + assert!(qwen.data.supports_function_calling); + assert!(qwen.data.reasoning_levels.is_empty()); + assert_eq!(qwen.release_date.as_deref(), Some("2024-04-03")); + + assert!(modelsdev_provider_models(&api, "no-such-provider").is_empty()); + } + + #[test] + fn openrouter_adapter_maps_fields() { + let payload: Value = serde_json::from_str(OPENROUTER_FIXTURE).unwrap(); + let index = openrouter_index(&payload); + + let data = openrouter_model_data("gpt-4.1", index["openai/gpt-4.1"]); + assert_eq!(data.max_input_tokens, Some(1_047_576)); + assert_eq!(data.max_output_tokens, Some(32_768)); + assert_eq!(data.input_price, Some(2.0)); + assert_eq!(data.output_price, Some(8.0)); + assert!(data.supports_vision); + assert!(data.supports_function_calling); + + let rounded = openrouter_model_data( + "m", + &json!({"pricing": {"prompt": "0.00000123456789", "completion": "0.0000025"}}), + ); + assert_eq!(rounded.input_price, Some(1.2346)); + assert_eq!(rounded.output_price, Some(2.5)); + } + + #[test] + fn clamps_drop_out_of_range_fields() { + let api = json!({ + "p": {"models": {"m": { + "limit": {"context": 100_000_000, "output": 0}, + "cost": {"input": -1.0, "output": 20_000.0}, + }}} + }); + let models = modelsdev_provider_models(&api, "p"); + assert_eq!(models.len(), 1); + let data = &models[0].data; + assert_eq!(data.name, "m"); + assert_eq!(data.max_input_tokens, None); + assert_eq!(data.max_output_tokens, None); + assert_eq!(data.input_price, None); + assert_eq!(data.output_price, None); + + let data = openrouter_model_data( + "m", + &json!({ + "context_length": 60_000_000, + "top_provider": {"max_completion_tokens": 20_000_000}, + "pricing": {"prompt": "-0.000001", "completion": "999"}, + }), + ); + assert_eq!(data.max_input_tokens, None); + assert_eq!(data.max_output_tokens, None); + assert_eq!(data.input_price, None); + assert_eq!(data.output_price, None); + } + + #[test] + fn adapters_never_set_quirks_owned_fields() { + let api: Value = serde_json::from_str(MODELSDEV_FIXTURE).unwrap(); + let payload: Value = serde_json::from_str(OPENROUTER_FIXTURE).unwrap(); + let mut all: Vec = ["openai", "anthropic", "xai", "alibaba"] + .iter() + .flat_map(|id| modelsdev_provider_models(&api, id)) + .collect(); + all.extend(openrouter_native_models(&payload)); + assert!(!all.is_empty()); + for model in &all { + let data = &model.data; + assert_eq!(data.model_type, "chat", "{}", data.name); + assert!(data.patch.is_none(), "{}", data.name); + assert!(data.real_name.is_none(), "{}", data.name); + assert!(data.system_prompt_prefix.is_none(), "{}", data.name); + assert!(data.max_tokens_per_chunk.is_none(), "{}", data.name); + assert!(data.default_chunk_size.is_none(), "{}", data.name); + assert!(data.max_batch_size.is_none(), "{}", data.name); + } + } + + #[test] + fn oauth_comes_only_from_quirks() { + let without_quirks = build_catalog(&fixture_sources(), &[]); + assert!( + without_quirks + .providers + .iter() + .all(|provider| provider.oauth.is_none()) + ); + + let quirks = parse_quirks( + r#" +- provider: xai + oauth: + client_id: id + token_url: https://example.com/token +"#, + ); + let build = build_catalog(&fixture_sources(), &quirks); + assert!(find_provider(&build, "xai").oauth.is_some()); + assert!(find_provider(&build, "openai").oauth.is_none()); + } + + #[test] + fn merge_precedence_hand_owned_beats_rule_beats_modelsdev_beats_openrouter() { + let modelsdev = json!({ + "xai": {"models": { + "m-1": {"limit": {"context": 1000}, "cost": {"input": 1.0}}, + "m-2": {"limit": {"context": 1000}}, + }} + }) + .to_string(); + let openrouter = json!({"data": [ + { + "id": "x-ai/m-1", + "context_length": 2000, + "pricing": {"prompt": "0.000003", "completion": "0.000004"}, + "top_provider": {"max_completion_tokens": 512}, + }, + {"id": "x-ai/m-2", "context_length": 2000}, + ]}) + .to_string(); + let quirks = parse_quirks( + r#" +- provider: xai + rules: + - match: "m-1" + input_price: 5 + max_input_tokens: 3000 + models: + - name: m-1 + input_price: 7 +"#, + ); + let build = build_catalog(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks); + let xai = find_provider(&build, "xai"); + + let merged = find_model(xai, "m-1"); + // hand-owned beats the rule, which beat models.dev, which beat openrouter + assert_eq!(merged.input_price, Some(7.0)); + // rule beats models.dev + assert_eq!(merged.max_input_tokens, Some(3000)); + // openrouter fills fields models.dev left unset + assert_eq!(merged.output_price, Some(4.0)); + assert_eq!(merged.max_output_tokens, Some(512)); + + // models.dev beats openrouter on the same field + assert_eq!(find_model(xai, "m-2").max_input_tokens, Some(1000)); + } + + #[test] + fn excludes_drop_api_models_but_never_hand_owned() { + let quirks = parse_quirks( + r#" +- provider: openai + exclude: + - "gpt-4*" + models: + - name: gpt-4.1 + input_price: 9 +"#, + ); + let build = build_catalog(&fixture_sources(), &quirks); + let openai = find_provider(&build, "openai"); + assert!(openai.models.iter().all(|model| model.name != "gpt-4o")); + find_model(openai, "gpt-5.6"); + let hand = find_model(openai, "gpt-4.1"); + assert_eq!(hand.input_price, Some(9.0)); + // the API copy was excluded before the merge, so nothing leaked in + assert_eq!(hand.max_input_tokens, None); + } + + #[test] + fn variant_expansion() { + let quirks = parse_quirks( + r#" +- provider: xai + variants: + - match: "grok-4.6" + suffix: ":thinking" + max_output_tokens: 24000 + patch: + body: + thinking: true + - match: "grok-4.5" + suffix: ":fast" + real_name: grok-4.5-fast +"#, + ); + let build = build_catalog(&fixture_sources(), &quirks); + let xai = find_provider(&build, "xai"); + let names: Vec<&str> = xai.models.iter().map(|model| model.name.as_str()).collect(); + assert_eq!( + names, + ["grok-4.6", "grok-4.6:thinking", "grok-4.5", "grok-4.5:fast"] + ); + + let thinking = find_model(xai, "grok-4.6:thinking"); + assert_eq!(thinking.real_name.as_deref(), Some("grok-4.6")); + assert_eq!(thinking.max_output_tokens, Some(24_000)); + assert_eq!(thinking.patch, Some(json!({"body": {"thinking": true}}))); + + assert_eq!( + find_model(xai, "grok-4.5:fast").real_name.as_deref(), + Some("grok-4.5-fast") + ); + assert_eq!(find_model(xai, "grok-4.6").real_name, None); + } + + #[test] + fn ordering_hand_owned_first_then_release_date_desc_name_asc() { + let shuffled_payloads = [ + r#"{"openai":{"models":{ + "b":{"release_date":"2026-05-01"}, + "a":{"release_date":"2026-05-01"}, + "c":{"release_date":"2026-06-01"}, + "z":{}, + "y":{} + }}}"#, + r#"{"openai":{"models":{ + "y":{}, + "c":{"release_date":"2026-06-01"}, + "z":{}, + "a":{"release_date":"2026-05-01"}, + "b":{"release_date":"2026-05-01"} + }}}"#, + ]; + let quirks = parse_quirks( + r#" +- provider: openai + models: + - name: n2 + - name: n1 +"#, + ); + for payload in shuffled_payloads { + let build = build_catalog(&make_sources(Some(payload), None), &quirks); + let names: Vec<&str> = find_provider(&build, "openai") + .models + .iter() + .map(|model| model.name.as_str()) + .collect(); + assert_eq!(names, ["n2", "n1", "c", "a", "b", "y", "z"]); + } + } + + #[test] + fn catalog_round_trips_through_yaml() { + let build = build_catalog(&fixture_sources(), &ALL_QUIRKS); + assert!(!build.providers.is_empty()); + let yaml = serde_yaml::to_string(&build.providers).unwrap(); + let parsed: Vec = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml); + } + + #[test] + fn openrouter_native_models_use_full_ids() { + let build = build_catalog(&fixture_sources(), &[]); + let openrouter = find_provider(&build, "openrouter"); + assert_eq!(openrouter.models.len(), 8); + + let auto = find_model(openrouter, "openrouter/auto"); + assert_eq!(auto.max_input_tokens, Some(2_000_000)); + // null max_completion_tokens and the "-1" sentinel prices are dropped + assert_eq!(auto.max_output_tokens, None); + assert_eq!(auto.input_price, None); + assert_eq!(auto.output_price, None); + assert!(auto.supports_vision); + assert!(auto.supports_function_calling); + + let free = find_model(openrouter, "openrouter/free"); + assert_eq!(free.max_input_tokens, Some(200_000)); + assert_eq!(free.input_price, Some(0.0)); + assert_eq!(free.output_price, Some(0.0)); + + // no release dates upstream, so full ids sort ascending + let names: Vec<&str> = openrouter + .models + .iter() + .map(|model| model.name.as_str()) + .collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + } + + #[test] + fn failure_routing_degraded_and_failed() { + let quirks = parse_quirks( + r#" +- provider: gemini + models: [{name: g-1}] +- provider: ai21 + models: [{name: jamba}] +"#, + ); + let build = build_catalog(&make_sources(None, None), &quirks); + assert_eq!(build.degraded, ["gemini"]); + assert!(build.failed.contains(&"openai".to_string())); + assert!(build.failed.contains(&"openrouter".to_string())); + assert!(build.failed.contains(&"jina".to_string())); + assert!(!build.failed.contains(&"gemini".to_string())); + assert!(!build.failed.contains(&"ai21".to_string())); + assert!(!build.degraded.contains(&"ai21".to_string())); + + let providers: Vec<&str> = build + .providers + .iter() + .map(|provider| provider.provider.as_str()) + .collect(); + assert_eq!(providers, ["gemini", "ai21"]); + assert!( + build + .providers + .iter() + .all(|provider| !provider.models.is_empty()) + ); + } + + #[test] + fn strict_errors_on_degraded_or_failed() { + let quirks = parse_quirks( + r#" +- provider: gemini + models: [{name: g-1}] +"#, + ); + let err = build_catalog_strict(&make_sources(None, None), &quirks) + .unwrap_err() + .to_string(); + assert!(err.contains("gemini"), "{err}"); + assert!(err.contains("openai"), "{err}"); + } + + #[test] + fn strict_succeeds_when_every_provider_resolves() { + let mut api = serde_json::Map::new(); + for source in &PROVIDER_SOURCES { + if let Some(id) = source.modelsdev { + api.insert(id.to_string(), json!({"models": {"m": {}}})); + } + } + let modelsdev = Value::Object(api).to_string(); + let openrouter = json!({"data": [{"id": "openrouter/auto"}]}).to_string(); + let quirks = parse_quirks( + r#" +- provider: ai21 + models: [{name: a}] +- provider: ernie + models: [{name: e}] +- provider: hunyuan + models: [{name: h}] +- provider: jina + models: [{name: j}] +- provider: voyageai + models: [{name: v}] +"#, + ); + let providers = + build_catalog_strict(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks) + .unwrap(); + let order: Vec<&str> = providers + .iter() + .map(|provider| provider.provider.as_str()) + .collect(); + assert_eq!(order, KNOWN_PROVIDERS); + } + + #[test] + fn name_overlap_fraction_of_old_names_still_present() { + let old: Vec = ["a", "b", "c", "d"].map(str::to_string).into(); + let new: Vec = ["a", "c", "x"].map(str::to_string).into(); + assert_eq!(name_overlap(&old, &new), 0.5); + assert_eq!(name_overlap(&[], &new), 1.0); + assert_eq!(name_overlap(&old, &[]), 0.0); + } + + /// Guards against upstream id-shape drift by comparing live API model + /// names against the embedded models.yaml. Run manually: + /// `cargo test idshape_guard_live -- --ignored --nocapture` + #[tokio::test] + #[ignore] + async fn idshape_guard_live() { + let sources = fetch_sources().await; + let modelsdev: Value = serde_json::from_str( + sources + .modelsdev + .as_deref() + .expect("models.dev fetch failed"), + ) + .unwrap(); + let openrouter: Value = serde_json::from_str( + sources + .openrouter + .as_deref() + .expect("openrouter fetch failed"), + ) + .unwrap(); + let embedded: Vec = + serde_yaml::from_str(crate::client::MODELS_YAML).unwrap(); + + println!( + "{:<12} {:>7} {:>5} {:>5}", + "provider", "overlap", "old", "new" + ); + for source in &PROVIDER_SOURCES { + let new: Vec = if source.provider == "openrouter" { + openrouter_native_models(&openrouter) + .into_iter() + .map(|model| model.data.name) + .collect() + } else if let Some(id) = source.modelsdev { + modelsdev_provider_models(&modelsdev, id) + .into_iter() + .map(|model| model.data.name) + .collect() + } else { + continue; + }; + let old: Vec = embedded + .iter() + .find(|provider| provider.provider == source.provider) + .map(|provider| { + provider + .models + .iter() + .filter(|model| { + model.model_type == "chat" && !model.name.ends_with(":thinking") + }) + .map(|model| model.name.clone()) + .collect() + }) + .unwrap_or_default(); + println!( + "{:<12} {:>7.2} {:>5} {:>5}", + source.provider, + name_overlap(&old, &new), + old.len(), + new.len() + ); + } + } } diff --git a/src/client/testdata/modelsdev_fixture.json b/src/client/testdata/modelsdev_fixture.json new file mode 100644 index 0000000..d91242a --- /dev/null +++ b/src/client/testdata/modelsdev_fixture.json @@ -0,0 +1,457 @@ +{ + "openai": { + "id": "openai", + "env": [ + "OPENAI_API_KEY" + ], + "npm": "@ai-sdk/openai", + "name": "OpenAI", + "doc": "https://platform.openai.com/docs/models", + "models": { + "gpt-5.6": { + "id": "gpt-5.6", + "name": "GPT-5.6", + "description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows", + "family": "gpt-sol", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "tool_call": true, + "structured_output": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1050000, + "input": 922000, + "output": 128000 + }, + "experimental": { + "modes": { + "fast": { + "cost": { + "input": 8, + "output": 40, + "cache_read": 0.8, + "cache_write": 10 + }, + "provider": { + "body": { + "service_tier": "priority" + } + } + }, + "pro": { + "provider": { + "body": { + "reasoning": { + "mode": "pro" + } + } + } + } + } + }, + "cost": { + "input": 4, + "output": 20, + "cache_read": 0.4, + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10 + } + } + }, + "gpt-4.1": { + "id": "gpt-4.1", + "name": "GPT-4.1", + "description": "Long-lived GPT workhorse for coding, instruction following, and production apps", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "structured_output": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1047576, + "output": 32768 + }, + "cost": { + "input": 2, + "output": 8, + "cache_read": 0.5 + } + }, + "gpt-4o": { + "id": "gpt-4o", + "name": "GPT-4o", + "description": "Omni-era GPT for multimodal chat, practical coding, and general assistants", + "family": "gpt", + "attachment": true, + "reasoning": false, + "tool_call": true, + "structured_output": true, + "temperature": true, + "knowledge": "2023-09", + "release_date": "2024-05-13", + "last_updated": "2024-08-06", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 128000, + "output": 16384 + }, + "cost": { + "input": 2.5, + "output": 10, + "cache_read": 1.25 + } + } + } + }, + "anthropic": { + "id": "anthropic", + "env": [ + "ANTHROPIC_API_KEY" + ], + "npm": "@ai-sdk/anthropic", + "name": "Anthropic", + "doc": "https://docs.anthropic.com/en/docs/about-claude/models", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "Claude workhorse for coding agents, careful analysis, and production cost control", + "family": "claude-sonnet", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "max" + ] + }, + { + "type": "budget_tokens", + "min": 1024 + } + ], + "tool_call": true, + "structured_output": true, + "temperature": true, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1000000, + "output": 128000 + }, + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + } + }, + "claude-opus-4-6": { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "High-end Claude for difficult coding, planning, and slower expert reasoning", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "max" + ] + }, + { + "type": "budget_tokens", + "min": 1024 + } + ], + "tool_call": true, + "structured_output": true, + "temperature": true, + "knowledge": "2025-05-31", + "release_date": "2026-02-04", + "last_updated": "2026-03-13", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1000000, + "output": 128000 + }, + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + } + } + } + }, + "xai": { + "id": "xai", + "env": [ + "XAI_API_KEY" + ], + "npm": "@ai-sdk/xai", + "name": "xAI", + "doc": "https://docs.x.ai/docs/models", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "name": "Grok 4.6", + "description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects", + "family": "grok", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh" + ] + } + ], + "tool_call": true, + "structured_output": true, + "temperature": true, + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 500000, + "output": 500000 + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } + } + }, + "grok-4.5": { + "id": "grok-4.5", + "name": "Grok 4.5", + "description": "xAI's Grok model for chat, coding, agentic tools, and lower hallucination risk", + "family": "grok", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high" + ] + } + ], + "tool_call": true, + "structured_output": true, + "temperature": true, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 500000, + "output": 500000 + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.3, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 0.6 + } + } + } + } + }, + "alibaba": { + "id": "alibaba", + "env": [ + "DASHSCOPE_API_KEY" + ], + "npm": "@ai-sdk/openai-compatible", + "name": "Alibaba", + "doc": "https://www.alibabacloud.com/help/en/model-studio/models", + "models": { + "qwen-max": { + "id": "qwen-max", + "name": "Qwen Max", + "description": "Flagship Qwen model for complex reasoning, coding, and agentic workflows", + "family": "qwen", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "knowledge": "2024-04", + "release_date": "2024-04-03", + "last_updated": "2025-01-25", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 32768, + "output": 8192 + }, + "cost": { + "input": 1.6, + "output": 6.4 + } + } + } + } +} diff --git a/src/client/testdata/openrouter_fixture.json b/src/client/testdata/openrouter_fixture.json new file mode 100644 index 0000000..956cbdd --- /dev/null +++ b/src/client/testdata/openrouter_fixture.json @@ -0,0 +1,538 @@ +{ + "data": [ + { + "id": "x-ai/grok-4.6", + "canonical_slug": "x-ai/grok-4.6-20260810", + "hugging_face_id": null, + "name": "SpaceXAI: Grok 4.6", + "created": 1786548957, + "description": "Grok 4.6 is SpaceXAI's smartest model with frontier performance on coding, knowledge work, and STEM.", + "context_length": 500000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": "0.000002", + "completion": "0.000006", + "web_search": "0.005", + "input_cache_read": "0.0000005", + "overrides": [ + { + "min_prompt_tokens": 200000, + "prompt": "0.000004", + "completion": "0.000012", + "input_cache_read": "0.000001" + } + ] + }, + "top_provider": { + "context_length": 500000, + "max_completion_tokens": 450000, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "include_reasoning", + "logprobs", + "max_tokens", + "reasoning", + "reasoning_effort", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_k", + "top_logprobs", + "top_p" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/x-ai/grok-4.6-20260810/endpoints" + }, + "reasoning": { + "mandatory": true, + "default_enabled": true, + "supported_efforts": [ + "xhigh", + "high", + "medium", + "low" + ], + "default_effort": "high" + } + }, + { + "id": "qwen/qwen3.8-max", + "canonical_slug": "qwen/qwen3.8-max-20260803", + "hugging_face_id": null, + "name": "Qwen: Qwen3.8 Max", + "created": 1785731612, + "description": "Qwen3.8 Max is the flagship model in Alibaba's Qwen3.8 series, the general-availability successor to the Qwen3.8 Max Preview. It is a multimodal reasoning model intended for complex reasoning, visual understanding,...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+video->text", + "input_modalities": [ + "text", + "image", + "video" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Qwen", + "instruct_type": null + }, + "pricing": { + "prompt": "0.000002", + "completion": "0.000006", + "input_cache_read": "0.00000025", + "input_cache_write": "0.0000025" + }, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 131072, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "frequency_penalty", + "include_reasoning", + "logprobs", + "max_tokens", + "presence_penalty", + "reasoning", + "reasoning_effort", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_k", + "top_logprobs", + "top_p" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/qwen/qwen3.8-max-20260803/endpoints" + }, + "reasoning": { + "mandatory": true, + "default_enabled": true, + "supported_efforts": [ + "xhigh", + "high", + "medium", + "low", + "minimal" + ], + "default_effort": "xhigh" + } + }, + { + "id": "x-ai/grok-4.5", + "canonical_slug": "x-ai/grok-4.5-20260708", + "hugging_face_id": null, + "name": "SpaceXAI: Grok 4.5", + "created": 1783523154, + "description": "Grok 4.5 is a model from SpaceXAI with frontier performance on coding, knowledge work, and STEM.", + "context_length": 500000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Grok", + "instruct_type": null + }, + "pricing": { + "prompt": "0.000002", + "completion": "0.000006", + "web_search": "0.005", + "input_cache_read": "0.0000003", + "overrides": [ + { + "min_prompt_tokens": 200000, + "prompt": "0.000004", + "completion": "0.000012", + "input_cache_read": "0.0000006" + } + ] + }, + "top_provider": { + "context_length": 500000, + "max_completion_tokens": 450000, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "include_reasoning", + "logprobs", + "max_tokens", + "reasoning", + "reasoning_effort", + "response_format", + "seed", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_logprobs", + "top_p" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/x-ai/grok-4.5-20260708/endpoints" + }, + "reasoning": { + "mandatory": true, + "default_enabled": true, + "supported_efforts": [ + "high", + "medium", + "low" + ], + "default_effort": "high" + } + }, + { + "id": "anthropic/claude-sonnet-4.6", + "canonical_slug": "anthropic/claude-4.6-sonnet-20260217", + "hugging_face_id": "", + "name": "Anthropic: Claude Sonnet 4.6", + "created": 1771342990, + "description": "Sonnet 4.6 is Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work. It excels at iterative development, complex codebase navigation, end-to-end project management with...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": "0.000003", + "completion": "0.000015", + "web_search": "0.01", + "input_cache_read": "0.0000003", + "input_cache_write": "0.00000375", + "input_cache_write_1h": "0.000006" + }, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "include_reasoning", + "max_completion_tokens", + "max_tokens", + "reasoning", + "reasoning_effort", + "response_format", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_k", + "top_p", + "verbosity" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/anthropic/claude-4.6-sonnet-20260217/endpoints" + }, + "reasoning": { + "mandatory": false, + "supported_efforts": [ + "max", + "high", + "medium", + "low" + ], + "default_effort": "medium" + } + }, + { + "id": "openrouter/free", + "canonical_slug": "openrouter/free", + "hugging_face_id": "", + "name": "Free Models Router", + "created": 1769917427, + "description": "The simplest way to get free inference. openrouter/free is a router that selects free models at random from the models available on OpenRouter. The router smartly filters for models that...", + "context_length": 200000, + "architecture": { + "modality": "text+image->text", + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": "0", + "completion": "0" + }, + "top_provider": { + "context_length": null, + "max_completion_tokens": null, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "frequency_penalty", + "include_reasoning", + "logprobs", + "max_completion_tokens", + "max_tokens", + "min_p", + "presence_penalty", + "reasoning", + "reasoning_effort", + "repetition_penalty", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_k", + "top_logprobs", + "top_p" + ], + "default_parameters": { + "temperature": null, + "top_p": null, + "frequency_penalty": null + }, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/openrouter/free/endpoints" + } + }, + { + "id": "openai/gpt-4.1", + "canonical_slug": "openai/gpt-4.1-2025-04-14", + "hugging_face_id": "", + "name": "OpenAI: GPT-4.1", + "created": 1744651385, + "description": "GPT-4.1 is a flagship large language model optimized for advanced instruction following, real-world software engineering, and long-context reasoning. It supports a 1 million token context window and outperforms GPT-4o and...", + "context_length": 1047576, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "image", + "text", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": "0.000002", + "completion": "0.000008", + "web_search": "0.01", + "input_cache_read": "0.0000005" + }, + "top_provider": { + "context_length": 1047576, + "max_completion_tokens": 32768, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_completion_tokens", + "max_tokens", + "response_format", + "seed", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_p" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": "2024-06-30", + "expiration_date": null, + "links": { + "details": "/api/v1/models/openai/gpt-4.1-2025-04-14/endpoints" + } + }, + { + "id": "openai/gpt-4o", + "canonical_slug": "openai/gpt-4o", + "hugging_face_id": null, + "name": "OpenAI: GPT-4o", + "created": 1715558400, + "description": "GPT-4o (\"o\" for \"omni\") is OpenAI's latest AI model, supporting both text and image inputs with text outputs. It maintains the intelligence level of [GPT-4 Turbo](/models/openai/gpt-4-turbo) while being twice as...", + "context_length": 128000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": "0.0000025", + "completion": "0.00001", + "input_cache_read": "0.00000125" + }, + "top_provider": { + "context_length": 128000, + "max_completion_tokens": 16384, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "frequency_penalty", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "prediction", + "presence_penalty", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_logprobs", + "top_p", + "web_search_options" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": "2023-10-31", + "expiration_date": null, + "links": { + "details": "/api/v1/models/openai/gpt-4o/endpoints" + } + }, + { + "id": "openrouter/auto", + "canonical_slug": "openrouter/auto", + "hugging_face_id": null, + "name": "Auto Router", + "created": 1699401600, + "description": "The Auto Router automatically selects the best model for your prompt, powered by the wisdom of the market. It routes you based on what the OpenRouter community collectively spends on...", + "context_length": 2000000, + "architecture": { + "modality": "text+image+file+audio+video->text+image", + "input_modalities": [ + "text", + "image", + "audio", + "file", + "video" + ], + "output_modalities": [ + "text", + "image" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": "-1", + "completion": "-1" + }, + "top_provider": { + "context_length": null, + "max_completion_tokens": null, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "frequency_penalty", + "include_reasoning", + "logit_bias", + "logprobs", + "max_tokens", + "min_p", + "prediction", + "presence_penalty", + "reasoning", + "reasoning_effort", + "repetition_penalty", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_a", + "top_k", + "top_logprobs", + "top_p", + "web_search_options" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/openrouter/auto/endpoints" + } + } + ] +}