1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::provider::Provider;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ApiStyle {
11 Anthropic,
13 OpenAi,
15 OpenAiCodex,
17 Google,
19 OpenAiCompat,
21}
22
23#[derive(Debug, Clone)]
25pub struct ProviderMeta {
26 pub id: &'static str,
28 pub name: &'static str,
30 pub env_vars: &'static [&'static str],
32 pub api_base_url: Option<&'static str>,
34 pub docs_url: &'static str,
36 pub api_style: ApiStyle,
38}
39
40#[derive(Debug, Clone)]
42pub struct ProviderRegistry {
43 providers: Vec<ProviderMeta>,
44}
45
46impl ProviderRegistry {
47 pub fn new() -> Self {
49 Self {
50 providers: Vec::new(),
51 }
52 }
53
54 pub fn with_builtins() -> Self {
56 Self {
57 providers: builtin_providers(),
58 }
59 }
60
61 pub fn find(&self, id: &str) -> Option<&ProviderMeta> {
63 self.providers.iter().find(|p| p.id == id)
64 }
65
66 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
78pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ModelMeta {
159 pub id: String,
161 pub provider: String,
163 pub name: String,
165 pub context_window: u32,
167 pub max_output_tokens: u32,
169 pub pricing: ModelPricing,
171 pub capabilities: Capabilities,
173}
174
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct ModelPricing {
178 pub input_per_mtok: f64,
180 pub output_per_mtok: f64,
182 pub cache_read_per_mtok: f64,
184 pub cache_write_per_mtok: f64,
186}
187
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190pub struct Capabilities {
191 pub reasoning: bool,
193 pub images: bool,
195 pub tool_use: bool,
197}
198
199pub struct Model {
201 pub meta: ModelMeta,
203 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#[derive(Debug, Clone)]
221pub struct ModelRegistry {
222 models: Vec<ModelMeta>,
223 aliases: HashMap<String, String>,
224}
225
226impl ModelRegistry {
227 pub fn new() -> Self {
229 Self {
230 models: Vec::new(),
231 aliases: HashMap::new(),
232 }
233 }
234
235 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 pub fn register(&mut self, meta: ModelMeta) {
250 if !self.models.iter().any(|m| m.id == meta.id) {
252 self.models.push(meta);
253 }
254 }
255
256 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 pub fn find(&self, id: &str) -> Option<&ModelMeta> {
263 self.models.iter().find(|m| m.id == id)
264 }
265
266 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 pub fn list(&self) -> &[ModelMeta] {
277 &self.models
278 }
279
280 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 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
320fn builtin_models() -> Vec<ModelMeta> {
325 let mut models = vec![
326 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 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 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 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 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 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 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 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 ("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 ("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 ("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 ("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 ("gemini-pro".into(), "gemini-2.5-pro".into()),
1023 ("gemini-flash".into(), "gemini-2.5-flash".into()),
1024 ("deepseek".into(), "deepseek-chat".into()),
1026 ("deepseek-v3".into(), "deepseek-chat".into()),
1027 ("deepseek-r1".into(), "deepseek-reasoner".into()),
1028 ("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".into(), "kimi2.6".into()),
1047 ("kimi2.6".into(), "kimi2.6".into()),
1048 ("kimi-for-coding".into(), "kimi-for-coding".into()),
1049 ("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}