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