Skip to main content

imp_llm/
model.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::provider::Provider;
7
8/// How a provider's API should be called.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ApiStyle {
11    /// Native Anthropic Messages API.
12    Anthropic,
13    /// Native OpenAI Responses API.
14    OpenAi,
15    /// ChatGPT/Codex-backed OpenAI Responses API.
16    OpenAiCodex,
17    /// Native Google Gemini API.
18    Google,
19    /// OpenAI-compatible Chat Completions API (DeepSeek, Groq, etc.).
20    OpenAiCompat,
21}
22
23/// Metadata about an LLM provider.
24#[derive(Debug, Clone)]
25pub struct ProviderMeta {
26    /// Provider identifier (e.g. "anthropic", "deepseek").
27    pub id: &'static str,
28    /// Human-readable name (e.g. "Anthropic", "DeepSeek").
29    pub name: &'static str,
30    /// Environment variable names for API key resolution, in priority order.
31    pub env_vars: &'static [&'static str],
32    /// Base URL for API requests. None for native providers that hardcode their URL.
33    pub api_base_url: Option<&'static str>,
34    /// URL where users can get an API key (shown in welcome flow).
35    pub docs_url: &'static str,
36    /// Which API protocol this provider uses.
37    pub api_style: ApiStyle,
38}
39
40/// Registry of known LLM providers.
41#[derive(Debug, Clone)]
42pub struct ProviderRegistry {
43    providers: Vec<ProviderMeta>,
44}
45
46impl ProviderRegistry {
47    /// Empty registry with no providers.
48    pub fn new() -> Self {
49        Self {
50            providers: Vec::new(),
51        }
52    }
53
54    /// Registry pre-populated with all built-in providers.
55    pub fn with_builtins() -> Self {
56        Self {
57            providers: builtin_providers(),
58        }
59    }
60
61    /// Find a provider by its id (e.g. "anthropic", "deepseek").
62    pub fn find(&self, id: &str) -> Option<&ProviderMeta> {
63        self.providers.iter().find(|p| p.id == id)
64    }
65
66    /// All registered providers.
67    pub fn list(&self) -> &[ProviderMeta] {
68        &self.providers
69    }
70}
71
72impl Default for ProviderRegistry {
73    fn default() -> Self {
74        Self::with_builtins()
75    }
76}
77
78/// Built-in provider catalogue covering all supported LLM providers.
79pub fn builtin_providers() -> Vec<ProviderMeta> {
80    vec![
81        ProviderMeta {
82            id: "anthropic",
83            name: "Anthropic",
84            env_vars: &["ANTHROPIC_API_KEY"],
85            api_base_url: None,
86            docs_url: "console.anthropic.com/settings/keys",
87            api_style: ApiStyle::Anthropic,
88        },
89        ProviderMeta {
90            id: "openai",
91            name: "OpenAI",
92            env_vars: &["OPENAI_API_KEY"],
93            api_base_url: None,
94            docs_url: "platform.openai.com/api-keys",
95            api_style: ApiStyle::OpenAi,
96        },
97        ProviderMeta {
98            id: "openai-codex",
99            name: "ChatGPT",
100            env_vars: &[],
101            api_base_url: Some("https://chatgpt.com/backend-api"),
102            docs_url: "chatgpt.com/codex",
103            api_style: ApiStyle::OpenAiCodex,
104        },
105        ProviderMeta {
106            id: "google",
107            name: "Google",
108            env_vars: &["GOOGLE_API_KEY"],
109            api_base_url: None,
110            docs_url: "aistudio.google.dev/apikey",
111            api_style: ApiStyle::Google,
112        },
113        ProviderMeta {
114            id: "deepseek",
115            name: "DeepSeek",
116            env_vars: &["DEEPSEEK_API_KEY"],
117            api_base_url: Some("https://api.deepseek.com"),
118            docs_url: "platform.deepseek.com/api_keys",
119            api_style: ApiStyle::OpenAiCompat,
120        },
121        ProviderMeta {
122            id: "moonshot",
123            name: "Moonshot / Kimi",
124            env_vars: &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
125            api_base_url: Some("https://api.moonshot.ai"),
126            docs_url: "platform.kimi.ai/console/api-keys",
127            api_style: ApiStyle::OpenAiCompat,
128        },
129        ProviderMeta {
130            id: "kimi-code",
131            name: "Kimi Code",
132            env_vars: &["KIMICODE_API_KEY"],
133            api_base_url: Some("https://api.kimi.com/coding"),
134            docs_url: "code.kimi.com",
135            api_style: ApiStyle::OpenAiCompat,
136        },
137        ProviderMeta {
138            id: "openrouter",
139            name: "OpenRouter",
140            env_vars: &["OPENROUTER_API_KEY"],
141            api_base_url: Some("https://openrouter.ai/api"),
142            docs_url: "openrouter.ai/keys",
143            api_style: ApiStyle::OpenAiCompat,
144        },
145        ProviderMeta {
146            id: "groq",
147            name: "Groq",
148            env_vars: &["GROQ_API_KEY"],
149            api_base_url: Some("https://api.groq.com/openai"),
150            docs_url: "console.groq.com/keys",
151            api_style: ApiStyle::OpenAiCompat,
152        },
153    ]
154}
155
156/// Static metadata describing a model's capabilities and pricing.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ModelMeta {
159    /// Canonical model identifier (e.g. "claude-sonnet-4-6").
160    pub id: String,
161    /// Provider that serves this model (e.g. "anthropic").
162    pub provider: String,
163    /// Human-readable display name.
164    pub name: String,
165    /// Maximum input context in tokens.
166    pub context_window: u32,
167    /// Maximum tokens the model can generate.
168    pub max_output_tokens: u32,
169    /// Per-million-token pricing.
170    pub pricing: ModelPricing,
171    /// Feature flags.
172    pub capabilities: Capabilities,
173}
174
175/// Per-million-token pricing for a model.
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct ModelPricing {
178    /// Dollars per million input tokens.
179    pub input_per_mtok: f64,
180    /// Dollars per million output tokens.
181    pub output_per_mtok: f64,
182    /// Dollars per million cache-read tokens.
183    pub cache_read_per_mtok: f64,
184    /// Dollars per million cache-write tokens.
185    pub cache_write_per_mtok: f64,
186}
187
188/// Feature flags indicating what a model supports.
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190pub struct Capabilities {
191    /// Supports extended thinking / chain-of-thought.
192    pub reasoning: bool,
193    /// Supports image inputs.
194    pub images: bool,
195    /// Supports tool/function calling.
196    pub tool_use: bool,
197}
198
199/// Resolved model ready for use (metadata + provider reference).
200pub struct Model {
201    /// Static metadata for this model.
202    pub meta: ModelMeta,
203    /// The provider that will serve requests.
204    pub provider: Arc<dyn Provider>,
205}
206
207impl std::fmt::Debug for Model {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("Model")
210            .field("meta", &self.meta)
211            .field("provider", &self.provider.id())
212            .finish()
213    }
214}
215
216/// Central index of available models with alias resolution.
217///
218/// Stores [`ModelMeta`] entries and short aliases (e.g. "sonnet" → canonical id).
219/// Create with [`ModelRegistry::with_builtins`] for a pre-populated registry.
220#[derive(Debug, Clone)]
221pub struct ModelRegistry {
222    models: Vec<ModelMeta>,
223    aliases: HashMap<String, String>,
224}
225
226impl ModelRegistry {
227    /// Empty registry with no models or aliases.
228    pub fn new() -> Self {
229        Self {
230            models: Vec::new(),
231            aliases: HashMap::new(),
232        }
233    }
234
235    /// Registry pre-populated with built-in models and aliases for
236    /// Anthropic, OpenAI, and Google.
237    pub fn with_builtins() -> Self {
238        let mut reg = Self::new();
239        for meta in builtin_models() {
240            reg.register(meta);
241        }
242        for (alias, canonical) in builtin_aliases() {
243            reg.aliases.insert(alias, canonical);
244        }
245        reg
246    }
247
248    /// Add a model to the registry.
249    pub fn register(&mut self, meta: ModelMeta) {
250        // Avoid duplicates by id.
251        if !self.models.iter().any(|m| m.id == meta.id) {
252            self.models.push(meta);
253        }
254    }
255
256    /// Register a short alias that maps to a canonical model id.
257    pub fn register_alias(&mut self, alias: impl Into<String>, canonical_id: impl Into<String>) {
258        self.aliases.insert(alias.into(), canonical_id.into());
259    }
260
261    /// Find a model by exact canonical id.
262    pub fn find(&self, id: &str) -> Option<&ModelMeta> {
263        self.models.iter().find(|m| m.id == id)
264    }
265
266    /// Resolve an alias to a model. Falls back to exact-id lookup if no alias matches.
267    pub fn find_by_alias(&self, alias: &str) -> Option<&ModelMeta> {
268        if let Some(canonical) = self.aliases.get(alias) {
269            self.find(canonical)
270        } else {
271            self.find(alias)
272        }
273    }
274
275    /// All registered models.
276    pub fn list(&self) -> &[ModelMeta] {
277        &self.models
278    }
279
280    /// Models from a specific provider.
281    pub fn list_by_provider(&self, provider: &str) -> Vec<&ModelMeta> {
282        self.models
283            .iter()
284            .filter(|m| m.provider == provider)
285            .collect()
286    }
287
288    /// Resolve a built-in model, or synthesize metadata for a custom model id.
289    pub fn resolve_meta(&self, model_name: &str, provider_hint: Option<&str>) -> Option<ModelMeta> {
290        let canonical_name = self
291            .aliases
292            .get(model_name)
293            .map(String::as_str)
294            .unwrap_or(model_name);
295        let validated_provider_hint = provider_hint
296            .filter(|provider| ProviderRegistry::with_builtins().find(provider).is_some());
297
298        if let Some(meta) = self.find(canonical_name) {
299            if let Some(provider_hint) = validated_provider_hint {
300                if provider_hint != meta.provider {
301                    return Some(synthesize_custom_model_meta(canonical_name, provider_hint));
302                }
303            }
304            return Some(meta.clone());
305        }
306
307        let provider_name =
308            validated_provider_hint.or_else(|| guess_provider_for_custom_model(canonical_name))?;
309
310        Some(synthesize_custom_model_meta(canonical_name, provider_name))
311    }
312}
313
314impl Default for ModelRegistry {
315    fn default() -> Self {
316        Self::with_builtins()
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Built-in model catalogue
322// ---------------------------------------------------------------------------
323
324fn builtin_models() -> Vec<ModelMeta> {
325    let mut models = vec![
326        // -- Anthropic --
327        // Latest: Sonnet 4.6 (released 2026-02)
328        ModelMeta {
329            id: "claude-sonnet-4-6".into(),
330            provider: "anthropic".into(),
331            name: "Claude Sonnet 4.6".into(),
332            context_window: 1_000_000,
333            max_output_tokens: 128_000,
334            pricing: ModelPricing {
335                input_per_mtok: 3.0,
336                output_per_mtok: 15.0,
337                cache_read_per_mtok: 0.3,
338                cache_write_per_mtok: 3.75,
339            },
340            capabilities: Capabilities {
341                reasoning: true,
342                images: true,
343                tool_use: true,
344            },
345        },
346        // Latest: Haiku 4.5 (released 2025-10)
347        ModelMeta {
348            id: "claude-haiku-4-5-20251001".into(),
349            provider: "anthropic".into(),
350            name: "Claude Haiku 4.5".into(),
351            context_window: 200_000,
352            max_output_tokens: 64_000,
353            pricing: ModelPricing {
354                input_per_mtok: 1.0,
355                output_per_mtok: 5.0,
356                cache_read_per_mtok: 0.1,
357                cache_write_per_mtok: 1.25,
358            },
359            capabilities: Capabilities {
360                reasoning: true,
361                images: true,
362                tool_use: true,
363            },
364        },
365        // Latest: Opus 4.6 (released 2026-02)
366        ModelMeta {
367            id: "claude-opus-4-6".into(),
368            provider: "anthropic".into(),
369            name: "Claude Opus 4.6".into(),
370            context_window: 1_000_000,
371            max_output_tokens: 128_000,
372            pricing: ModelPricing {
373                input_per_mtok: 5.0,
374                output_per_mtok: 25.0,
375                cache_read_per_mtok: 0.5,
376                cache_write_per_mtok: 6.25,
377            },
378            capabilities: Capabilities {
379                reasoning: true,
380                images: true,
381                tool_use: true,
382            },
383        },
384        // -- Google --
385        ModelMeta {
386            id: "gemini-2.5-pro".into(),
387            provider: "google".into(),
388            name: "Gemini 2.5 Pro".into(),
389            context_window: 1_048_576,
390            max_output_tokens: 65_536,
391            pricing: ModelPricing {
392                input_per_mtok: 1.25,
393                output_per_mtok: 10.0,
394                cache_read_per_mtok: 0.125,
395                cache_write_per_mtok: 1.25,
396            },
397            capabilities: Capabilities {
398                reasoning: true,
399                images: true,
400                tool_use: true,
401            },
402        },
403        ModelMeta {
404            id: "gemini-2.5-flash".into(),
405            provider: "google".into(),
406            name: "Gemini 2.5 Flash".into(),
407            context_window: 1_048_576,
408            max_output_tokens: 65_536,
409            pricing: ModelPricing {
410                input_per_mtok: 0.30,
411                output_per_mtok: 2.50,
412                cache_read_per_mtok: 0.03,
413                cache_write_per_mtok: 0.30,
414            },
415            capabilities: Capabilities {
416                reasoning: true,
417                images: true,
418                tool_use: true,
419            },
420        },
421        // -- DeepSeek --
422        ModelMeta {
423            id: "deepseek-chat".into(),
424            provider: "deepseek".into(),
425            name: "DeepSeek V3".into(),
426            context_window: 64_000,
427            max_output_tokens: 8_192,
428            pricing: ModelPricing {
429                input_per_mtok: 0.27,
430                output_per_mtok: 1.10,
431                cache_read_per_mtok: 0.07,
432                cache_write_per_mtok: 0.27,
433            },
434            capabilities: Capabilities {
435                reasoning: false,
436                images: false,
437                tool_use: true,
438            },
439        },
440        ModelMeta {
441            id: "deepseek-reasoner".into(),
442            provider: "deepseek".into(),
443            name: "DeepSeek R1".into(),
444            context_window: 64_000,
445            max_output_tokens: 8_192,
446            pricing: ModelPricing {
447                input_per_mtok: 0.55,
448                output_per_mtok: 2.19,
449                cache_read_per_mtok: 0.14,
450                cache_write_per_mtok: 0.55,
451            },
452            capabilities: Capabilities {
453                reasoning: true,
454                images: false,
455                tool_use: false,
456            },
457        },
458        // -- Moonshot / Kimi --
459        ModelMeta {
460            id: "kimi-k2.6".into(),
461            provider: "moonshot".into(),
462            name: "Kimi K2.6".into(),
463            context_window: 256_000,
464            max_output_tokens: 32_768,
465            pricing: ModelPricing::default(),
466            capabilities: Capabilities {
467                reasoning: true,
468                images: true,
469                tool_use: true,
470            },
471        },
472        ModelMeta {
473            id: "kimi-k2.5".into(),
474            provider: "moonshot".into(),
475            name: "Kimi K2.5".into(),
476            context_window: 256_000,
477            max_output_tokens: 32_768,
478            pricing: ModelPricing::default(),
479            capabilities: Capabilities {
480                reasoning: true,
481                images: true,
482                tool_use: true,
483            },
484        },
485        ModelMeta {
486            id: "kimi-k2-0905-preview".into(),
487            provider: "moonshot".into(),
488            name: "Kimi K2 0905 Preview".into(),
489            context_window: 256_000,
490            max_output_tokens: 16_384,
491            pricing: ModelPricing::default(),
492            capabilities: Capabilities {
493                reasoning: false,
494                images: false,
495                tool_use: true,
496            },
497        },
498        ModelMeta {
499            id: "kimi-k2-turbo-preview".into(),
500            provider: "moonshot".into(),
501            name: "Kimi K2 Turbo Preview".into(),
502            context_window: 256_000,
503            max_output_tokens: 16_384,
504            pricing: ModelPricing::default(),
505            capabilities: Capabilities {
506                reasoning: false,
507                images: false,
508                tool_use: true,
509            },
510        },
511        ModelMeta {
512            id: "kimi-k2-thinking".into(),
513            provider: "moonshot".into(),
514            name: "Kimi K2 Thinking".into(),
515            context_window: 256_000,
516            max_output_tokens: 32_768,
517            pricing: ModelPricing::default(),
518            capabilities: Capabilities {
519                reasoning: true,
520                images: false,
521                tool_use: true,
522            },
523        },
524        ModelMeta {
525            id: "kimi-k2-thinking-turbo".into(),
526            provider: "moonshot".into(),
527            name: "Kimi K2 Thinking Turbo".into(),
528            context_window: 256_000,
529            max_output_tokens: 32_768,
530            pricing: ModelPricing::default(),
531            capabilities: Capabilities {
532                reasoning: true,
533                images: false,
534                tool_use: true,
535            },
536        },
537        // -- Kimi Code --
538        ModelMeta {
539            id: "kimi2.6".into(),
540            provider: "kimi-code".into(),
541            name: "Kimi K2.6 Code".into(),
542            context_window: 262_144,
543            max_output_tokens: 16_384,
544            pricing: ModelPricing::default(),
545            capabilities: Capabilities {
546                reasoning: true,
547                images: true,
548                tool_use: true,
549            },
550        },
551        ModelMeta {
552            id: "kimi-for-coding".into(),
553            provider: "kimi-code".into(),
554            name: "Kimi for Coding".into(),
555            context_window: 262_144,
556            max_output_tokens: 16_384,
557            pricing: ModelPricing::default(),
558            capabilities: Capabilities {
559                reasoning: true,
560                images: true,
561                tool_use: true,
562            },
563        },
564        // -- Groq --
565        ModelMeta {
566            id: "google/gemini-3.1-flash-lite-preview".into(),
567            provider: "openrouter".into(),
568            name: "Google Gemini 3.1 Flash Lite Preview".into(),
569            context_window: 1_048_576,
570            max_output_tokens: 65_536,
571            pricing: ModelPricing::default(),
572            capabilities: Capabilities {
573                reasoning: true,
574                images: false,
575                tool_use: true,
576            },
577        },
578        ModelMeta {
579            id: "google/gemini-3-flash-preview".into(),
580            provider: "openrouter".into(),
581            name: "Google Gemini 3 Flash Preview".into(),
582            context_window: 1_048_576,
583            max_output_tokens: 65_536,
584            pricing: ModelPricing::default(),
585            capabilities: Capabilities {
586                reasoning: true,
587                images: false,
588                tool_use: true,
589            },
590        },
591        ModelMeta {
592            id: "llama-3.3-70b-versatile".into(),
593            provider: "groq".into(),
594            name: "Llama 3.3 70B".into(),
595            context_window: 128_000,
596            max_output_tokens: 32_768,
597            pricing: ModelPricing {
598                input_per_mtok: 0.59,
599                output_per_mtok: 0.79,
600                cache_read_per_mtok: 0.0,
601                cache_write_per_mtok: 0.0,
602            },
603            capabilities: Capabilities {
604                reasoning: false,
605                images: false,
606                tool_use: true,
607            },
608        },
609    ];
610
611    let openai_insert_at = models
612        .iter()
613        .take_while(|model| model.provider == "anthropic")
614        .count();
615    models.splice(openai_insert_at..openai_insert_at, builtin_openai_models());
616    models
617}
618
619pub fn builtin_openai_models() -> Vec<ModelMeta> {
620    vec![
621        ModelMeta {
622            id: "gpt-5.4".into(),
623            provider: "openai".into(),
624            name: "GPT-5.4".into(),
625            context_window: 1_050_000,
626            max_output_tokens: 128_000,
627            pricing: ModelPricing {
628                input_per_mtok: 2.5,
629                output_per_mtok: 15.0,
630                cache_read_per_mtok: 0.25,
631                cache_write_per_mtok: 2.5,
632            },
633            capabilities: Capabilities {
634                reasoning: true,
635                images: true,
636                tool_use: true,
637            },
638        },
639        ModelMeta {
640            id: "gpt-5.4-mini".into(),
641            provider: "openai".into(),
642            name: "GPT-5.4 mini".into(),
643            context_window: 400_000,
644            max_output_tokens: 128_000,
645            pricing: ModelPricing {
646                input_per_mtok: 0.75,
647                output_per_mtok: 4.5,
648                cache_read_per_mtok: 0.075,
649                cache_write_per_mtok: 0.75,
650            },
651            capabilities: Capabilities {
652                reasoning: true,
653                images: true,
654                tool_use: true,
655            },
656        },
657        ModelMeta {
658            id: "gpt-5.4-nano".into(),
659            provider: "openai".into(),
660            name: "GPT-5.4 nano".into(),
661            context_window: 400_000,
662            max_output_tokens: 128_000,
663            pricing: ModelPricing {
664                input_per_mtok: 0.20,
665                output_per_mtok: 1.25,
666                cache_read_per_mtok: 0.02,
667                cache_write_per_mtok: 0.20,
668            },
669            capabilities: Capabilities {
670                reasoning: true,
671                images: true,
672                tool_use: true,
673            },
674        },
675        ModelMeta {
676            id: "gpt-5.3-chat-latest".into(),
677            provider: "openai".into(),
678            name: "GPT-5.3 ChatGPT".into(),
679            context_window: 128_000,
680            max_output_tokens: 16_384,
681            pricing: ModelPricing {
682                input_per_mtok: 1.75,
683                output_per_mtok: 14.0,
684                cache_read_per_mtok: 0.175,
685                cache_write_per_mtok: 1.75,
686            },
687            capabilities: Capabilities {
688                reasoning: false,
689                images: true,
690                tool_use: true,
691            },
692        },
693        ModelMeta {
694            id: "gpt-5.3-codex".into(),
695            provider: "openai".into(),
696            name: "GPT-5.3 Codex".into(),
697            context_window: 400_000,
698            max_output_tokens: 128_000,
699            pricing: ModelPricing {
700                input_per_mtok: 1.75,
701                output_per_mtok: 14.0,
702                cache_read_per_mtok: 0.175,
703                cache_write_per_mtok: 1.75,
704            },
705            capabilities: Capabilities {
706                reasoning: true,
707                images: false,
708                tool_use: true,
709            },
710        },
711        ModelMeta {
712            id: "gpt-5.3-codex-spark".into(),
713            provider: "openai".into(),
714            name: "GPT-5.3 Codex Spark".into(),
715            context_window: 128_000,
716            max_output_tokens: 16_384,
717            pricing: ModelPricing::default(),
718            capabilities: Capabilities {
719                reasoning: true,
720                images: false,
721                tool_use: true,
722            },
723        },
724    ]
725}
726
727pub fn builtin_openai_codex_models() -> Vec<ModelMeta> {
728    let mut models: Vec<ModelMeta> = builtin_openai_models()
729        .into_iter()
730        .map(|mut model| {
731            model.provider = "openai-codex".into();
732            model
733        })
734        .collect();
735
736    models.push(ModelMeta {
737        id: "gpt-5.5".into(),
738        provider: "openai-codex".into(),
739        name: "GPT-5.5".into(),
740        context_window: 400_000,
741        max_output_tokens: 128_000,
742        pricing: ModelPricing::default(),
743        capabilities: Capabilities {
744            reasoning: true,
745            images: true,
746            tool_use: true,
747        },
748    });
749
750    models
751}
752
753fn guess_provider_for_custom_model(model_name: &str) -> Option<&'static str> {
754    let lower = model_name.to_lowercase();
755
756    if lower.starts_with("gpt-")
757        || lower.starts_with("chatgpt")
758        || lower.starts_with("o1")
759        || lower.starts_with("o3")
760        || lower.starts_with("o4")
761        || lower.contains("codex")
762    {
763        return Some("openai");
764    }
765
766    if lower.starts_with("claude") {
767        return Some("anthropic");
768    }
769
770    if lower.starts_with("gemini") {
771        return Some("google");
772    }
773
774    if lower.starts_with("kimi") || lower.starts_with("moonshot") {
775        return Some("moonshot");
776    }
777
778    None
779}
780
781fn synthesize_custom_model_meta(model_id: &str, provider: &str) -> ModelMeta {
782    match provider {
783        "openai" => synthesize_openai_model_meta(model_id),
784        "openai-codex" => {
785            let mut meta = synthesize_openai_model_meta(model_id);
786            meta.provider = "openai-codex".into();
787            meta
788        }
789        "anthropic" => ModelMeta {
790            id: model_id.into(),
791            provider: provider.into(),
792            name: model_id.into(),
793            context_window: 200_000,
794            max_output_tokens: 64_000,
795            pricing: ModelPricing::default(),
796            capabilities: Capabilities {
797                reasoning: true,
798                images: true,
799                tool_use: true,
800            },
801        },
802        "google" => ModelMeta {
803            id: model_id.into(),
804            provider: provider.into(),
805            name: model_id.into(),
806            context_window: 1_048_576,
807            max_output_tokens: 65_536,
808            pricing: ModelPricing::default(),
809            capabilities: Capabilities {
810                reasoning: true,
811                images: true,
812                tool_use: true,
813            },
814        },
815        "moonshot" => ModelMeta {
816            id: model_id.into(),
817            provider: provider.into(),
818            name: model_id.into(),
819            context_window: 256_000,
820            max_output_tokens: if model_id.contains("thinking")
821                || matches!(model_id, "kimi-k2.6" | "kimi-k2.5")
822            {
823                32_768
824            } else {
825                16_384
826            },
827            pricing: ModelPricing::default(),
828            capabilities: Capabilities {
829                reasoning: true,
830                images: true,
831                tool_use: true,
832            },
833        },
834        _ => ModelMeta {
835            id: model_id.into(),
836            provider: provider.into(),
837            name: model_id.into(),
838            context_window: 200_000,
839            max_output_tokens: 16_384,
840            pricing: ModelPricing::default(),
841            capabilities: Capabilities {
842                reasoning: false,
843                images: false,
844                tool_use: true,
845            },
846        },
847    }
848}
849
850fn synthesize_openai_model_meta(model_id: &str) -> ModelMeta {
851    match model_id {
852        "gpt-4o" => ModelMeta {
853            id: model_id.into(),
854            provider: "openai".into(),
855            name: "GPT-4o (legacy custom)".into(),
856            context_window: 128_000,
857            max_output_tokens: 16_384,
858            pricing: ModelPricing {
859                input_per_mtok: 2.5,
860                output_per_mtok: 10.0,
861                cache_read_per_mtok: 1.25,
862                cache_write_per_mtok: 2.5,
863            },
864            capabilities: Capabilities {
865                reasoning: false,
866                images: true,
867                tool_use: true,
868            },
869        },
870        "o3" => ModelMeta {
871            id: model_id.into(),
872            provider: "openai".into(),
873            name: "o3 (legacy custom)".into(),
874            context_window: 200_000,
875            max_output_tokens: 100_000,
876            pricing: ModelPricing {
877                input_per_mtok: 2.0,
878                output_per_mtok: 8.0,
879                cache_read_per_mtok: 0.5,
880                cache_write_per_mtok: 2.0,
881            },
882            capabilities: Capabilities {
883                reasoning: true,
884                images: true,
885                tool_use: true,
886            },
887        },
888        "o4-mini" => ModelMeta {
889            id: model_id.into(),
890            provider: "openai".into(),
891            name: "o4-mini (legacy custom)".into(),
892            context_window: 200_000,
893            max_output_tokens: 100_000,
894            pricing: ModelPricing {
895                input_per_mtok: 1.1,
896                output_per_mtok: 4.4,
897                cache_read_per_mtok: 0.275,
898                cache_write_per_mtok: 1.1,
899            },
900            capabilities: Capabilities {
901                reasoning: true,
902                images: true,
903                tool_use: true,
904            },
905        },
906        "gpt-5.3-codex-spark" => ModelMeta {
907            id: model_id.into(),
908            provider: "openai".into(),
909            name: "GPT-5.3 Codex Spark (preview)".into(),
910            context_window: 128_000,
911            max_output_tokens: 16_384,
912            pricing: ModelPricing::default(),
913            capabilities: Capabilities {
914                reasoning: true,
915                images: false,
916                tool_use: true,
917            },
918        },
919        _ if model_id.starts_with("gpt-5.3-codex") || model_id.contains("codex") => ModelMeta {
920            id: model_id.into(),
921            provider: "openai".into(),
922            name: model_id.into(),
923            context_window: 400_000,
924            max_output_tokens: 128_000,
925            pricing: ModelPricing::default(),
926            capabilities: Capabilities {
927                reasoning: true,
928                images: false,
929                tool_use: true,
930            },
931        },
932        _ if model_id.contains("chat-latest") => ModelMeta {
933            id: model_id.into(),
934            provider: "openai".into(),
935            name: model_id.into(),
936            context_window: 128_000,
937            max_output_tokens: 16_384,
938            pricing: ModelPricing::default(),
939            capabilities: Capabilities {
940                reasoning: false,
941                images: true,
942                tool_use: true,
943            },
944        },
945        _ if model_id.starts_with("gpt-5") => ModelMeta {
946            id: model_id.into(),
947            provider: "openai".into(),
948            name: model_id.into(),
949            context_window: 400_000,
950            max_output_tokens: 128_000,
951            pricing: ModelPricing::default(),
952            capabilities: Capabilities {
953                reasoning: true,
954                images: true,
955                tool_use: true,
956            },
957        },
958        _ if model_id.starts_with('o') => ModelMeta {
959            id: model_id.into(),
960            provider: "openai".into(),
961            name: model_id.into(),
962            context_window: 200_000,
963            max_output_tokens: 100_000,
964            pricing: ModelPricing::default(),
965            capabilities: Capabilities {
966                reasoning: true,
967                images: true,
968                tool_use: true,
969            },
970        },
971        _ => ModelMeta {
972            id: model_id.into(),
973            provider: "openai".into(),
974            name: model_id.into(),
975            context_window: 200_000,
976            max_output_tokens: 16_384,
977            pricing: ModelPricing::default(),
978            capabilities: Capabilities {
979                reasoning: false,
980                images: true,
981                tool_use: true,
982            },
983        },
984    }
985}
986
987fn builtin_aliases() -> Vec<(String, String)> {
988    vec![
989        // Anthropic — sonnet
990        ("sonnet".into(), "claude-sonnet-4-6".into()),
991        ("claude-sonnet".into(), "claude-sonnet-4-6".into()),
992        ("sonnet-4.6".into(), "claude-sonnet-4-6".into()),
993        // Anthropic — haiku
994        ("haiku".into(), "claude-haiku-4-5-20251001".into()),
995        ("claude-haiku".into(), "claude-haiku-4-5-20251001".into()),
996        ("haiku-4.5".into(), "claude-haiku-4-5-20251001".into()),
997        // Anthropic — opus
998        ("opus".into(), "claude-opus-4-6".into()),
999        ("claude-opus".into(), "claude-opus-4-6".into()),
1000        ("opus-4.6".into(), "claude-opus-4-6".into()),
1001        // OpenAI
1002        ("gpt5.5".into(), "gpt-5.5".into()),
1003        ("gpt-5.5".into(), "gpt-5.5".into()),
1004        ("chatgpt5.5".into(), "gpt-5.5".into()),
1005        ("chatgpt-5.5".into(), "gpt-5.5".into()),
1006        ("gpt5".into(), "gpt-5.4".into()),
1007        ("gpt5.4".into(), "gpt-5.4".into()),
1008        ("gpt-5".into(), "gpt-5.4".into()),
1009        ("gpt-5.4".into(), "gpt-5.4".into()),
1010        ("gpt5mini".into(), "gpt-5.4-mini".into()),
1011        ("gpt-5-mini".into(), "gpt-5.4-mini".into()),
1012        ("gpt5nano".into(), "gpt-5.4-nano".into()),
1013        ("gpt-5-nano".into(), "gpt-5.4-nano".into()),
1014        ("chatgpt".into(), "gpt-5.3-chat-latest".into()),
1015        ("chatgpt-latest".into(), "gpt-5.3-chat-latest".into()),
1016        ("gpt5chat".into(), "gpt-5.3-chat-latest".into()),
1017        ("codex".into(), "gpt-5.3-codex".into()),
1018        ("gpt5codex".into(), "gpt-5.3-codex".into()),
1019        ("spark".into(), "gpt-5.3-codex-spark".into()),
1020        ("codex-spark".into(), "gpt-5.3-codex-spark".into()),
1021        // Google
1022        ("gemini-pro".into(), "gemini-2.5-pro".into()),
1023        ("gemini-flash".into(), "gemini-2.5-flash".into()),
1024        // DeepSeek
1025        ("deepseek".into(), "deepseek-chat".into()),
1026        ("deepseek-v3".into(), "deepseek-chat".into()),
1027        ("deepseek-r1".into(), "deepseek-reasoner".into()),
1028        // Moonshot / Kimi
1029        ("kimi".into(), "kimi-k2.6".into()),
1030        ("kimi-k2.6".into(), "kimi-k2.6".into()),
1031        ("kimi-k2.5".into(), "kimi-k2.5".into()),
1032        ("kimi-k2".into(), "kimi-k2-0905-preview".into()),
1033        ("kimi-k2-0905".into(), "kimi-k2-0905-preview".into()),
1034        ("kimi-k2-turbo".into(), "kimi-k2-turbo-preview".into()),
1035        ("kimi-thinking".into(), "kimi-k2-thinking".into()),
1036        ("kimi-k2-thinking".into(), "kimi-k2-thinking".into()),
1037        (
1038            "kimi-thinking-turbo".into(),
1039            "kimi-k2-thinking-turbo".into(),
1040        ),
1041        (
1042            "kimi-k2-thinking-turbo".into(),
1043            "kimi-k2-thinking-turbo".into(),
1044        ),
1045        // Kimi Code
1046        ("kimi-code".into(), "kimi2.6".into()),
1047        ("kimi2.6".into(), "kimi2.6".into()),
1048        ("kimi-for-coding".into(), "kimi-for-coding".into()),
1049        // Groq
1050        ("llama-groq".into(), "llama-3.3-70b-versatile".into()),
1051    ]
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    #[test]
1059    fn find_by_alias_resolves_sonnet() {
1060        let reg = ModelRegistry::with_builtins();
1061        let model = reg
1062            .find_by_alias("sonnet")
1063            .expect("sonnet alias should resolve");
1064        assert_eq!(model.id, "claude-sonnet-4-6");
1065        assert_eq!(model.provider, "anthropic");
1066    }
1067
1068    #[test]
1069    fn find_by_alias_resolves_haiku() {
1070        let reg = ModelRegistry::with_builtins();
1071        let model = reg
1072            .find_by_alias("haiku")
1073            .expect("haiku alias should resolve");
1074        assert_eq!(model.id, "claude-haiku-4-5-20251001");
1075    }
1076
1077    #[test]
1078    fn find_by_alias_resolves_opus() {
1079        let reg = ModelRegistry::with_builtins();
1080        let model = reg
1081            .find_by_alias("opus")
1082            .expect("opus alias should resolve");
1083        assert_eq!(model.id, "claude-opus-4-6");
1084    }
1085
1086    #[test]
1087    fn find_by_alias_resolves_gpt5() {
1088        let reg = ModelRegistry::with_builtins();
1089        let model = reg
1090            .find_by_alias("gpt5")
1091            .expect("gpt5 alias should resolve");
1092        assert_eq!(model.id, "gpt-5.4");
1093    }
1094
1095    #[test]
1096    fn resolve_meta_synthesizes_gpt_5_5_alias() {
1097        let reg = ModelRegistry::with_builtins();
1098        let model = reg
1099            .resolve_meta("gpt5.5", None)
1100            .expect("gpt5.5 alias should synthesize");
1101        assert_eq!(model.id, "gpt-5.5");
1102        assert_eq!(model.provider, "openai");
1103    }
1104
1105    #[test]
1106    fn find_by_alias_resolves_chatgpt() {
1107        let reg = ModelRegistry::with_builtins();
1108        let model = reg
1109            .find_by_alias("chatgpt")
1110            .expect("chatgpt alias should resolve");
1111        assert_eq!(model.id, "gpt-5.3-chat-latest");
1112    }
1113
1114    #[test]
1115    fn find_by_alias_resolves_codex() {
1116        let reg = ModelRegistry::with_builtins();
1117        let model = reg
1118            .find_by_alias("codex")
1119            .expect("codex alias should resolve");
1120        assert_eq!(model.id, "gpt-5.3-codex");
1121    }
1122
1123    #[test]
1124    fn resolve_meta_synthesizes_spark_preview() {
1125        let reg = ModelRegistry::with_builtins();
1126        let model = reg
1127            .resolve_meta("spark", None)
1128            .expect("spark alias should synthesize");
1129        assert_eq!(model.id, "gpt-5.3-codex-spark");
1130        assert_eq!(model.provider, "openai");
1131    }
1132
1133    #[test]
1134    fn resolve_meta_synthesizes_legacy_openai_model() {
1135        let reg = ModelRegistry::with_builtins();
1136        let model = reg
1137            .resolve_meta("gpt-4o", None)
1138            .expect("legacy openai model should synthesize");
1139        assert_eq!(model.id, "gpt-4o");
1140        assert_eq!(model.provider, "openai");
1141    }
1142
1143    #[test]
1144    fn find_by_alias_resolves_gemini_pro() {
1145        let reg = ModelRegistry::with_builtins();
1146        let model = reg
1147            .find_by_alias("gemini-pro")
1148            .expect("gemini-pro alias should resolve");
1149        assert_eq!(model.id, "gemini-2.5-pro");
1150    }
1151
1152    #[test]
1153    fn find_by_alias_resolves_kimi() {
1154        let reg = ModelRegistry::with_builtins();
1155        let model = reg
1156            .find_by_alias("kimi")
1157            .expect("kimi alias should resolve");
1158        assert_eq!(model.id, "kimi-k2.6");
1159        assert_eq!(model.provider, "moonshot");
1160    }
1161
1162    #[test]
1163    fn find_by_alias_resolves_kimi_turbo() {
1164        let reg = ModelRegistry::with_builtins();
1165        let model = reg
1166            .find_by_alias("kimi-k2-turbo")
1167            .expect("kimi-k2-turbo alias should resolve");
1168        assert_eq!(model.id, "kimi-k2-turbo-preview");
1169        assert_eq!(model.provider, "moonshot");
1170    }
1171
1172    #[test]
1173    fn resolve_meta_guesses_moonshot_for_kimi_models() {
1174        let reg = ModelRegistry::with_builtins();
1175        let model = reg
1176            .resolve_meta("kimi-k2-thinking-turbo", None)
1177            .expect("kimi model should synthesize");
1178        assert_eq!(model.id, "kimi-k2-thinking-turbo");
1179        assert_eq!(model.provider, "moonshot");
1180    }
1181
1182    #[test]
1183    fn provider_registry_includes_moonshot() {
1184        let registry = ProviderRegistry::with_builtins();
1185        let provider = registry
1186            .find("moonshot")
1187            .expect("moonshot provider should exist");
1188        assert_eq!(provider.name, "Moonshot / Kimi");
1189        assert_eq!(provider.api_base_url, Some("https://api.moonshot.ai"));
1190        assert_eq!(provider.env_vars, &["MOONSHOT_API_KEY", "KIMI_API_KEY"]);
1191    }
1192
1193    #[test]
1194    fn find_by_alias_falls_back_to_exact_id() {
1195        let reg = ModelRegistry::with_builtins();
1196        let model = reg
1197            .find_by_alias("gpt-5.3-codex")
1198            .expect("exact id lookup should work as fallback");
1199        assert_eq!(model.id, "gpt-5.3-codex");
1200    }
1201
1202    #[test]
1203    fn find_by_alias_returns_none_for_unknown() {
1204        let reg = ModelRegistry::with_builtins();
1205        assert!(reg.find_by_alias("nonexistent-model").is_none());
1206    }
1207
1208    #[test]
1209    fn list_by_provider_filters_correctly() {
1210        let reg = ModelRegistry::with_builtins();
1211        let anthropic = reg.list_by_provider("anthropic");
1212        assert_eq!(anthropic.len(), 3);
1213        assert!(anthropic.iter().all(|m| m.provider == "anthropic"));
1214
1215        let openai = reg.list_by_provider("openai");
1216        assert_eq!(openai.len(), 6);
1217
1218        let google = reg.list_by_provider("google");
1219        assert_eq!(google.len(), 2);
1220
1221        let moonshot = reg.list_by_provider("moonshot");
1222        assert_eq!(moonshot.len(), 6);
1223    }
1224
1225    #[test]
1226    fn builtin_openai_codex_models_retag_openai_models() {
1227        let models = builtin_openai_codex_models();
1228        assert_eq!(models.len(), 7);
1229        assert!(models.iter().all(|model| model.provider == "openai-codex"));
1230        assert!(models.iter().any(|model| model.id == "gpt-5.5"));
1231    }
1232
1233    #[test]
1234    fn register_skips_duplicates() {
1235        let mut reg = ModelRegistry::new();
1236        let meta = ModelMeta {
1237            id: "test-model".into(),
1238            provider: "test".into(),
1239            name: "Test".into(),
1240            context_window: 1000,
1241            max_output_tokens: 100,
1242            pricing: ModelPricing::default(),
1243            capabilities: Capabilities::default(),
1244        };
1245        reg.register(meta.clone());
1246        reg.register(meta);
1247        assert_eq!(reg.list().len(), 1);
1248    }
1249}