Skip to main content

adk_model/
catalog.rs

1//! Curated model identifiers, lifecycle metadata, and provider defaults.
2//!
3//! Provider catalogs change independently and often expose deployment-scoped or
4//! account-scoped identifiers. This module therefore distinguishes curated ADK
5//! recommendations from exhaustive provider discovery:
6//!
7//! - [`crate::catalog::recommended_model`] returns a stable ADK default only where a portable
8//!   provider-level model ID exists.
9//! - [`crate::catalog::lookup_model`] describes model IDs whose lifecycle ADK knows about.
10//! - [`crate::catalog::validate_model_selection`] accepts unknown IDs so private, fine-tuned,
11//!   newly released, and deployment-scoped models remain usable, but rejects
12//!   identifiers known to be retired.
13//!
14//! Azure AI, Amazon Bedrock, and Volcano Engine Ark intentionally have no
15//! universal default. Their model or deployment identifiers vary by resource,
16//! region, account, or endpoint and must be supplied explicitly.
17
18use serde::{Deserialize, Serialize};
19
20/// Date on which the bundled catalog was verified against provider documentation.
21pub const CATALOG_AS_OF: &str = "2026-08-23";
22
23/// Recommended Google Gemini model for general agent workloads.
24pub const GEMINI_DEFAULT: &str = "gemini-3.7-flash";
25/// Recommended OpenAI model balancing capability, latency, and cost.
26pub const OPENAI_DEFAULT: &str = "gpt-5.6-terra";
27/// Recommended Anthropic model balancing capability, latency, and cost.
28pub const ANTHROPIC_DEFAULT: &str = "claude-sonnet-5";
29/// Recommended DeepSeek model for general agent workloads.
30pub const DEEPSEEK_DEFAULT: &str = "deepseek-v4-flash";
31/// Recommended Groq production model.
32pub const GROQ_DEFAULT: &str = "openai/gpt-oss-120b";
33/// Suggested local Ollama model. The model must already be installed locally.
34pub const OLLAMA_DEFAULT: &str = "qwen3.5";
35/// Recommended OpenRouter model. Callers should use model discovery for user-facing pickers.
36pub const OPENROUTER_DEFAULT: &str = "qwen/qwen3.7-max";
37/// Recommended Fireworks balanced model.
38pub const FIREWORKS_DEFAULT: &str = "accounts/fireworks/models/kimi-k2p6";
39/// Recommended Together AI balanced model.
40pub const TOGETHER_DEFAULT: &str = "MiniMaxAI/MiniMax-M2.7";
41/// Recommended Mistral general-purpose model alias.
42pub const MISTRAL_DEFAULT: &str = "mistral-medium-latest";
43/// Recommended Perplexity Sonar model.
44pub const PERPLEXITY_DEFAULT: &str = "sonar-pro";
45/// Recommended Cerebras production model.
46pub const CEREBRAS_DEFAULT: &str = "gpt-oss-120b";
47/// Recommended SambaNova production model.
48pub const SAMBANOVA_DEFAULT: &str = "gpt-oss-120b";
49/// Recommended xAI model.
50pub const XAI_DEFAULT: &str = "grok-4.6";
51/// Recommended MiniMax model. Model IDs are case-sensitive.
52pub const MINIMAX_DEFAULT: &str = "MiniMax-M2.7";
53/// Recommended Zhipu model.
54pub const ZHIPU_DEFAULT: &str = "glm-5.2";
55/// Recommended Baidu Qianfan model.
56pub const BAIDU_DEFAULT: &str = "ernie-5.1";
57/// Recommended Cohere model.
58pub const COHERE_DEFAULT: &str = "command-a-plus-05-2026";
59
60/// Recommended OpenAI Realtime model.
61pub const OPENAI_REALTIME_DEFAULT: &str = "gpt-realtime-2.1";
62/// Recommended Gemini Live model.
63pub const GEMINI_LIVE_DEFAULT: &str = "gemini-3.1-flash-live-preview";
64/// Recommended OpenAI live transcription model.
65pub const OPENAI_LIVE_TRANSCRIPTION_DEFAULT: &str = "gpt-live-transcribe";
66/// Recommended Gemini speech-to-text model.
67pub const GEMINI_TRANSCRIPTION_DEFAULT: &str = GEMINI_DEFAULT;
68/// Recommended Deepgram speech-to-text model.
69pub const DEEPGRAM_DEFAULT: &str = "nova-3";
70/// Recommended Cartesia text-to-speech model.
71pub const CARTESIA_DEFAULT: &str = "sonic-3.5";
72/// Recommended Gemini text-to-speech model.
73pub const GEMINI_TTS_DEFAULT: &str = "gemini-3.1-flash-tts-preview";
74/// Recommended Gemini embedding model.
75pub const GEMINI_EMBEDDING_DEFAULT: &str = "gemini-embedding-2";
76/// Recommended OpenAI embedding model.
77pub const OPENAI_EMBEDDING_DEFAULT: &str = "text-embedding-3-small";
78
79/// Lifecycle state for a model identifier.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub enum ModelLifecycle {
83    /// Generally available and suitable for production defaults.
84    Active,
85    /// Available as a preview and subject to shorter lifecycle guarantees.
86    Preview,
87    /// Still available in at least one supported tier, but migration is recommended.
88    Deprecated,
89    /// No longer available on the provider surface represented by the entry.
90    Retired,
91}
92
93/// Intended workload role of a curated model.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub enum ModelRole {
97    /// Balanced default for general agent workloads.
98    Balanced,
99    /// Highest-quality or flagship workload tier.
100    Flagship,
101    /// Cost- or latency-oriented workload tier.
102    Economy,
103    /// Low-latency bidirectional audio model.
104    Realtime,
105    /// Speech recognition or transcription model.
106    Transcription,
107    /// Speech generation model.
108    Speech,
109    /// Embedding model.
110    Embedding,
111    /// Image generation model.
112    Image,
113}
114
115/// One curated model-catalog entry.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ModelCatalogEntry {
119    /// Provider machine identifier.
120    pub provider: &'static str,
121    /// Exact provider model identifier.
122    pub model: &'static str,
123    /// Intended workload role.
124    pub role: ModelRole,
125    /// Current lifecycle state.
126    pub lifecycle: ModelLifecycle,
127    /// Recommended replacement for deprecated or retired entries.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub replacement: Option<&'static str>,
130    /// Known shutdown date in ISO 8601 form.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub shutdown_date: Option<&'static str>,
133    /// Whether ADK uses this entry as the provider's portable default.
134    pub recommended_default: bool,
135}
136
137impl ModelCatalogEntry {
138    const fn active(provider: &'static str, model: &'static str, role: ModelRole) -> Self {
139        Self {
140            provider,
141            model,
142            role,
143            lifecycle: ModelLifecycle::Active,
144            replacement: None,
145            shutdown_date: None,
146            recommended_default: false,
147        }
148    }
149
150    const fn default(provider: &'static str, model: &'static str, role: ModelRole) -> Self {
151        Self { recommended_default: true, ..Self::active(provider, model, role) }
152    }
153
154    const fn preview(provider: &'static str, model: &'static str, role: ModelRole) -> Self {
155        Self {
156            provider,
157            model,
158            role,
159            lifecycle: ModelLifecycle::Preview,
160            replacement: None,
161            shutdown_date: None,
162            recommended_default: false,
163        }
164    }
165
166    const fn default_preview(provider: &'static str, model: &'static str, role: ModelRole) -> Self {
167        Self { recommended_default: true, ..Self::preview(provider, model, role) }
168    }
169
170    const fn obsolete(
171        provider: &'static str,
172        model: &'static str,
173        lifecycle: ModelLifecycle,
174        replacement: &'static str,
175        shutdown_date: Option<&'static str>,
176    ) -> Self {
177        Self {
178            provider,
179            model,
180            role: ModelRole::Balanced,
181            lifecycle,
182            replacement: Some(replacement),
183            shutdown_date,
184            recommended_default: false,
185        }
186    }
187}
188
189/// Providers known to the catalog, including deployment-scoped providers.
190pub const KNOWN_PROVIDERS: &[&str] = &[
191    "gemini",
192    "openai",
193    "anthropic",
194    "deepseek",
195    "groq",
196    "ollama",
197    "openrouter",
198    "fireworks",
199    "together",
200    "mistral",
201    "perplexity",
202    "cerebras",
203    "sambanova",
204    "xai",
205    "minimax",
206    "zhipu",
207    "baidu",
208    "cohere",
209    "azure-ai",
210    "bedrock",
211    "bytedance",
212    "openai-realtime",
213    "gemini-live",
214    "openai-transcription",
215    "gemini-transcription",
216    "deepgram",
217    "cartesia",
218    "gemini-tts",
219    "gemini-embedding",
220    "openai-embedding",
221];
222
223/// Curated model entries bundled with ADK-Rust.
224pub const MODEL_CATALOG: &[ModelCatalogEntry] = &[
225    ModelCatalogEntry::default("gemini", GEMINI_DEFAULT, ModelRole::Balanced),
226    ModelCatalogEntry::active("gemini", "gemini-3.6-flash", ModelRole::Balanced),
227    ModelCatalogEntry::active("gemini", "gemini-3.5-flash", ModelRole::Balanced),
228    ModelCatalogEntry::active("gemini", "gemini-3.5-flash-lite", ModelRole::Economy),
229    ModelCatalogEntry::obsolete(
230        "gemini",
231        "gemini-3.1-flash-lite",
232        ModelLifecycle::Deprecated,
233        "gemini-3.5-flash-lite",
234        Some("2027-05-07"),
235    ),
236    ModelCatalogEntry::preview("gemini", "gemini-3.1-pro-preview", ModelRole::Flagship),
237    ModelCatalogEntry::active("gemini", "gemini-3.1-flash-image", ModelRole::Image),
238    ModelCatalogEntry::active("gemini", "gemini-3-pro-image", ModelRole::Image),
239    ModelCatalogEntry::default("gemini-embedding", GEMINI_EMBEDDING_DEFAULT, ModelRole::Embedding),
240    ModelCatalogEntry::default("openai", OPENAI_DEFAULT, ModelRole::Balanced),
241    ModelCatalogEntry::active("openai", "gpt-5.6-sol", ModelRole::Flagship),
242    ModelCatalogEntry::active("openai", "gpt-5.6-luna", ModelRole::Economy),
243    ModelCatalogEntry::active("openai", "gpt-5.6", ModelRole::Flagship),
244    ModelCatalogEntry::default("anthropic", ANTHROPIC_DEFAULT, ModelRole::Balanced),
245    ModelCatalogEntry::active("anthropic", "claude-opus-5", ModelRole::Flagship),
246    ModelCatalogEntry::active("anthropic", "claude-fable-5", ModelRole::Flagship),
247    ModelCatalogEntry::active("anthropic", "claude-haiku-4-5", ModelRole::Economy),
248    ModelCatalogEntry::default("deepseek", DEEPSEEK_DEFAULT, ModelRole::Balanced),
249    ModelCatalogEntry::active("deepseek", "deepseek-v4-pro", ModelRole::Flagship),
250    ModelCatalogEntry::default("groq", GROQ_DEFAULT, ModelRole::Balanced),
251    ModelCatalogEntry::active("groq", "openai/gpt-oss-20b", ModelRole::Economy),
252    ModelCatalogEntry::default("ollama", OLLAMA_DEFAULT, ModelRole::Balanced),
253    ModelCatalogEntry::default("openrouter", OPENROUTER_DEFAULT, ModelRole::Balanced),
254    ModelCatalogEntry::default("fireworks", FIREWORKS_DEFAULT, ModelRole::Balanced),
255    ModelCatalogEntry::active(
256        "fireworks",
257        "accounts/fireworks/models/kimi-k3",
258        ModelRole::Flagship,
259    ),
260    ModelCatalogEntry::default("together", TOGETHER_DEFAULT, ModelRole::Balanced),
261    ModelCatalogEntry::default("mistral", MISTRAL_DEFAULT, ModelRole::Balanced),
262    ModelCatalogEntry::default("perplexity", PERPLEXITY_DEFAULT, ModelRole::Balanced),
263    ModelCatalogEntry::default("cerebras", CEREBRAS_DEFAULT, ModelRole::Balanced),
264    ModelCatalogEntry::default("sambanova", SAMBANOVA_DEFAULT, ModelRole::Balanced),
265    ModelCatalogEntry::default("xai", XAI_DEFAULT, ModelRole::Balanced),
266    ModelCatalogEntry::default("minimax", MINIMAX_DEFAULT, ModelRole::Balanced),
267    ModelCatalogEntry::default("zhipu", ZHIPU_DEFAULT, ModelRole::Balanced),
268    ModelCatalogEntry::default("baidu", BAIDU_DEFAULT, ModelRole::Balanced),
269    ModelCatalogEntry::default("cohere", COHERE_DEFAULT, ModelRole::Balanced),
270    ModelCatalogEntry::default("openai-realtime", OPENAI_REALTIME_DEFAULT, ModelRole::Realtime),
271    ModelCatalogEntry::default_preview("gemini-live", GEMINI_LIVE_DEFAULT, ModelRole::Realtime),
272    ModelCatalogEntry::active(
273        "gemini-live",
274        "gemini-live-2.5-flash-native-audio",
275        ModelRole::Realtime,
276    ),
277    ModelCatalogEntry::default(
278        "openai-transcription",
279        OPENAI_LIVE_TRANSCRIPTION_DEFAULT,
280        ModelRole::Transcription,
281    ),
282    ModelCatalogEntry::default(
283        "gemini-transcription",
284        GEMINI_TRANSCRIPTION_DEFAULT,
285        ModelRole::Transcription,
286    ),
287    ModelCatalogEntry::default("deepgram", DEEPGRAM_DEFAULT, ModelRole::Transcription),
288    ModelCatalogEntry::default("cartesia", CARTESIA_DEFAULT, ModelRole::Speech),
289    ModelCatalogEntry::default_preview("gemini-tts", GEMINI_TTS_DEFAULT, ModelRole::Speech),
290    ModelCatalogEntry::default("openai-embedding", OPENAI_EMBEDDING_DEFAULT, ModelRole::Embedding),
291    ModelCatalogEntry::obsolete(
292        "gemini",
293        "gemini-3.1-flash-lite-preview",
294        ModelLifecycle::Retired,
295        "gemini-3.1-flash-lite",
296        Some("2026-05-25"),
297    ),
298    ModelCatalogEntry::obsolete(
299        "gemini",
300        "gemini-3-flash-preview",
301        ModelLifecycle::Deprecated,
302        "gemini-3.6-flash",
303        None,
304    ),
305    ModelCatalogEntry::obsolete(
306        "gemini",
307        "gemini-3-pro-preview",
308        ModelLifecycle::Retired,
309        "gemini-3.1-pro-preview",
310        Some("2026-03-09"),
311    ),
312    ModelCatalogEntry::obsolete(
313        "gemini",
314        "gemini-3.1-flash-image-preview",
315        ModelLifecycle::Retired,
316        "gemini-3.1-flash-image",
317        Some("2026-06-25"),
318    ),
319    ModelCatalogEntry::obsolete(
320        "gemini",
321        "gemini-3-pro-image-preview",
322        ModelLifecycle::Retired,
323        "gemini-3-pro-image",
324        Some("2026-06-25"),
325    ),
326    ModelCatalogEntry::obsolete(
327        "gemini",
328        "gemini-2.0-flash",
329        ModelLifecycle::Retired,
330        "gemini-3.6-flash",
331        Some("2026-06-01"),
332    ),
333    ModelCatalogEntry::obsolete(
334        "gemini",
335        "gemini-2.0-flash-001",
336        ModelLifecycle::Retired,
337        "gemini-3.6-flash",
338        Some("2026-06-01"),
339    ),
340    ModelCatalogEntry::obsolete(
341        "gemini",
342        "gemini-2.0-flash-lite",
343        ModelLifecycle::Retired,
344        "gemini-3.1-flash-lite",
345        Some("2026-06-01"),
346    ),
347    ModelCatalogEntry::obsolete(
348        "gemini",
349        "gemini-2.0-flash-lite-001",
350        ModelLifecycle::Retired,
351        "gemini-3.1-flash-lite",
352        Some("2026-06-01"),
353    ),
354    ModelCatalogEntry::obsolete(
355        "gemini",
356        "gemini-2.5-flash-image-preview",
357        ModelLifecycle::Retired,
358        "gemini-3.1-flash-image",
359        Some("2026-01-15"),
360    ),
361    ModelCatalogEntry::obsolete(
362        "groq",
363        "llama-3.3-70b-versatile",
364        ModelLifecycle::Deprecated,
365        GROQ_DEFAULT,
366        Some("2026-08-16"),
367    ),
368    ModelCatalogEntry::obsolete(
369        "groq",
370        "llama-3.1-8b-instant",
371        ModelLifecycle::Deprecated,
372        "openai/gpt-oss-20b",
373        Some("2026-08-16"),
374    ),
375    ModelCatalogEntry::obsolete(
376        "groq",
377        "meta-llama/llama-4-scout-17b-16e-instruct",
378        ModelLifecycle::Deprecated,
379        GROQ_DEFAULT,
380        Some("2026-07-17"),
381    ),
382    ModelCatalogEntry::obsolete(
383        "groq",
384        "qwen/qwen3-32b",
385        ModelLifecycle::Deprecated,
386        GROQ_DEFAULT,
387        Some("2026-07-17"),
388    ),
389    ModelCatalogEntry::obsolete(
390        "deepseek",
391        "deepseek-chat",
392        ModelLifecycle::Retired,
393        DEEPSEEK_DEFAULT,
394        Some("2026-07-24"),
395    ),
396    ModelCatalogEntry::obsolete(
397        "deepseek",
398        "deepseek-reasoner",
399        ModelLifecycle::Retired,
400        "deepseek-v4-pro",
401        Some("2026-07-24"),
402    ),
403    ModelCatalogEntry::obsolete(
404        "cerebras",
405        "llama-3.3-70b",
406        ModelLifecycle::Retired,
407        CEREBRAS_DEFAULT,
408        None,
409    ),
410    ModelCatalogEntry::obsolete(
411        "cartesia",
412        "sonic-2",
413        ModelLifecycle::Deprecated,
414        CARTESIA_DEFAULT,
415        None,
416    ),
417    ModelCatalogEntry::obsolete(
418        "gemini-live",
419        "gemini-2.5-flash-native-audio-preview-12-2025",
420        ModelLifecycle::Deprecated,
421        GEMINI_LIVE_DEFAULT,
422        None,
423    ),
424    ModelCatalogEntry::obsolete(
425        "gemini-live",
426        "gemini-live-2.5-flash-preview",
427        ModelLifecycle::Retired,
428        GEMINI_LIVE_DEFAULT,
429        Some("2025-12-09"),
430    ),
431    ModelCatalogEntry::obsolete(
432        "minimax",
433        "minimax-m2.7",
434        ModelLifecycle::Retired,
435        MINIMAX_DEFAULT,
436        None,
437    ),
438    ModelCatalogEntry::obsolete("baidu", "ernie-5", ModelLifecycle::Retired, BAIDU_DEFAULT, None),
439];
440
441/// Return the curated portable default for a provider.
442///
443/// Returns `None` for deployment-scoped providers (`azure-ai`, `bedrock`, and
444/// `bytedance`) and unknown providers.
445pub fn recommended_model(provider: &str) -> Option<&'static str> {
446    match provider {
447        "gemini" => Some(GEMINI_DEFAULT),
448        "openai" => Some(OPENAI_DEFAULT),
449        "anthropic" => Some(ANTHROPIC_DEFAULT),
450        "deepseek" => Some(DEEPSEEK_DEFAULT),
451        "groq" => Some(GROQ_DEFAULT),
452        "ollama" => Some(OLLAMA_DEFAULT),
453        "openrouter" => Some(OPENROUTER_DEFAULT),
454        "fireworks" => Some(FIREWORKS_DEFAULT),
455        "together" => Some(TOGETHER_DEFAULT),
456        "mistral" => Some(MISTRAL_DEFAULT),
457        "perplexity" => Some(PERPLEXITY_DEFAULT),
458        "cerebras" => Some(CEREBRAS_DEFAULT),
459        "sambanova" => Some(SAMBANOVA_DEFAULT),
460        "xai" => Some(XAI_DEFAULT),
461        "minimax" => Some(MINIMAX_DEFAULT),
462        "zhipu" => Some(ZHIPU_DEFAULT),
463        "baidu" => Some(BAIDU_DEFAULT),
464        "cohere" => Some(COHERE_DEFAULT),
465        "openai-realtime" => Some(OPENAI_REALTIME_DEFAULT),
466        "gemini-live" => Some(GEMINI_LIVE_DEFAULT),
467        "openai-transcription" => Some(OPENAI_LIVE_TRANSCRIPTION_DEFAULT),
468        "gemini-transcription" => Some(GEMINI_TRANSCRIPTION_DEFAULT),
469        "deepgram" => Some(DEEPGRAM_DEFAULT),
470        "cartesia" => Some(CARTESIA_DEFAULT),
471        "gemini-tts" => Some(GEMINI_TTS_DEFAULT),
472        "gemini-embedding" => Some(GEMINI_EMBEDDING_DEFAULT),
473        "openai-embedding" => Some(OPENAI_EMBEDDING_DEFAULT),
474        _ => None,
475    }
476}
477
478/// Return whether a provider requires an account-, endpoint-, or deployment-specific model ID.
479pub fn requires_explicit_model(provider: &str) -> bool {
480    matches!(provider, "azure-ai" | "bedrock" | "bytedance")
481}
482
483/// Look up lifecycle metadata for a provider model ID.
484///
485/// Gemini's optional `models/` resource prefix is ignored for catalog lookup.
486pub fn lookup_model(provider: &str, model: &str) -> Option<&'static ModelCatalogEntry> {
487    let normalized = if provider == "gemini" || provider == "gemini-live" {
488        model.strip_prefix("models/").unwrap_or(model)
489    } else {
490        model
491    };
492    MODEL_CATALOG.iter().find(|entry| entry.provider == provider && entry.model == normalized)
493}
494
495/// Validate a model selection without preventing newly released or private IDs.
496///
497/// Unknown IDs are accepted deliberately. Known retired IDs return an error;
498/// known deprecated IDs remain usable so provider-plan exceptions and staged
499/// migrations are not broken.
500pub fn validate_model_selection(provider: &str, model: &str) -> adk_core::Result<()> {
501    if model.trim().is_empty() {
502        return Err(adk_core::AdkError::new(
503            adk_core::ErrorComponent::Model,
504            adk_core::ErrorCategory::InvalidInput,
505            "model.catalog.empty_model",
506            format!(
507                "model ID for provider '{provider}' is empty; pass an explicit model or deployment ID"
508            ),
509        )
510        .with_provider(provider));
511    }
512    if let Some(entry) = lookup_model(provider, model)
513        && entry.lifecycle == ModelLifecycle::Retired
514    {
515        let replacement = entry.replacement.unwrap_or("a current provider model");
516        return Err(adk_core::AdkError::new(
517            adk_core::ErrorComponent::Model,
518            adk_core::ErrorCategory::InvalidInput,
519            "model.catalog.retired_model",
520            format!(
521                "model '{model}' is retired for provider '{provider}'; use '{replacement}' instead"
522            ),
523        )
524        .with_provider(provider));
525    }
526    Ok(())
527}
528
529/// Emit a structured warning for a known deprecated or retired model.
530///
531/// Runtime constructors call this advisory helper instead of rejecting IDs to
532/// preserve existing applications and provider-plan exceptions. Scaffolding
533/// and validation tools should use [`crate::catalog::validate_model_selection`] to prevent new
534/// projects from starting on a retired model.
535pub fn warn_if_obsolete(provider: &str, model: &str) {
536    if let Some(entry) = lookup_model(provider, model)
537        && matches!(entry.lifecycle, ModelLifecycle::Deprecated | ModelLifecycle::Retired)
538    {
539        tracing::warn!(
540            provider,
541            model,
542            lifecycle = ?entry.lifecycle,
543            replacement = entry.replacement,
544            shutdown_date = entry.shutdown_date,
545            "configured model is obsolete"
546        );
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use std::collections::HashSet;
554
555    #[test]
556    fn entries_are_unique() {
557        let mut seen = HashSet::new();
558        for entry in MODEL_CATALOG {
559            assert!(
560                seen.insert((entry.provider, entry.model)),
561                "duplicate catalog entry: {entry:?}"
562            );
563        }
564    }
565
566    #[test]
567    fn every_portable_default_is_active_and_catalogued() {
568        for provider in KNOWN_PROVIDERS {
569            let Some(model) = recommended_model(provider) else {
570                assert!(requires_explicit_model(provider));
571                continue;
572            };
573            let entry = lookup_model(provider, model)
574                .unwrap_or_else(|| panic!("default {provider}/{model} is missing from catalog"));
575            assert!(
576                matches!(entry.lifecycle, ModelLifecycle::Active | ModelLifecycle::Preview),
577                "obsolete default: {entry:?}"
578            );
579            assert!(entry.recommended_default, "default entry is not marked as default: {entry:?}");
580        }
581    }
582
583    #[test]
584    fn unknown_and_private_models_remain_usable() {
585        assert!(validate_model_selection("openai", "ft:gpt-private:team:model").is_ok());
586        assert!(validate_model_selection("azure-ai", "my-production-deployment").is_ok());
587    }
588
589    #[test]
590    fn retired_model_reports_replacement() {
591        let error = validate_model_selection("gemini", "models/gemini-2.0-flash")
592            .expect_err("retired model must be rejected by explicit validation");
593        assert!(error.to_string().contains("gemini-3.6-flash"));
594    }
595
596    #[test]
597    fn live_catalog_distinguishes_vertex_ga_from_retired_studio_model() {
598        let vertex = lookup_model("gemini-live", "models/gemini-live-2.5-flash-native-audio")
599            .expect("Vertex Live GA model should be catalogued");
600        assert_eq!(vertex.lifecycle, ModelLifecycle::Active);
601
602        let error = validate_model_selection("gemini-live", "gemini-live-2.5-flash-preview")
603            .expect_err("retired AI Studio Live model must be rejected");
604        assert!(error.to_string().contains(GEMINI_LIVE_DEFAULT));
605    }
606
607    #[test]
608    fn catalog_is_serializable() {
609        let value = serde_json::to_value(MODEL_CATALOG).expect("catalog should serialize");
610        assert!(value.as_array().is_some_and(|entries| !entries.is_empty()));
611    }
612}