From 2f7442312e6cb932c33c149dbf562ad92daba280 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 30 Sep 2026 12:21:32 +0200 Subject: [PATCH] refactor(llm): Share one model table type across providers The OpenAI, Google, Anthropic and Cerebras providers each kept their hand-maintained model facts in a `match` over model ids. They now use a shared `Catalog` in `jp_llm::model::catalog`, named `MODEL_OVERRIDES` with a `model_overrides(id)` lookup in every provider. An entry lists its canonical id and any aliases (dated snapshots, `-latest` pointers, `gpt-5.6` / `gpt-5.6-sol`), and a lookup under an alias still reports the id the caller named. Each provider keeps its own value type: `ModelDetails` where the API reports little or nothing, and `ModelOverrides` for Anthropic, whose table only fills gaps in the capabilities API. A `match` could look up one id but could not be iterated, so OpenAI kept a separate `SUBSCRIPTION_MODELS` list for subscription credentials, and it had drifted: `gpt-6-sol` and `gpt-6-luna` were marked as served by a ChatGPT subscription but missing from the list. The list is now derived from the entries marked `subscription: Some(true)`, so a subscription credential lists both models, and the two can no longer disagree. Every provider has a test that no id is claimed by two entries, which the compiler used to catch as an unreachable `match` arm. Signed-off-by: Jean Mertz --- crates/jp_llm/src/model.rs | 2 + crates/jp_llm/src/model/catalog.rs | 85 + crates/jp_llm/src/model/catalog_tests.rs | 84 + crates/jp_llm/src/provider/anthropic.rs | 196 +- crates/jp_llm/src/provider/anthropic_tests.rs | 24 + crates/jp_llm/src/provider/cerebras.rs | 150 +- crates/jp_llm/src/provider/cerebras_tests.rs | 5 + crates/jp_llm/src/provider/google.rs | 285 +-- crates/jp_llm/src/provider/google_tests.rs | 68 + crates/jp_llm/src/provider/openai.rs | 1663 +++++++++-------- crates/jp_llm/src/provider/openai_tests.rs | 44 +- 11 files changed, 1579 insertions(+), 1027 deletions(-) create mode 100644 crates/jp_llm/src/model/catalog.rs create mode 100644 crates/jp_llm/src/model/catalog_tests.rs diff --git a/crates/jp_llm/src/model.rs b/crates/jp_llm/src/model.rs index ad6a7927d..ec272beb8 100644 --- a/crates/jp_llm/src/model.rs +++ b/crates/jp_llm/src/model.rs @@ -1,3 +1,5 @@ +pub(crate) mod catalog; + use chrono::NaiveDate; use jp_config::model::{ id::ModelIdConfig, diff --git a/crates/jp_llm/src/model/catalog.rs b/crates/jp_llm/src/model/catalog.rs new file mode 100644 index 000000000..1b17c6e1a --- /dev/null +++ b/crates/jp_llm/src/model/catalog.rs @@ -0,0 +1,85 @@ +//! Hand-maintained model tables, for providers whose API reports too little. +//! +//! A [`Catalog`] holds one [`Entry`] per model. +//! An entry is found under the model's canonical id or any of its aliases, such +//! as a dated snapshot or a `-latest` pointer. +//! What an entry carries is up to the provider: the full [`ModelDetails`] when +//! the API reports nothing, or only the facts the API leaves out. + +use std::iter; + +use crate::model::ModelDetails; + +/// A value a [`Catalog`] can hold: something that names its canonical model id. +pub(crate) trait Cataloged { + /// The id the model is cataloged under, without its provider prefix. + fn catalog_id(&self) -> &str; +} + +impl Cataloged for ModelDetails { + fn catalog_id(&self) -> &str { + self.id.name.as_ref() + } +} + +/// One model in a [`Catalog`]. +pub(crate) struct Entry { + /// Other ids the provider serves the model under. + pub aliases: &'static [&'static str], + + /// What the provider knows about the model. + pub value: T, +} + +/// A provider's table of models, looked up by canonical id or alias. +pub(crate) struct Catalog(Vec>); + +impl Catalog { + /// Build a catalog from its entries, in the order they are listed. + /// + /// # Panics + /// + /// In debug builds, if two entries claim the same id: a lookup would + /// silently return whichever comes first. + pub(crate) fn new(entries: Vec>) -> Self { + let catalog = Self(entries); + if let Some(id) = catalog.duplicate_id() { + debug_assert!(false, "{id} is cataloged twice"); + } + + catalog + } + + /// The value cataloged under `id`, as its canonical id or an alias. + pub(crate) fn get(&self, id: &str) -> Option<&T> { + self.0 + .iter() + .find(|entry| entry.value.catalog_id() == id || entry.aliases.contains(&id)) + .map(|entry| &entry.value) + } + + /// Every value, in the order the entries were listed. + pub(crate) fn values(&self) -> impl Iterator { + self.0.iter().map(|entry| &entry.value) + } + + /// The first id, canonical or alias, that more than one entry claims. + pub(crate) fn duplicate_id(&self) -> Option<&str> { + let mut seen = vec![]; + self.0 + .iter() + .flat_map(|entry| { + let aliases = entry.aliases.iter().copied(); + iter::once(entry.value.catalog_id()).chain(aliases) + }) + .find(|id| { + let duplicate = seen.contains(id); + seen.push(*id); + duplicate + }) + } +} + +#[cfg(test)] +#[path = "catalog_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/model/catalog_tests.rs b/crates/jp_llm/src/model/catalog_tests.rs new file mode 100644 index 000000000..3dd16edae --- /dev/null +++ b/crates/jp_llm/src/model/catalog_tests.rs @@ -0,0 +1,84 @@ +use super::*; + +struct Model(&'static str); + +impl Cataloged for Model { + fn catalog_id(&self) -> &str { + self.0 + } +} + +fn catalog() -> Catalog { + Catalog::new(vec![ + Entry { + aliases: &["alpha-latest", "alpha-2026-01-01"], + value: Model("alpha"), + }, + Entry { + aliases: &[], + value: Model("beta"), + }, + ]) +} + +#[test] +fn finds_an_entry_under_its_canonical_id() { + assert_eq!(catalog().get("beta").map(|m| m.0), Some("beta")); +} + +#[test] +fn finds_an_entry_under_any_alias() { + let catalog = catalog(); + + assert_eq!(catalog.get("alpha-latest").map(|m| m.0), Some("alpha")); + assert_eq!(catalog.get("alpha-2026-01-01").map(|m| m.0), Some("alpha")); +} + +#[test] +fn an_unlisted_id_finds_nothing() { + assert!(catalog().get("gamma").is_none()); +} + +#[test] +fn values_come_back_in_listed_order() { + let ids: Vec<_> = catalog().values().map(|m| m.0).collect(); + + assert_eq!(ids, vec!["alpha", "beta"]); +} + +#[test] +fn an_alias_that_repeats_another_canonical_id_is_a_duplicate() { + let catalog = Catalog(vec![ + Entry { + aliases: &[], + value: Model("alpha"), + }, + Entry { + aliases: &["alpha"], + value: Model("beta"), + }, + ]); + + assert_eq!(catalog.duplicate_id(), Some("alpha")); +} + +#[test] +fn distinct_ids_have_no_duplicate() { + assert_eq!(catalog().duplicate_id(), None); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "alpha is cataloged twice")] +fn building_a_catalog_with_a_duplicate_panics_in_debug() { + Catalog::new(vec![ + Entry { + aliases: &[], + value: Model("alpha"), + }, + Entry { + aliases: &[], + value: Model("alpha"), + }, + ]); +} diff --git a/crates/jp_llm/src/provider/anthropic.rs b/crates/jp_llm/src/provider/anthropic.rs index 0eee0878b..a7c65c88f 100644 --- a/crates/jp_llm/src/provider/anthropic.rs +++ b/crates/jp_llm/src/provider/anthropic.rs @@ -4,7 +4,7 @@ mod http; pub mod oauth; pub mod resolve; -use std::{env, mem, ops::RangeInclusive, time::Duration}; +use std::{env, mem, ops::RangeInclusive, sync::LazyLock, time::Duration}; use async_anthropic::{ Client, @@ -51,7 +51,10 @@ use crate::{ }, event::{Event, EventMatcher, EventPart, EventPatch, FinishReason, PatchAction, ToolCallPart}, event_builder::EventBuilder, - model::{ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode}, + model::{ + ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode, + catalog::{Catalog, Cataloged, Entry}, + }, query::{ChatQuery, QueryContext, QueryStream, ToolExecution}, stream::{EventStream, chain::find_merge_point, with_tool_call_keepalive}, }; @@ -2329,9 +2332,12 @@ fn adaptive_effort( /// /// Everything else (token limits, reasoning mode and effort ladder, structured /// output, feature flags) is derived from the API, so a newly released model -/// only needs an entry here when one of these four differs from the defaults. +/// only needs an entry here when one of these facts differs from the defaults. #[derive(Debug, Clone)] struct ModelOverrides { + /// The model's canonical id. + id: &'static str, + /// Training data cutoff. /// /// See: @@ -2351,9 +2357,12 @@ struct ModelOverrides { always_on: bool, } -impl Default for ModelOverrides { - fn default() -> Self { +impl ModelOverrides { + /// Overrides for `id` that change nothing: no known cutoff, active, and + /// every flag off. + fn new(id: &'static str) -> Self { Self { + id, knowledge_cutoff: None, deprecated: ModelDeprecation::Active, prefill: false, @@ -2362,82 +2371,130 @@ impl Default for ModelOverrides { } } +impl Cataloged for ModelOverrides { + fn catalog_id(&self) -> &str { + self.id + } +} + /// Look up the facts the capabilities API cannot supply. /// -/// Returns `None` for a model absent from this table, leaving its cutoff and -/// deprecation status unknown rather than asserting defaults for a model this -/// binary predates. -#[expect(clippy::match_same_arms)] -fn model_overrides(id: &str) -> Option { +/// Returns `None` for a model absent from [`MODEL_OVERRIDES`], leaving its +/// cutoff and deprecation status unknown rather than asserting defaults for a +/// model this binary predates. +fn model_overrides(id: &str) -> Option<&'static ModelOverrides> { + MODEL_OVERRIDES.get(id) +} + +/// The facts the capabilities API cannot supply, per model. +static MODEL_OVERRIDES: LazyLock> = LazyLock::new(|| { let cutoff = |year, month| NaiveDate::from_ymd_opt(year, month, 1); - Some(match id { - "claude-fable-5-1" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 6), - always_on: true, - ..Default::default() + Catalog::new(vec![ + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 6), + always_on: true, + ..ModelOverrides::new("claude-fable-5-1") + }, }, - "claude-fable-5" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 1), - always_on: true, - ..Default::default() + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 1), + always_on: true, + ..ModelOverrides::new("claude-fable-5") + }, }, - "claude-opus-5-5" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 6), - always_on: true, - ..Default::default() + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 6), + always_on: true, + ..ModelOverrides::new("claude-opus-5-5") + }, }, - "claude-opus-5" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 5), - ..Default::default() + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 5), + ..ModelOverrides::new("claude-opus-5") + }, }, - "claude-sonnet-5" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 1), - ..Default::default() + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 1), + ..ModelOverrides::new("claude-sonnet-5") + }, }, - "claude-opus-4-8" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 1), - ..Default::default() + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 1), + ..ModelOverrides::new("claude-opus-4-8") + }, }, - "claude-opus-4-7" | "claude-opus-4-7-20260416" => ModelOverrides { - knowledge_cutoff: cutoff(2026, 1), - ..Default::default() + Entry { + aliases: &["claude-opus-4-7-20260416"], + value: ModelOverrides { + knowledge_cutoff: cutoff(2026, 1), + ..ModelOverrides::new("claude-opus-4-7") + }, }, - "claude-sonnet-4-6" => ModelOverrides { - knowledge_cutoff: cutoff(2025, 8), - ..Default::default() + Entry { + aliases: &[], + value: ModelOverrides { + knowledge_cutoff: cutoff(2025, 8), + ..ModelOverrides::new("claude-sonnet-4-6") + }, }, - "claude-opus-4-6" | "claude-opus-4-6-20260205" => ModelOverrides { - knowledge_cutoff: cutoff(2025, 8), - ..Default::default() + Entry { + aliases: &["claude-opus-4-6-20260205"], + value: ModelOverrides { + knowledge_cutoff: cutoff(2025, 8), + ..ModelOverrides::new("claude-opus-4-6") + }, }, - "claude-opus-4-5" | "claude-opus-4-5-20251101" => ModelOverrides { - knowledge_cutoff: cutoff(2025, 8), - prefill: true, - ..Default::default() + Entry { + aliases: &["claude-opus-4-5-20251101"], + value: ModelOverrides { + knowledge_cutoff: cutoff(2025, 8), + prefill: true, + ..ModelOverrides::new("claude-opus-4-5") + }, }, - "claude-haiku-4-5" | "claude-haiku-4-5-20251001" => ModelOverrides { - knowledge_cutoff: cutoff(2025, 7), - prefill: true, - ..Default::default() + Entry { + aliases: &["claude-haiku-4-5-20251001"], + value: ModelOverrides { + knowledge_cutoff: cutoff(2025, 7), + prefill: true, + ..ModelOverrides::new("claude-haiku-4-5") + }, }, - "claude-sonnet-4-5" | "claude-sonnet-4-5-20250929" => ModelOverrides { - knowledge_cutoff: cutoff(2025, 7), - prefill: true, - ..Default::default() + Entry { + aliases: &["claude-sonnet-4-5-20250929"], + value: ModelOverrides { + knowledge_cutoff: cutoff(2025, 7), + prefill: true, + ..ModelOverrides::new("claude-sonnet-4-5") + }, }, - "claude-opus-4-1" | "claude-opus-4-1-20250805" => ModelOverrides { - knowledge_cutoff: cutoff(2025, 3), - deprecated: ModelDeprecation::deprecated( - &"recommended replacement: claude-opus-5", - NaiveDate::from_ymd_opt(2026, 8, 5), - ), - prefill: true, - ..Default::default() + Entry { + aliases: &["claude-opus-4-1-20250805"], + value: ModelOverrides { + knowledge_cutoff: cutoff(2025, 3), + deprecated: ModelDeprecation::deprecated( + &"recommended replacement: claude-opus-5", + NaiveDate::from_ymd_opt(2026, 8, 5), + ), + prefill: true, + ..ModelOverrides::new("claude-opus-4-1") + }, }, - _ => return None, - }) -} + ]) +}); /// Derive the provider feature flags from the reported capabilities. /// @@ -2470,9 +2527,6 @@ fn map_model(model: types::Model) -> Result { ); } - let known = overrides.is_some(); - let overrides = overrides.unwrap_or_default(); - // A `0` token limit means "unspecified", so treat it as unknown and let the // request fall back to its default rather than capping at zero. let context_window = (model.max_input_tokens != 0).then_some(model.max_input_tokens); @@ -2482,7 +2536,7 @@ fn map_model(model: types::Model) -> Result { let features = derive_features(&model.capabilities); let mut reasoning = derive_reasoning(&model.capabilities); - if overrides.always_on { + if overrides.is_some_and(|o| o.always_on) { reasoning = reasoning.map(ReasoningDetails::always_on); } @@ -2492,12 +2546,12 @@ fn map_model(model: types::Model) -> Result { context_window, max_output_tokens, reasoning, - knowledge_cutoff: overrides.knowledge_cutoff, - deprecated: known.then_some(overrides.deprecated), + knowledge_cutoff: overrides.and_then(|o| o.knowledge_cutoff), + deprecated: overrides.map(|o| o.deprecated.clone()), structured_output, // Only a model in the table has a known answer; the API reports nothing // about prefill. - prefill: known.then_some(overrides.prefill), + prefill: overrides.map(|o| o.prefill), // The HTTP model catalog does not report subscription availability. subscription: None, features, diff --git a/crates/jp_llm/src/provider/anthropic_tests.rs b/crates/jp_llm/src/provider/anthropic_tests.rs index c9972c22a..5331ded37 100644 --- a/crates/jp_llm/src/provider/anthropic_tests.rs +++ b/crates/jp_llm/src/provider/anthropic_tests.rs @@ -1004,6 +1004,30 @@ fn test_map_model_opus_5_5() { assert!(!details.supports_prefill()); } +#[test] +fn model_override_ids_are_unique() { + assert_eq!(MODEL_OVERRIDES.duplicate_id(), None); +} + +/// A dated snapshot takes its model's overrides but keeps its own id. +#[test] +fn test_map_model_snapshot_alias_uses_the_model_overrides() { + let model = adaptive_api_model("claude-opus-4-7-20260416", "Claude Opus 4.7", true, true); + + let details = map_model(model).unwrap(); + + assert_eq!( + details.id, + (PROVIDER, "claude-opus-4-7-20260416").try_into().unwrap() + ); + assert_eq!( + details.knowledge_cutoff, + NaiveDate::from_ymd_opt(2026, 1, 1) + ); + assert_eq!(details.deprecated, Some(ModelDeprecation::Active)); + assert_eq!(details.prefill, Some(false)); +} + /// Verify the `map_model` arm for Claude Fable 5.1 produces the expected /// `ModelDetails`, including the `thinking-always-on` capability that stops JP /// from sending `thinking: disabled` and from hard-forcing a `tool_choice`, diff --git a/crates/jp_llm/src/provider/cerebras.rs b/crates/jp_llm/src/provider/cerebras.rs index df24fe3ce..421c19100 100644 --- a/crates/jp_llm/src/provider/cerebras.rs +++ b/crates/jp_llm/src/provider/cerebras.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, sync::LazyLock, time::Duration}; use async_trait::async_trait; use futures::{Stream, StreamExt as _, future, stream}; @@ -30,7 +30,10 @@ use super::{ use crate::{ error::{Error, Result, StreamError, StreamErrorKind}, event::{Event, FinishReason}, - model::{ModelDeprecation, ReasoningDetails}, + model::{ + ModelDeprecation, ReasoningDetails, + catalog::{Catalog, Entry}, + }, provider::trace_to_tmpfile, query::ChatQuery, stream::with_tool_call_keepalive, @@ -361,68 +364,95 @@ fn map_model_with_catalog(id: &str, public: Option<&PublicModel>) -> Result Result { - let details = match id { - // Context and output limits use paid-tier values. Free-tier users - // get lower limits enforced server-side. - "gemma-4-31b" => ModelDetails { - id: (PROVIDER, id).try_into()?, - display_name: Some("Gemma 4 31B".to_owned()), - context_window: Some(131_072), - max_output_tokens: Some(40_960), - // The public catalog reports that this model reasons but names no - // effort levels, so support stays unknown rather than inheriting a - // ladder invented from its siblings. `auto` then omits the effort and - // lets the server pick, and an explicit `off` still sends - // `reasoning_effort: "none"`, both of which are recorded as accepted - // for this model. - reasoning: None, - knowledge_cutoff: None, - deprecated: None, - structured_output: Some(true), - prefill: None, - subscription: None, - features: vec![], + if let Some(details) = model_overrides(id) { + return Ok(details.clone()); + } + + warn!(model = id, "Unknown Cerebras model, using empty details."); + Ok(ModelDetails::empty((PROVIDER, id).try_into()?)) +} + +/// Look up the built-in details for `id`. +/// +/// `None` for a model absent from [`MODEL_OVERRIDES`]. +fn model_overrides(id: &str) -> Option<&'static ModelDetails> { + MODEL_OVERRIDES.get(id) +} + +/// The Cerebras facts the public catalog does not report, chiefly the effort +/// ladder, plus fallback limits for when the catalog is unavailable. +/// +/// Context and output limits use paid-tier values. +/// Free-tier users get lower limits enforced server-side. +static MODEL_OVERRIDES: LazyLock> = LazyLock::new(|| { + let id = |name: &str| ModelIdConfig::try_from((PROVIDER, name)).unwrap(); + + Catalog::new(vec![ + Entry { + aliases: &[], + value: ModelDetails { + id: id("gemma-4-31b"), + display_name: Some("Gemma 4 31B".to_owned()), + context_window: Some(131_072), + max_output_tokens: Some(40_960), + // The public catalog reports that this model reasons but names + // no effort levels, so support stays unknown rather than + // inheriting a ladder invented from its siblings. `auto` then + // omits the effort and lets the server pick, and an explicit + // `off` still sends `reasoning_effort: "none"`, both of which + // are recorded as accepted for this model. + reasoning: None, + knowledge_cutoff: None, + deprecated: None, + structured_output: Some(true), + prefill: None, + subscription: None, + features: vec![], + }, }, - "gpt-oss-120b" => ModelDetails { - id: (PROVIDER, id).try_into()?, - display_name: Some("GPT-OSS 120B".to_owned()), - context_window: Some(131_072), - max_output_tokens: Some(40_960), - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: None, - deprecated: None, - structured_output: Some(true), - prefill: None, - subscription: None, - features: vec![], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-oss-120b"), + display_name: Some("GPT-OSS 120B".to_owned()), + context_window: Some(131_072), + max_output_tokens: Some(40_960), + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: None, + deprecated: None, + structured_output: Some(true), + prefill: None, + subscription: None, + features: vec![], + }, }, - "zai-glm-4.7" => ModelDetails { - id: (PROVIDER, id).try_into()?, - display_name: Some("Zai GLM 4.7".to_owned()), - context_window: Some(131_072), - max_output_tokens: Some(40_960), - // Reasoning is enabled by default; only `none` disables it. - reasoning: Some(ReasoningDetails::leveled( - false, false, false, false, false, false, - )), - knowledge_cutoff: None, - deprecated: None, - structured_output: Some(true), - prefill: None, - subscription: None, - features: vec![], + Entry { + aliases: &[], + value: ModelDetails { + id: id("zai-glm-4.7"), + display_name: Some("Zai GLM 4.7".to_owned()), + context_window: Some(131_072), + max_output_tokens: Some(40_960), + // Reasoning is enabled by default; only `none` disables it. + reasoning: Some(ReasoningDetails::leveled( + false, false, false, false, false, false, + )), + knowledge_cutoff: None, + deprecated: None, + structured_output: Some(true), + prefill: None, + subscription: None, + features: vec![], + }, }, - _ => { - warn!(model = id, "Unknown Cerebras model, using empty details."); - ModelDetails::empty((PROVIDER, id).try_into()?) - } - }; - - Ok(details) -} + ]) +}); #[cfg(test)] impl Cerebras { diff --git a/crates/jp_llm/src/provider/cerebras_tests.rs b/crates/jp_llm/src/provider/cerebras_tests.rs index 228c80e24..ce703df5a 100644 --- a/crates/jp_llm/src/provider/cerebras_tests.rs +++ b/crates/jp_llm/src/provider/cerebras_tests.rs @@ -591,6 +591,11 @@ fn map_model_with_catalog_absent_capabilities_keeps_table() { assert!(details.reasoning.unwrap().is_leveled()); } +#[test] +fn model_override_ids_are_unique() { + assert_eq!(MODEL_OVERRIDES.duplicate_id(), None); +} + #[test] fn map_model_known() { let details = map_model("gemma-4-31b").unwrap(); diff --git a/crates/jp_llm/src/provider/google.rs b/crates/jp_llm/src/provider/google.rs index da0908a2e..3098f12f0 100644 --- a/crates/jp_llm/src/provider/google.rs +++ b/crates/jp_llm/src/provider/google.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, sync::LazyLock, time::Duration}; use async_stream::stream; use async_trait::async_trait; @@ -33,7 +33,10 @@ use crate::{ looks_like_quota_error, parse_retry_delay, }, event::{Event, EventMatcher, EventPatch, FinishReason, PatchAction}, - model::{ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode}, + model::{ + ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode, + catalog::{Catalog, Entry}, + }, query::ChatQuery, }; @@ -471,9 +474,50 @@ fn create_request( /// Map a Gemini model to a `ModelDetails`. /// -/// A model absent from this table still gets its limits from the API, so an -/// entry is only needed for the reasoning ladder, which the API does not -/// report. +/// Limits and the display name come from the API. +/// The rest comes from [`MODEL_OVERRIDES`] when the model is listed there, and +/// is unknown otherwise. +fn map_model(model: types::Model) -> ModelDetails { + let name = model.base_model_id.as_str(); + let Ok(id) = ModelIdConfig::try_from((PROVIDER, name)) else { + return ModelDetails::empty((PROVIDER, "unknown").try_into().unwrap()); + }; + + let mut details = model_overrides(name).cloned().unwrap_or_else(|| { + trace!( + name, + display_name = model.display_name, + "Missing model details. Falling back to generic model details." + ); + + ModelDetails::empty(id.clone()) + }); + + // Reported under the id the API named, which may be an alias. + details.id = id; + details.display_name = Some(model.display_name); + details.context_window = Some(model.input_token_limit); + details.max_output_tokens = Some(model.output_token_limit); + + // The API reports whether the model can think at all, but not its effort + // levels, so any ladder still comes from the catalog. + details.reasoning = apply_thinking_support(details.reasoning, model.thinking); + + details +} + +/// Look up the details for `id`, under its canonical id or an alias. +/// +/// `None` for a model absent from [`MODEL_OVERRIDES`]. +fn model_overrides(id: &str) -> Option<&'static ModelDetails> { + MODEL_OVERRIDES.get(id) +} + +/// The Gemini facts the API does not report: the reasoning ladder, knowledge +/// cutoff, deprecation, and structured output support. +/// +/// Display names and token limits are left unset here; `map_model` takes them +/// from the API. /// /// Note that `/v1beta/models` lists models that `generateContent` no longer /// serves, so being listed is not evidence a model is callable. @@ -485,67 +529,67 @@ fn create_request( /// See: See: /// See: /// -#[expect(clippy::too_many_lines)] -fn map_model(model: types::Model) -> ModelDetails { - let name = model.base_model_id.as_str(); - let display_name = Some(model.display_name); - let context_window = Some(model.input_token_limit); - let max_output_tokens = Some(model.output_token_limit); - let Ok(id) = (PROVIDER, model.base_model_id.as_str()).try_into() else { - return ModelDetails::empty((PROVIDER, "unknown").try_into().unwrap()); - }; - - // Whether the API reports the model as able to think at all. It does not - // report effort levels, so any ladder still comes from the table below. - let thinks = model.thinking; - - let mut details = match name { - "gemini-pro-latest" | "gemini-3.1-pro-preview" | "gemini-3.1-pro-preview-customtools" => { - ModelDetails { - id, - display_name, - context_window, - max_output_tokens, +static MODEL_OVERRIDES: LazyLock> = LazyLock::new(|| { + let date = |year, month, day| NaiveDate::from_ymd_opt(year, month, day).unwrap(); + let id = |name: &str| ModelIdConfig::try_from((PROVIDER, name)).unwrap(); + + Catalog::new(vec![ + Entry { + aliases: &[ + "gemini-3.1-pro-preview", + "gemini-3.1-pro-preview-customtools", + ], + value: ModelDetails { + id: id("gemini-pro-latest"), + display_name: None, + context_window: None, + max_output_tokens: None, reasoning: Some( ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), + knowledge_cutoff: Some(date(2025, 1, 1)), deprecated: Some(ModelDeprecation::Active), structured_output: None, prefill: None, subscription: None, features: vec![], - } - } - "gemini-flash-latest" | "gemini-3-flash-preview" => ModelDetails { - id, - display_name, - context_window, - max_output_tokens, - reasoning: Some( - ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: None, - features: vec![], + }, }, - "gemini-3.8-flash" => ModelDetails { - id, - display_name, - context_window, - max_output_tokens, - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: Some(true), - prefill: None, - subscription: None, - features: vec![], + Entry { + aliases: &["gemini-3-flash-preview"], + value: ModelDetails { + id: id("gemini-flash-latest"), + display_name: None, + context_window: None, + max_output_tokens: None, + reasoning: Some( + ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 1, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: None, + features: vec![], + }, + }, + Entry { + aliases: &[], + value: ModelDetails { + id: id("gemini-3.8-flash"), + display_name: None, + context_window: None, + max_output_tokens: None, + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2026, 3, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: Some(true), + prefill: None, + subscription: None, + features: vec![], + }, }, // Closed to new users rather than retired: `generateContent` answers 404 // "no longer available to new users" for a key that never had access, @@ -556,86 +600,65 @@ fn map_model(model: types::Model) -> ModelDetails { // The entry earns its place because this is a budget-era model. Without // it the catch-all infers a thinking *level*, which this generation does // not accept. - "gemini-2.5-flash" => ModelDetails { - id, - display_name, - context_window, - max_output_tokens, - reasoning: Some(ReasoningDetails::budgetted(0, Some(24576))), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gemini-3.6-flash", - Some(NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: None, - features: vec![], - }, - "gemini-flash-lite-latest" | "gemini-2.5-flash-lite" => ModelDetails { - id, - display_name, - context_window, - max_output_tokens, - reasoning: Some(ReasoningDetails::budgetted(512, Some(24576))), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gemini-3.1-flash-lite", - Some(NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: None, - features: vec![], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gemini-2.5-flash"), + display_name: None, + context_window: None, + max_output_tokens: None, + reasoning: Some(ReasoningDetails::budgetted(0, Some(24576))), + knowledge_cutoff: Some(date(2025, 1, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gemini-3.6-flash", + Some(date(2026, 10, 16)), + )), + structured_output: None, + prefill: None, + subscription: None, + features: vec![], + }, }, - "gemini-2.5-pro" => ModelDetails { - id, - display_name, - context_window, - max_output_tokens, - reasoning: Some(ReasoningDetails::budgetted(512, Some(24576))), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gemini-3.1-pro-preview", - Some(NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: None, - features: vec![], + Entry { + aliases: &["gemini-2.5-flash-lite"], + value: ModelDetails { + id: id("gemini-flash-lite-latest"), + display_name: None, + context_window: None, + max_output_tokens: None, + reasoning: Some(ReasoningDetails::budgetted(512, Some(24576))), + knowledge_cutoff: Some(date(2025, 1, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gemini-3.1-flash-lite", + Some(date(2026, 10, 16)), + )), + structured_output: None, + prefill: None, + subscription: None, + features: vec![], + }, }, - id => { - trace!( - name, - display_name = display_name - .clone() - .unwrap_or_else(|| "".to_owned()), - id, - "Missing model details. Falling back to generic model details." - ); - - ModelDetails { - id: (PROVIDER, model.base_model_id.as_str()) - .try_into() - .unwrap_or((PROVIDER, "unknown").try_into().unwrap()), - display_name, - context_window, - max_output_tokens, - reasoning: None, - knowledge_cutoff: None, - deprecated: None, + Entry { + aliases: &[], + value: ModelDetails { + id: id("gemini-2.5-pro"), + display_name: None, + context_window: None, + max_output_tokens: None, + reasoning: Some(ReasoningDetails::budgetted(512, Some(24576))), + knowledge_cutoff: Some(date(2025, 1, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gemini-3.1-pro-preview", + Some(date(2026, 10, 16)), + )), structured_output: None, prefill: None, subscription: None, features: vec![], - } - } - }; - - details.reasoning = apply_thinking_support(details.reasoning, thinks); - - details -} + }, + }, + ]) +}); /// Map a reasoning effort onto the nearest thinking level, without consulting a /// ladder. diff --git a/crates/jp_llm/src/provider/google_tests.rs b/crates/jp_llm/src/provider/google_tests.rs index efb0caed6..6ecc14afd 100644 --- a/crates/jp_llm/src/provider/google_tests.rs +++ b/crates/jp_llm/src/provider/google_tests.rs @@ -106,6 +106,74 @@ fn test_map_model_thinking_flag_overrides_table() { assert_eq!(details.max_output_tokens, Some(65_536)); } +#[test] +fn model_override_ids_are_unique() { + assert_eq!(MODEL_OVERRIDES.duplicate_id(), None); +} + +/// An alias takes the catalog's facts but keeps the id and the limits the API +/// reported for it. +#[test] +fn test_map_model_alias_uses_catalog_facts_under_its_own_id() { + let model = types::Model { + base_model_id: "gemini-3-flash-preview".to_owned(), + display_name: "Gemini 3 Flash Preview".to_owned(), + input_token_limit: 1_048_576, + output_token_limit: 65_536, + thinking: true, + ..Default::default() + }; + + let details = map_model(model); + + assert_eq!( + details.id, + (PROVIDER, "gemini-3-flash-preview").try_into().unwrap() + ); + assert_eq!( + details.display_name, + Some("Gemini 3 Flash Preview".to_owned()) + ); + assert_eq!(details.context_window, Some(1_048_576)); + assert_eq!( + details.reasoning, + Some(ReasoningDetails::leveled(true, true, true, true, false, false).always_on()) + ); + assert_eq!( + details.knowledge_cutoff, + Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()) + ); +} + +/// A model absent from the catalog keeps the API's limits and nothing else. +#[test] +fn test_map_model_unknown_keeps_only_api_facts() { + let model = types::Model { + base_model_id: "gemini-9-unreleased".to_owned(), + display_name: "Gemini 9".to_owned(), + input_token_limit: 2_000_000, + output_token_limit: 100_000, + thinking: false, + ..Default::default() + }; + + let details = map_model(model); + + assert_eq!(details, ModelDetails { + id: (PROVIDER, "gemini-9-unreleased").try_into().unwrap(), + display_name: Some("Gemini 9".to_owned()), + context_window: Some(2_000_000), + max_output_tokens: Some(100_000), + reasoning: apply_thinking_support(None, false), + knowledge_cutoff: None, + deprecated: None, + structured_output: None, + prefill: None, + subscription: None, + features: vec![], + }); +} + #[test] fn test_map_model_gemini_3_8_flash() { let model = types::Model { diff --git a/crates/jp_llm/src/provider/openai.rs b/crates/jp_llm/src/provider/openai.rs index 0b8d0cfae..e94fa5665 100644 --- a/crates/jp_llm/src/provider/openai.rs +++ b/crates/jp_llm/src/provider/openai.rs @@ -1,5 +1,5 @@ use std::{ - sync::{Arc, Mutex, PoisonError}, + sync::{Arc, LazyLock, Mutex, PoisonError}, time::Duration, }; @@ -11,7 +11,7 @@ use jp_attachment::AttachmentContent; use jp_config::{ assistant::tool_choice::ToolChoice, model::{ - id::{Name, ProviderId}, + id::{ModelIdConfig, Name, ProviderId}, parameters::{CustomReasoningConfig, ReasoningConfig, ReasoningEffort, ServiceTier}, }, providers::llm::{AuthEntry, openai::OpenaiConfig}, @@ -42,7 +42,10 @@ use crate::{ looks_like_context_window_error, looks_like_quota_error, }, event::{Event, FinishReason}, - model::{ModelDeprecation, ReasoningDetails}, + model::{ + ModelDeprecation, ReasoningDetails, + catalog::{Catalog, Entry}, + }, provider::trace_to_tmpfile, query::{ChatQuery, Truncation}, stream::with_tool_call_keepalive, @@ -569,19 +572,6 @@ fn session_id(query: Option<&ChatQuery>) -> String { uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes()).to_string() } -/// The models a `ChatGPT` subscription serves. -/// -/// Only the ids live here; every property comes from the catalog, which -/// `subscription_models_are_marked_in_the_catalog` holds to agreement. -const SUBSCRIPTION_MODELS: &[&str] = &[ - "gpt-6-astra", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.3-codex-spark", -]; - /// A configured value, overridden by an environment variable when it is set. fn env_override(env_key: &str, configured: &str) -> String { std::env::var(env_key).unwrap_or_else(|_| configured.to_owned()) @@ -606,7 +596,7 @@ impl Provider for Openai { return Err(Error::UnsupportedForCredential(format!( "{name} is not served by a ChatGPT subscription; pick one of {}, or add \ `api_key` to providers.llm.openai.auth", - SUBSCRIPTION_MODELS.join(", ") + subscription_models().collect::>().join(", ") ))); } @@ -631,9 +621,8 @@ impl Provider for Openai { // No endpoint enumerates a plan's models, so the catalog answers // instead of the request failing. if attempt.is_subscription() { - return SUBSCRIPTION_MODELS - .iter() - .map(|id| map_model(ModelResponse::named((*id).to_owned()))) + return subscription_models() + .map(|id| map_model(ModelResponse::named(id.to_owned()))) .collect(); } @@ -1677,13 +1666,15 @@ fn create_request( /// /// An id the catalog does not know maps to empty details, with a warning. fn map_model(model: ModelResponse) -> Result { - let id = model.id.clone(); - if let Some(details) = catalog_entry(model)? { + if let Some(details) = model_overrides(&model.id) { + // Reported under the id the caller named, which may be an alias. + let mut details = details.clone(); + details.id = (PROVIDER, model.id).try_into()?; return Ok(details); } - warn!(model = id, "Missing model details."); - Ok(ModelDetails::empty((PROVIDER, id).try_into()?)) + warn!(model = model.id.as_str(), "Missing model details."); + Ok(ModelDetails::empty((PROVIDER, model.id).try_into()?)) } /// Whether the catalog marks `model` as served only through the API. @@ -1692,14 +1683,26 @@ fn map_model(model: ModelResponse) -> Result { /// changes without a JP release, and refusing it here would hide a model the /// subscription may well serve. fn is_api_only(model: &str) -> bool { - catalog_entry(ModelResponse::named(model.to_owned())) - .ok() - .flatten() - .is_some_and(|details| details.subscription == Some(false)) + model_overrides(model).is_some_and(|details| details.subscription == Some(false)) } -#[expect(clippy::too_many_lines)] -/// Look up an OpenAI model id in the capability catalog. +/// The canonical ids of the models a `ChatGPT` subscription serves, in catalog +/// order. +fn subscription_models() -> impl Iterator { + MODEL_OVERRIDES + .values() + .filter(|details| details.served_by_subscription()) + .map(|details| details.id.name.as_ref()) +} + +/// Look up the details for `id`, under its canonical id or an alias. +/// +/// `None` for a model absent from [`MODEL_OVERRIDES`]. +fn model_overrides(id: &str) -> Option<&'static ModelDetails> { + MODEL_OVERRIDES.get(id) +} + +/// The OpenAI model details the API does not report, which is all of them. /// /// This table is authoritative rather than a fallback: `GET /v1/models/{id}` /// returns only `{id, object, created, owned_by}`, reporting neither context @@ -1707,786 +1710,920 @@ fn is_api_only(model: &str) -> bool { /// Unlike the Anthropic, OpenRouter, and Cerebras providers, there is nothing /// to derive from, so every value here is maintained by hand against OpenAI's /// published model documentation. -/// -/// `None` for an id the catalog does not list. -fn catalog_entry(model: ModelResponse) -> Result> { - let details = match model.id.as_str() { - // An entry marked `subscription: Some(true)` is one a subscription - // credential can list and name; one marked `Some(false)` is API-only. - "gpt-6-astra" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-6 Astra".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: low, medium, high, xhigh, max. There - // is no `none`, so reasoning cannot be turned off; the lowest - // level stands in for a disable. - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, true, true).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 4, 30).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - // Reasoning is always active, so TEMP_REQUIRES_NO_REASONING drops - // temperature and top_p on every request — which is what this - // model wants: it rejects both outright. - subscription: Some(true), - features: vec![ - TEMP_REQUIRES_NO_REASONING, - REASONING_PRO_MODE, - PERSISTED_REASONING, - EXPLICIT_PROMPT_CACHING, - ], +static MODEL_OVERRIDES: LazyLock> = LazyLock::new(|| { + let date = |year, month, day| NaiveDate::from_ymd_opt(year, month, day).unwrap(); + let id = |name: &str| ModelIdConfig::try_from((PROVIDER, name)).unwrap(); + + Catalog::new(vec![ + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-6-astra"), + display_name: Some("GPT-6 Astra".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: low, medium, high, xhigh, max. There + // is no `none`, so reasoning cannot be turned off; the lowest + // level stands in for a disable. + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, true, true).always_on(), + ), + knowledge_cutoff: Some(date(2026, 4, 30)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + // Reasoning is always active, so TEMP_REQUIRES_NO_REASONING drops + // temperature and top_p on every request, which is what this model + // wants: it rejects both outright. + features: vec![ + TEMP_REQUIRES_NO_REASONING, + REASONING_PRO_MODE, + PERSISTED_REASONING, + EXPLICIT_PROMPT_CACHING, + ], + }, }, - "gpt-6-sol" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-6 Sol".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: none, low, medium, high, xhigh, max. - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, true, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 4, 20).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![ - TEMP_REQUIRES_NO_REASONING, - REASONING_PRO_MODE, - PERSISTED_REASONING, - EXPLICIT_PROMPT_CACHING, - ], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-6-sol"), + display_name: Some("GPT-6 Sol".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: none, low, medium, high, xhigh, max. + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, true, + )), + knowledge_cutoff: Some(date(2026, 4, 20)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![ + TEMP_REQUIRES_NO_REASONING, + REASONING_PRO_MODE, + PERSISTED_REASONING, + EXPLICIT_PROMPT_CACHING, + ], + }, }, - "gpt-6-luna" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-6 Luna".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: none, low, medium, high, xhigh, max. - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, true, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 5, 18).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![ - TEMP_REQUIRES_NO_REASONING, - REASONING_PRO_MODE, - PERSISTED_REASONING, - EXPLICIT_PROMPT_CACHING, - ], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-6-luna"), + display_name: Some("GPT-6 Luna".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: none, low, medium, high, xhigh, max. + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, true, + )), + knowledge_cutoff: Some(date(2026, 5, 18)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![ + TEMP_REQUIRES_NO_REASONING, + REASONING_PRO_MODE, + PERSISTED_REASONING, + EXPLICIT_PROMPT_CACHING, + ], + }, }, - "gpt-5.6" | "gpt-5.6-sol" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.6 Sol".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: none, low, medium, high, xhigh, max. - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, true, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 2, 16).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![ - TEMP_REQUIRES_NO_REASONING, - REASONING_PRO_MODE, - PERSISTED_REASONING, - EXPLICIT_PROMPT_CACHING, - ], + Entry { + aliases: &["gpt-5.6"], + value: ModelDetails { + id: id("gpt-5.6-sol"), + display_name: Some("GPT-5.6 Sol".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: none, low, medium, high, xhigh, max. + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, true, + )), + knowledge_cutoff: Some(date(2026, 2, 16)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![ + TEMP_REQUIRES_NO_REASONING, + REASONING_PRO_MODE, + PERSISTED_REASONING, + EXPLICIT_PROMPT_CACHING, + ], + }, }, - "gpt-5.6-terra" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.6 Terra".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, true, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 2, 16).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![ - TEMP_REQUIRES_NO_REASONING, - REASONING_PRO_MODE, - PERSISTED_REASONING, - EXPLICIT_PROMPT_CACHING, - ], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.6-terra"), + display_name: Some("GPT-5.6 Terra".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, true, + )), + knowledge_cutoff: Some(date(2026, 2, 16)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![ + TEMP_REQUIRES_NO_REASONING, + REASONING_PRO_MODE, + PERSISTED_REASONING, + EXPLICIT_PROMPT_CACHING, + ], + }, }, - "gpt-5.6-luna" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.6 Luna".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, true, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2026, 2, 16).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![ - TEMP_REQUIRES_NO_REASONING, - REASONING_PRO_MODE, - PERSISTED_REASONING, - EXPLICIT_PROMPT_CACHING, - ], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.6-luna"), + display_name: Some("GPT-5.6 Luna".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, true, + )), + knowledge_cutoff: Some(date(2026, 2, 16)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![ + TEMP_REQUIRES_NO_REASONING, + REASONING_PRO_MODE, + PERSISTED_REASONING, + EXPLICIT_PROMPT_CACHING, + ], + }, }, - "gpt-5.5" | "gpt-5.5-2026-04-23" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.5".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 12, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.5-2026-04-23"], + value: ModelDetails { + id: id("gpt-5.5"), + display_name: Some("GPT-5.5".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, false, + )), + knowledge_cutoff: Some(date(2025, 12, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.5-pro" | "gpt-5.5-pro-2026-04-23" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.5 pro".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - reasoning: Some( - ReasoningDetails::leveled(false, false, true, true, true, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 12, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING, STREAMING_UNSUPPORTED], + Entry { + aliases: &["gpt-5.5-pro-2026-04-23"], + value: ModelDetails { + id: id("gpt-5.5-pro"), + display_name: Some("GPT-5.5 pro".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + reasoning: Some( + ReasoningDetails::leveled(false, false, true, true, true, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 12, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING, STREAMING_UNSUPPORTED], + }, }, - "gpt-5.4" | "gpt-5.4-2026-03-05" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.4".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.4-2026-03-05"], + value: ModelDetails { + id: id("gpt-5.4"), + display_name: Some("GPT-5.4".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, false, + )), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.4-pro" | "gpt-5.4-pro-2026-03-05" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.4 pro".to_owned()), - context_window: Some(1_050_000), - max_output_tokens: Some(128_000), - reasoning: Some( - ReasoningDetails::leveled(false, false, true, true, true, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.4-pro-2026-03-05"], + value: ModelDetails { + id: id("gpt-5.4-pro"), + display_name: Some("GPT-5.4 pro".to_owned()), + context_window: Some(1_050_000), + max_output_tokens: Some(128_000), + reasoning: Some( + ReasoningDetails::leveled(false, false, true, true, true, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.4-mini" | "gpt-5.4-mini-2026-03-17" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.4 mini".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.4-mini-2026-03-17"], + value: ModelDetails { + id: id("gpt-5.4-mini"), + display_name: Some("GPT-5.4 mini".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, false, + )), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.4-nano" | "gpt-5.4-nano-2026-03-17" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.4 nano".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.4-nano-2026-03-17"], + value: ModelDetails { + id: id("gpt-5.4-nano"), + display_name: Some("GPT-5.4 nano".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, false, + )), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, // Codex's ultra-fast tier, served only through a subscription. - "gpt-5.3-codex-spark" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.3 Codex Spark".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(true), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.3-codex-spark"), + display_name: Some("GPT-5.3 Codex Spark".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(true), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.3-codex" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.3 Codex".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, true, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.3-codex"), + display_name: Some("GPT-5.3 Codex".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, true, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.3-chat-latest" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.3 Chat".to_owned()), - context_window: Some(128_000), - max_output_tokens: Some(16_384), - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, true, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 8, 10).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.3-chat-latest"), + display_name: Some("GPT-5.3 Chat".to_owned()), + context_window: Some(128_000), + max_output_tokens: Some(16_384), + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, true, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 8, 10)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.2-codex" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.2 Codex".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: low, medium, high, xhigh (no none) - reasoning: Some( - ReasoningDetails::leveled(false, true, true, true, true, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.2-codex"), + display_name: Some("GPT-5.2 Codex".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: low, medium, high, xhigh (no none) + reasoning: Some( + ReasoningDetails::leveled(false, true, true, true, true, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.2-pro" | "gpt-5.2-pro-2025-12-11" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.2 pro".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some( - ReasoningDetails::leveled(false, false, true, true, true, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.2-pro-2025-12-11"], + value: ModelDetails { + id: id("gpt-5.2-pro"), + display_name: Some("GPT-5.2 pro".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some( + ReasoningDetails::leveled(false, false, true, true, true, false).always_on(), + ), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.2" | "gpt-5.2-2025-12-11" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.2".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: none (default), low, medium, high, xhigh - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.2-2025-12-11"], + value: ModelDetails { + id: id("gpt-5.2"), + display_name: Some("GPT-5.2".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: none (default), low, medium, high, xhigh + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, false, + )), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.2-chat-latest" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.2 Chat".to_owned()), - context_window: Some(128_000), - max_output_tokens: Some(16_384), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, true, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2025, 8, 31).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 8, 10).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.2-chat-latest"), + display_name: Some("GPT-5.2 Chat".to_owned()), + context_window: Some(128_000), + max_output_tokens: Some(16_384), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, true, false, + )), + knowledge_cutoff: Some(date(2025, 8, 31)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 8, 10)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.1-codex-max" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.1-Codex-Max".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.1-codex-max"), + display_name: Some("GPT-5.1-Codex-Max".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.1-codex" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.1 Codex".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.1-codex"), + display_name: Some("GPT-5.1 Codex".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.1-codex-mini" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.1 Codex mini".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.4-mini", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.1-codex-mini"), + display_name: Some("GPT-5.1 Codex mini".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.4-mini", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.1" | "gpt-5.1-2025-11-13" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.1".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: none (default), low, medium, high - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, false, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5.1-2025-11-13"], + value: ModelDetails { + id: id("gpt-5.1"), + display_name: Some("GPT-5.1".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: none (default), low, medium, high + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, false, false, + )), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5.1-chat-latest" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5.1 Chat".to_owned()), - context_window: Some(128_000), - max_output_tokens: Some(16_384), - reasoning: Some(ReasoningDetails::leveled( - false, true, true, true, false, false, - )), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5.1-chat-latest"), + display_name: Some("GPT-5.1 Chat".to_owned()), + context_window: Some(128_000), + max_output_tokens: Some(16_384), + reasoning: Some(ReasoningDetails::leveled( + false, true, true, true, false, false, + )), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5-codex" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5-Codex".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5-codex"), + display_name: Some("GPT-5-Codex".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: minimal, low, medium, high - reasoning: Some( - ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - // Deprecated without an announced retirement date; only the - // 2025-08-07 snapshot has a scheduled shutdown (2026-12-11). - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - None, - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5"), + display_name: Some("GPT-5".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: minimal, low, medium, high + reasoning: Some( + ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2024, 9, 30)), + // Deprecated without an announced retirement date; only the + // 2025-08-07 snapshot has a scheduled shutdown (2026-12-11). + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + None, + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5-2025-08-07" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - // Reasoning.effort supports: minimal, low, medium, high - reasoning: Some( - ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 12, 11).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5-2025-08-07"), + display_name: Some("GPT-5".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + // Reasoning.effort supports: minimal, low, medium, high + reasoning: Some( + ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 12, 11)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5-pro" | "gpt-5-pro-2025-10-06" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5 pro".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some( - ReasoningDetails::leveled(false, false, false, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5-pro", - Some(NaiveDate::from_ymd_opt(2026, 12, 11).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5-pro-2025-10-06"], + value: ModelDetails { + id: id("gpt-5-pro"), + display_name: Some("GPT-5 pro".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some( + ReasoningDetails::leveled(false, false, false, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5-pro", + Some(date(2026, 12, 11)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5-chat-latest" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5 Chat".to_owned()), - context_window: Some(128_000), - max_output_tokens: Some(16_384), - reasoning: Some( - ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), - ), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-5-chat-latest"), + display_name: Some("GPT-5 Chat".to_owned()), + context_window: Some(128_000), + max_output_tokens: Some(16_384), + reasoning: Some( + ReasoningDetails::leveled(true, true, true, true, false, false).always_on(), + ), + knowledge_cutoff: Some(date(2024, 9, 30)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5-mini" | "gpt-5-mini-2025-08-07" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5 mini".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 5, 31).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.4-mini", - Some(NaiveDate::from_ymd_opt(2026, 12, 11).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![TEMP_REQUIRES_NO_REASONING], + Entry { + aliases: &["gpt-5-mini-2025-08-07"], + value: ModelDetails { + id: id("gpt-5-mini"), + display_name: Some("GPT-5 mini".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 5, 31)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.4-mini", + Some(date(2026, 12, 11)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![TEMP_REQUIRES_NO_REASONING], + }, }, - "gpt-5-nano" | "gpt-5-nano-2025-08-07" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-5 nano".to_owned()), - context_window: Some(400_000), - max_output_tokens: Some(128_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 5, 31).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.4-nano", - Some(NaiveDate::from_ymd_opt(2026, 12, 11).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["gpt-5-nano-2025-08-07"], + value: ModelDetails { + id: id("gpt-5-nano"), + display_name: Some("GPT-5 nano".to_owned()), + context_window: Some(400_000), + max_output_tokens: Some(128_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 5, 31)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.4-nano", + Some(date(2026, 12, 11)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o4-mini" | "o4-mini-2025-04-16" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o4-mini".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.4-mini", - Some(NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o4-mini-2025-04-16"], + value: ModelDetails { + id: id("o4-mini"), + display_name: Some("o4-mini".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.4-mini", + Some(date(2026, 10, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o3-mini" | "o3-mini-2025-01-31" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o3-mini".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2023, 10, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o3-mini-2025-01-31"], + value: ModelDetails { + id: id("o3-mini"), + display_name: Some("o3-mini".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2023, 10, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 10, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o3" | "o3-2025-04-16" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o3".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 12, 11).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o3-2025-04-16"], + value: ModelDetails { + id: id("o3"), + display_name: Some("o3".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 12, 11)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o3-pro" | "o3-pro-2025-06-10" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o3-pro".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5-pro", - Some(NaiveDate::from_ymd_opt(2026, 12, 11).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o3-pro-2025-06-10"], + value: ModelDetails { + id: id("o3-pro"), + display_name: Some("o3-pro".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5-pro", + Some(date(2026, 12, 11)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o1" | "o1-2024-12-17" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o1".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2023, 10, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - Some(NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o1-2024-12-17"], + value: ModelDetails { + id: id("o1"), + display_name: Some("o1".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2023, 10, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + Some(date(2026, 10, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o1-pro" | "o1-pro-2025-03-19" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o1-pro".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2023, 10, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5-pro", - Some(NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o1-pro-2025-03-19"], + value: ModelDetails { + id: id("o1-pro"), + display_name: Some("o1-pro".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2023, 10, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5-pro", + Some(date(2026, 10, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-4.1" | "gpt-4.1-2025-04-14" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-4.1".to_owned()), - context_window: Some(1_047_576), - max_output_tokens: Some(32_768), - reasoning: Some(ReasoningDetails::unsupported()), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["gpt-4.1-2025-04-14"], + value: ModelDetails { + id: id("gpt-4.1"), + display_name: Some("GPT-4.1".to_owned()), + context_window: Some(1_047_576), + max_output_tokens: Some(32_768), + reasoning: Some(ReasoningDetails::unsupported()), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-4o" | "gpt-4o-2024-08-06" | "gpt-4o-2024-11-20" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-4o".to_owned()), - context_window: Some(128_000), - max_output_tokens: Some(16_384), - reasoning: Some(ReasoningDetails::unsupported()), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2023, 10, 1).unwrap()), - // Deprecated without an announced retirement date; only the - // 2024-05-13 snapshot has a scheduled shutdown (2026-10-23). - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5", - None, - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["gpt-4o-2024-08-06", "gpt-4o-2024-11-20"], + value: ModelDetails { + id: id("gpt-4o"), + display_name: Some("GPT-4o".to_owned()), + context_window: Some(128_000), + max_output_tokens: Some(16_384), + reasoning: Some(ReasoningDetails::unsupported()), + knowledge_cutoff: Some(date(2023, 10, 1)), + // Deprecated without an announced retirement date; only the + // 2024-05-13 snapshot has a scheduled shutdown (2026-10-23). + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5", + None, + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-4.1-nano" | "gpt-4.1-nano-2025-04-14" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-4.1 nano".to_owned()), - context_window: Some(1_047_576), - max_output_tokens: Some(32_768), - reasoning: Some(ReasoningDetails::unsupported()), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.4-nano", - Some(NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["gpt-4.1-nano-2025-04-14"], + value: ModelDetails { + id: id("gpt-4.1-nano"), + display_name: Some("GPT-4.1 nano".to_owned()), + context_window: Some(1_047_576), + max_output_tokens: Some(32_768), + reasoning: Some(ReasoningDetails::unsupported()), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.4-nano", + Some(date(2026, 10, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-4o-mini" | "gpt-4o-mini-2024-07-18" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-4o mini".to_owned()), - context_window: Some(128_000), - max_output_tokens: Some(16_384), - reasoning: Some(ReasoningDetails::unsupported()), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2023, 10, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["gpt-4o-mini-2024-07-18"], + value: ModelDetails { + id: id("gpt-4o-mini"), + display_name: Some("GPT-4o mini".to_owned()), + context_window: Some(128_000), + max_output_tokens: Some(16_384), + reasoning: Some(ReasoningDetails::unsupported()), + knowledge_cutoff: Some(date(2023, 10, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-4.1-mini" | "gpt-4.1-mini-2025-04-14" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("GPT-4.1 mini".to_owned()), - context_window: Some(1_047_576), - max_output_tokens: Some(32_768), - reasoning: Some(ReasoningDetails::unsupported()), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["gpt-4.1-mini-2025-04-14"], + value: ModelDetails { + id: id("gpt-4.1-mini"), + display_name: Some("GPT-4.1 mini".to_owned()), + context_window: Some(1_047_576), + max_output_tokens: Some(32_768), + reasoning: Some(ReasoningDetails::unsupported()), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-oss-120b" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("gpt-oss-120b".to_owned()), - context_window: Some(131_072), - max_output_tokens: Some(131_072), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-oss-120b"), + display_name: Some("gpt-oss-120b".to_owned()), + context_window: Some(131_072), + max_output_tokens: Some(131_072), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "gpt-oss-20b" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("gpt-oss-20b".to_owned()), - context_window: Some(131_072), - max_output_tokens: Some(131_072), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::Active), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &[], + value: ModelDetails { + id: id("gpt-oss-20b"), + display_name: Some("gpt-oss-20b".to_owned()), + context_window: Some(131_072), + max_output_tokens: Some(131_072), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::Active), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o3-deep-research" | "o3-deep-research-2025-06-26" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o3-deep-research".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5-pro", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o3-deep-research-2025-06-26"], + value: ModelDetails { + id: id("o3-deep-research"), + display_name: Some("o3-deep-research".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5-pro", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - "o4-mini-deep-research" | "o4-mini-deep-research-2025-06-26" => ModelDetails { - id: (PROVIDER, model.id).try_into()?, - display_name: Some("o4-mini-deep-research".to_owned()), - context_window: Some(200_000), - max_output_tokens: Some(100_000), - reasoning: Some(ReasoningDetails::budgetted(0, None)), - knowledge_cutoff: Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap()), - deprecated: Some(ModelDeprecation::deprecated( - &"recommended replacement: gpt-5.5-pro", - Some(NaiveDate::from_ymd_opt(2026, 7, 23).unwrap()), - )), - structured_output: None, - prefill: None, - subscription: Some(false), - features: vec![], + Entry { + aliases: &["o4-mini-deep-research-2025-06-26"], + value: ModelDetails { + id: id("o4-mini-deep-research"), + display_name: Some("o4-mini-deep-research".to_owned()), + context_window: Some(200_000), + max_output_tokens: Some(100_000), + reasoning: Some(ReasoningDetails::budgetted(0, None)), + knowledge_cutoff: Some(date(2024, 6, 1)), + deprecated: Some(ModelDeprecation::deprecated( + &"recommended replacement: gpt-5.5-pro", + Some(date(2026, 7, 23)), + )), + structured_output: None, + prefill: None, + subscription: Some(false), + features: vec![], + }, }, - _ => return Ok(None), - }; - - Ok(Some(details)) -} + ]) +}); /// Filter out unknown event types from the OpenAI SSE stream. /// diff --git a/crates/jp_llm/src/provider/openai_tests.rs b/crates/jp_llm/src/provider/openai_tests.rs index 8b8cf2a12..bde72c001 100644 --- a/crates/jp_llm/src/provider/openai_tests.rs +++ b/crates/jp_llm/src/provider/openai_tests.rs @@ -977,8 +977,9 @@ mod map_model { use chrono::{TimeZone as _, Utc}; use super::super::{ - EXPLICIT_PROMPT_CACHING, ModelResponse, PERSISTED_REASONING, REASONING_PRO_MODE, - STREAMING_UNSUPPORTED, TEMP_REQUIRES_NO_REASONING, map_model, + EXPLICIT_PROMPT_CACHING, MODEL_OVERRIDES, ModelResponse, PERSISTED_REASONING, + REASONING_PRO_MODE, STREAMING_UNSUPPORTED, TEMP_REQUIRES_NO_REASONING, is_api_only, + map_model, subscription_models, }; use crate::model::{ModelDeprecation, ReasoningDetails}; @@ -991,6 +992,45 @@ mod map_model { } } + /// No endpoint enumerates a plan's models, so this is exactly what a + /// subscription credential lists. + #[test] + fn subscription_lists_the_models_the_catalog_marks_as_served() { + assert_eq!(subscription_models().collect::>(), vec![ + "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.3-codex-spark", + ]); + } + + #[test] + fn model_override_ids_are_unique() { + assert_eq!(MODEL_OVERRIDES.duplicate_id(), None); + } + + /// An alias resolves to its entry but keeps the id the caller named. + #[test] + fn an_alias_reports_the_requested_id() { + let details = map_model(model("gpt-5.6")).unwrap(); + + assert_eq!(details.id.name.to_string(), "gpt-5.6"); + assert_eq!(details.display_name.as_deref(), Some("GPT-5.6 Sol")); + } + + /// Only a model the catalog marks API-only is refused; an unknown one may + /// be served by a plan newer than this binary. + #[test] + fn only_cataloged_api_only_models_are_api_only() { + assert!(is_api_only("gpt-5.5-pro")); + assert!(!is_api_only("gpt-6-luna")); + assert!(!is_api_only("gpt-9-unreleased")); + } + #[test] fn gpt_6_astra_uses_latest_metadata() { let details = map_model(model("gpt-6-astra")).unwrap();