Skip to main content

oxicode_ai/
model_registry.rs

1//! Model registry for oxicode-ai
2//!
3//! Provides a centralized registry of available LLM models.
4//! Supports both static built-in models and dynamic runtime registration
5//! for custom OpenAI-compatible providers.
6
7use crate::{Api, CompatSettings, Cost, InputModality, MaxTokensField, Model, ThinkingFormat};
8use parking_lot::RwLock;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12/// Extract the model name after the last '/', or return the whole id if no '/' is present.
13fn extract_model_name(id: &str) -> &str {
14    id.rsplit_once('/').map(|(_, name)| name).unwrap_or(id)
15}
16
17/// Return provider-specific compatibility defaults.
18///
19/// Internal helper used by `add_*_models()` functions so that every model
20/// from the same provider gets the same `compat` baseline.
21fn default_compat_for_provider(provider: &str) -> Option<CompatSettings> {
22    match provider {
23        "openai" | "openai-responses" | "openai-completions" => Some(CompatSettings {
24            thinking_format: Some(ThinkingFormat::OpenAI),
25            max_tokens_field: Some(MaxTokensField::MaxCompletionTokens),
26            ..CompatSettings::default()
27        }),
28        "openrouter" => Some(CompatSettings {
29            thinking_format: Some(ThinkingFormat::OpenRouter),
30            requires_tool_result_name: true,
31            ..CompatSettings::default()
32        }),
33        "deepseek" => Some(CompatSettings {
34            thinking_format: Some(ThinkingFormat::DeepSeek),
35            max_tokens_field: Some(MaxTokensField::MaxTokens),
36            ..CompatSettings::default()
37        }),
38        "zai" => Some(CompatSettings {
39            thinking_format: Some(ThinkingFormat::Zai),
40            ..CompatSettings::default()
41        }),
42        // azure-openai already has explicit CompatSettings in add_azure_models()
43        // All other providers: use defaults (return None)
44        _ => None,
45    }
46}
47
48/// Global model registry (static built-in models)
49static STATIC_MODELS: LazyLock<HashMap<String, Model>> = LazyLock::new(|| {
50    let mut map = HashMap::new();
51
52    // OpenAI models
53    add_openai_models(&mut map);
54
55    // Anthropic models
56    add_anthropic_models(&mut map);
57
58    // Google models
59    add_google_models(&mut map);
60
61    // DeepSeek models
62    add_deepseek_models(&mut map);
63
64    // Mistral models
65    add_mistral_models(&mut map);
66
67    // Groq models
68    add_groq_models(&mut map);
69
70    // Cerebras models
71    add_cerebras_models(&mut map);
72
73    // xAI models
74    add_xai_models(&mut map);
75
76    // OpenRouter models
77    add_openrouter_models(&mut map);
78
79    // Azure OpenAI models
80    add_azure_models(&mut map);
81
82    // ZAI models
83    add_zai_models(&mut map);
84    // MiniMax models
85    add_minimax_models(&mut map);
86
87    // models.dev is the source of truth for numeric metadata. The static
88    // registry exists to carry hand-maintained transport quirks (compat
89    // settings, base URLs) — its hand-written numbers drift (e.g.
90    // gemini-2.5-pro listed at 2M while models.dev reports 1_048_576).
91    // Resolution prefers static over catalog, so refresh the numbers here
92    // or the stale hand values would shadow the catalog forever.
93    refresh_numerics_from_catalog(&mut map);
94
95    map
96});
97
98/// Overwrite hand-maintained numeric metadata (context window, max output
99/// tokens, costs) with models.dev catalog values for every static entry
100/// the catalog knows. See the call site for why this runs at init.
101fn refresh_numerics_from_catalog(map: &mut HashMap<String, Model>) {
102    for (key, model) in map.iter_mut() {
103        let Some((provider, id)) = key.split_once('/') else {
104            continue;
105        };
106        let Some(entry) = crate::model_db::get_model_entry(provider, id) else {
107            continue;
108        };
109        if entry.context_window > 0 {
110            model.context_window = entry.context_window as usize;
111        }
112        if entry.max_tokens > 0 {
113            model.max_tokens = entry.max_tokens as usize;
114        }
115        if entry.cost_input > 0.0 {
116            model.cost.input = entry.cost_input;
117        }
118        if entry.cost_output > 0.0 {
119            model.cost.output = entry.cost_output;
120        }
121        if entry.cost_cache_read > 0.0 {
122            model.cost.cache_read = entry.cost_cache_read;
123        }
124        if entry.cost_cache_write > 0.0 {
125            model.cost.cache_write = entry.cost_cache_write;
126        }
127    }
128}
129
130fn add_openai_models(map: &mut HashMap<String, Model>) {
131    let models = [
132        ("openai/gpt-4o", "GPT-4o", true, 2.5, 10.0),
133        ("openai/gpt-4o-mini", "GPT-4o Mini", true, 0.15, 0.60),
134        ("openai/gpt-4-turbo", "GPT-4 Turbo", true, 10.0, 30.0),
135        ("openai/gpt-4", "GPT-4", false, 30.0, 60.0),
136        ("openai/gpt-3.5-turbo", "GPT-3.5 Turbo", false, 0.5, 1.5),
137        ("openai/o1-preview", "OpenAI o1 Preview", true, 15.0, 60.0),
138        ("openai/o1-mini", "OpenAI o1 Mini", true, 15.0, 60.0),
139        ("openai/o1", "OpenAI o1", true, 15.0, 60.0),
140        ("openai/o3", "OpenAI o3", true, 15.0, 60.0),
141        ("openai/o3-mini", "OpenAI o3 Mini", true, 15.0, 60.0),
142    ];
143
144    for (id, name, reasoning, input_cost, output_cost) in models {
145        map.insert(
146            id.to_string(),
147            Model {
148                id: extract_model_name(id).to_string(),
149                name: name.to_string(),
150                api: Api::OpenAiCompletions,
151                provider: "openai".to_string(),
152                base_url: "https://api.openai.com/v1".to_string(),
153                reasoning,
154                input: if reasoning {
155                    vec![InputModality::Text]
156                } else {
157                    vec![InputModality::Text, InputModality::Image]
158                },
159                cost: Cost {
160                    input: input_cost,
161                    output: output_cost,
162                    cache_read: input_cost * 0.5,
163                    cache_write: input_cost * 7.5,
164                },
165                context_window: 128_000,
166                max_tokens: 32_000,
167                headers: Default::default(),
168                compat: default_compat_for_provider("openai"),
169            },
170        );
171    }
172}
173
174fn add_anthropic_models(map: &mut HashMap<String, Model>) {
175    let models = [
176        (
177            "anthropic/claude-sonnet-4-20250514",
178            "Claude Sonnet 4",
179            true,
180            3.0,
181            15.0,
182        ),
183        (
184            "anthropic/claude-opus-4-20250514",
185            "Claude Opus 4",
186            true,
187            15.0,
188            75.0,
189        ),
190        (
191            "anthropic/claude-3-5-sonnet-20241022",
192            "Claude 3.5 Sonnet",
193            true,
194            3.0,
195            15.0,
196        ),
197        (
198            "anthropic/claude-3-5-haiku-20241022",
199            "Claude 3.5 Haiku",
200            false,
201            0.8,
202            4.0,
203        ),
204        (
205            "anthropic/claude-3-opus",
206            "Claude 3 Opus",
207            false,
208            15.0,
209            75.0,
210        ),
211        (
212            "anthropic/claude-3-sonnet",
213            "Claude 3 Sonnet",
214            false,
215            3.0,
216            15.0,
217        ),
218        (
219            "anthropic/claude-3-haiku",
220            "Claude 3 Haiku",
221            false,
222            0.25,
223            1.25,
224        ),
225    ];
226
227    for (id, name, reasoning, input_cost, output_cost) in models {
228        map.insert(
229            id.to_string(),
230            Model {
231                id: extract_model_name(id).to_string(),
232                name: name.to_string(),
233                api: Api::AnthropicMessages,
234                provider: "anthropic".to_string(),
235                base_url: "https://api.anthropic.com".to_string(),
236                reasoning,
237                input: vec![InputModality::Text, InputModality::Image],
238                cost: Cost {
239                    input: input_cost,
240                    output: output_cost,
241                    cache_read: input_cost * 0.1,
242                    cache_write: input_cost * 1.25,
243                },
244                context_window: 200_000,
245                max_tokens: 8192,
246                headers: Default::default(),
247                compat: default_compat_for_provider("anthropic"),
248            },
249        );
250    }
251}
252
253fn add_google_models(map: &mut HashMap<String, Model>) {
254    let models = [
255        (
256            "google/gemini-2.0-flash",
257            "Gemini 2.0 Flash",
258            0.0,
259            0.0,
260            1_000_000,
261        ),
262        (
263            "google/gemini-2.5-flash",
264            "Gemini 2.5 Flash",
265            0.0,
266            0.0,
267            1_000_000,
268        ),
269        (
270            "google/gemini-2.5-pro",
271            "Gemini 2.5 Pro",
272            1.25,
273            5.0,
274            2_000_000,
275        ),
276        (
277            "google/gemini-1.5-flash",
278            "Gemini 1.5 Flash",
279            0.0,
280            0.0,
281            1_000_000,
282        ),
283        (
284            "google/gemini-1.5-pro",
285            "Gemini 1.5 Pro",
286            1.25,
287            5.0,
288            2_000_000,
289        ),
290        ("google/gemini-pro", "Gemini Pro", 0.125, 0.5, 32_000),
291    ];
292
293    for (id, name, input_cost, output_cost, ctx) in models {
294        map.insert(
295            id.to_string(),
296            Model {
297                id: extract_model_name(id).to_string(),
298                name: name.to_string(),
299                api: Api::GoogleGenerativeAi,
300                provider: "google".to_string(),
301                base_url: "https://generativelanguage.googleapis.com".to_string(),
302                reasoning: false,
303                input: vec![InputModality::Text, InputModality::Image],
304                cost: Cost {
305                    input: input_cost,
306                    output: output_cost,
307                    cache_read: 0.0,
308                    cache_write: 0.0,
309                },
310                context_window: ctx,
311                max_tokens: 8192,
312                headers: Default::default(),
313                compat: default_compat_for_provider("google"),
314            },
315        );
316    }
317}
318
319fn add_deepseek_models(map: &mut HashMap<String, Model>) {
320    // Legacy models (to be retired 2026-07-24)
321    let legacy_models = [
322        (
323            "deepseek/deepseek-chat",
324            "DeepSeek Chat",
325            false,
326            0.27,
327            1.1,
328            64_000,
329            8192,
330        ),
331        (
332            "deepseek/deepseek-chat-v3",
333            "DeepSeek Chat V3",
334            false,
335            0.27,
336            1.1,
337            64_000,
338            8192,
339        ),
340        (
341            "deepseek/deepseek-reasoner",
342            "DeepSeek Reasoner",
343            true,
344            0.55,
345            2.19,
346            64_000,
347            8192,
348        ),
349        (
350            "deepseek/deepseek-coder",
351            "DeepSeek Coder",
352            false,
353            0.27,
354            1.1,
355            64_000,
356            8192,
357        ),
358    ];
359
360    for (id, name, reasoning, input_cost, output_cost, ctx, max_out) in legacy_models {
361        map.insert(
362            id.to_string(),
363            Model {
364                id: extract_model_name(id).to_string(),
365                name: name.to_string(),
366                api: Api::OpenAiCompletions,
367                provider: "deepseek".to_string(),
368                base_url: "https://api.deepseek.com".to_string(),
369                reasoning,
370                input: vec![InputModality::Text],
371                cost: Cost {
372                    input: input_cost,
373                    output: output_cost,
374                    cache_read: 0.1,
375                    cache_write: 1.0,
376                },
377                context_window: ctx,
378                max_tokens: max_out,
379                headers: Default::default(),
380                compat: default_compat_for_provider("deepseek"),
381            },
382        );
383    }
384
385    // V4 models (released 2026-04-24)
386    let v4_models = [
387        // deepseek-v4-flash: 284B total / 13B active, $0.14/M input, $0.28/M output
388        (
389            "deepseek/deepseek-v4-flash",
390            "DeepSeek V4 Flash",
391            true,
392            0.14,
393            0.28,
394            1_000_000,
395            384_000,
396        ),
397        // deepseek-v4-pro: 1.6T total / 49B active, $0.435/M input, $0.87/M output
398        (
399            "deepseek/deepseek-v4-pro",
400            "DeepSeek V4 Pro",
401            true,
402            0.435,
403            0.87,
404            1_000_000,
405            384_000,
406        ),
407    ];
408
409    for (id, name, reasoning, input_cost, output_cost, ctx, max_out) in v4_models {
410        map.insert(
411            id.to_string(),
412            Model {
413                id: extract_model_name(id).to_string(),
414                name: name.to_string(),
415                api: Api::OpenAiCompletions,
416                provider: "deepseek".to_string(),
417                base_url: "https://api.deepseek.com".to_string(),
418                reasoning,
419                input: vec![InputModality::Text],
420                cost: Cost {
421                    input: input_cost,
422                    output: output_cost,
423                    // V4 cache pricing: flash $0.0028, pro $0.003625 per 1M tokens
424                    cache_read: if input_cost < 0.2 { 0.0028 } else { 0.003625 },
425                    cache_write: 0.0, // DeepSeek does not charge extra for cache writes
426                },
427                context_window: ctx,
428                max_tokens: max_out,
429                headers: Default::default(),
430                compat: default_compat_for_provider("deepseek"),
431            },
432        );
433    }
434}
435
436fn add_mistral_models(map: &mut HashMap<String, Model>) {
437    let models = [
438        (
439            "mistral/mistral-large-latest",
440            "Mistral Large",
441            false,
442            2.0,
443            6.0,
444        ),
445        (
446            "mistral/mistral-medium-latest",
447            "Mistral Medium",
448            false,
449            0.5,
450            1.5,
451        ),
452        (
453            "mistral/mistral-small-latest",
454            "Mistral Small",
455            false,
456            0.2,
457            0.6,
458        ),
459        ("mistral/mistral-nemo", "Mistral Nemo", false, 0.15, 0.15),
460        ("mistral/codestral", "Codestral", false, 0.3, 0.9),
461        (
462            "mistral/codestral-mamba",
463            "Codestral Mamba",
464            false,
465            0.25,
466            0.25,
467        ),
468        (
469            "mistral/open-mixtral-8x22b",
470            "Mixtral 8x22B",
471            false,
472            0.45,
473            1.4,
474        ),
475        (
476            "mistral/open-mixtral-8x7b",
477            "Mixtral 8x7B",
478            false,
479            0.24,
480            0.24,
481        ),
482    ];
483
484    for (id, name, reasoning, input_cost, output_cost) in models {
485        map.insert(
486            id.to_string(),
487            Model {
488                id: extract_model_name(id).to_string(),
489                name: name.to_string(),
490                api: Api::OpenAiCompletions,
491                provider: "mistral".to_string(),
492                base_url: "https://api.mistral.ai".to_string(),
493                reasoning,
494                input: vec![InputModality::Text],
495                cost: Cost {
496                    input: input_cost,
497                    output: output_cost,
498                    cache_read: 0.0,
499                    cache_write: 0.0,
500                },
501                context_window: 128_000,
502                max_tokens: 32_000,
503                headers: Default::default(),
504                compat: default_compat_for_provider("mistral"),
505            },
506        );
507    }
508}
509
510fn add_groq_models(map: &mut HashMap<String, Model>) {
511    let models = [
512        (
513            "groq/llama-3.3-70b-versatile",
514            "Llama 3.3 70B Versatile",
515            false,
516            0.0,
517            0.0,
518        ),
519        (
520            "groq/llama-3.1-70b-versatile",
521            "Llama 3.1 70B Versatile",
522            false,
523            0.0,
524            0.0,
525        ),
526        (
527            "groq/llama-3.1-8b-instant",
528            "Llama 3.1 8B Instant",
529            false,
530            0.0,
531            0.0,
532        ),
533        (
534            "groq/llama-3-70b-versatile",
535            "Llama 3 70B Versatile",
536            false,
537            0.0,
538            0.0,
539        ),
540        (
541            "groq/llama-3-8b-versatile",
542            "Llama 3 8B Versatile",
543            false,
544            0.0,
545            0.0,
546        ),
547        ("groq/mixtral-8x7b-32768", "Mixtral 8x7B", false, 0.0, 0.0),
548        ("groq/gemma2-9b-it", "Gemma 2 9B", false, 0.0, 0.0),
549        ("groq/gemma-7b-it", "Gemma 7B", false, 0.0, 0.0),
550    ];
551
552    for (id, name, reasoning, input_cost, output_cost) in models {
553        map.insert(
554            id.to_string(),
555            Model {
556                id: extract_model_name(id).to_string(),
557                name: name.to_string(),
558                api: Api::OpenAiCompletions,
559                provider: "groq".to_string(),
560                base_url: "https://api.groq.com/openai/v1".to_string(),
561                reasoning,
562                input: vec![InputModality::Text],
563                cost: Cost {
564                    input: input_cost,
565                    output: output_cost,
566                    cache_read: 0.0,
567                    cache_write: 0.0,
568                },
569                context_window: 128_000,
570                max_tokens: 8192,
571                headers: Default::default(),
572                compat: default_compat_for_provider("groq"),
573            },
574        );
575    }
576}
577
578fn add_cerebras_models(map: &mut HashMap<String, Model>) {
579    let models = [
580        ("cerebras/llama-3.3-70b", "Llama 3.3 70B", false, 0.0, 0.0),
581        ("cerebras/llama-3.1-8b", "Llama 3.1 8B", false, 0.0, 0.0),
582        ("cerebras/qwen-2.5-32b", "Qwen 2.5 32B", false, 0.0, 0.0),
583        ("cerebras/qwen-2.5-7b", "Qwen 2.5 7B", false, 0.0, 0.0),
584    ];
585
586    for (id, name, reasoning, input_cost, output_cost) in models {
587        map.insert(
588            id.to_string(),
589            Model {
590                id: extract_model_name(id).to_string(),
591                name: name.to_string(),
592                api: Api::OpenAiCompletions,
593                provider: "cerebras".to_string(),
594                base_url: "https://api.cerebras.ai".to_string(),
595                reasoning,
596                input: vec![InputModality::Text],
597                cost: Cost {
598                    input: input_cost,
599                    output: output_cost,
600                    cache_read: 0.0,
601                    cache_write: 0.0,
602                },
603                context_window: 128_000,
604                max_tokens: 8192,
605                headers: Default::default(),
606                compat: default_compat_for_provider("cerebras"),
607            },
608        );
609    }
610}
611
612fn add_xai_models(map: &mut HashMap<String, Model>) {
613    let models = [
614        ("xai/grok-2", "Grok 2", false, 5.0, 15.0),
615        ("xai/grok-2-mini", "Grok 2 Mini", false, 0.3, 0.5),
616        ("xai/grok-1", "Grok 1", false, 5.0, 15.0),
617        ("xai/grok-1.5", "Grok 1.5", false, 5.0, 15.0),
618    ];
619
620    for (id, name, reasoning, input_cost, output_cost) in models {
621        map.insert(
622            id.to_string(),
623            Model {
624                id: extract_model_name(id).to_string(),
625                name: name.to_string(),
626                api: Api::OpenAiCompletions,
627                provider: "xai".to_string(),
628                base_url: "https://api.x.ai/v1".to_string(),
629                reasoning,
630                input: vec![InputModality::Text],
631                cost: Cost {
632                    input: input_cost,
633                    output: output_cost,
634                    cache_read: 0.0,
635                    cache_write: 0.0,
636                },
637                context_window: 131_072,
638                max_tokens: 8192,
639                headers: Default::default(),
640                compat: default_compat_for_provider("xai"),
641            },
642        );
643    }
644}
645
646fn add_openrouter_models(map: &mut HashMap<String, Model>) {
647    let models = [
648        (
649            "openrouter/anthropic/claude-3.5-sonnet",
650            "Claude 3.5 Sonnet",
651            false,
652            3.0,
653            15.0,
654        ),
655        (
656            "openrouter/anthropic/claude-3-opus",
657            "Claude 3 Opus",
658            false,
659            15.0,
660            75.0,
661        ),
662        (
663            "openrouter/google/gemini-pro-1.5",
664            "Gemini Pro 1.5",
665            false,
666            1.25,
667            5.0,
668        ),
669        (
670            "openrouter/meta-llama/llama-3-70b",
671            "Llama 3 70B",
672            false,
673            0.65,
674            2.75,
675        ),
676        (
677            "openrouter/meta-llama/llama-3-8b",
678            "Llama 3 8B",
679            false,
680            0.2,
681            0.2,
682        ),
683        (
684            "openrouter/mistralai/mistral-large",
685            "Mistral Large",
686            false,
687            2.0,
688            6.0,
689        ),
690        (
691            "openrouter/deepseek/deepseek-chat",
692            "DeepSeek Chat",
693            false,
694            0.27,
695            1.1,
696        ),
697        ("openrouter/qwen/qwen-2-72b", "Qwen 2 72B", false, 0.9, 0.9),
698        (
699            "openrouter/nousresearch/hermes-3-llama-3-70b",
700            "Hermes 3 70B",
701            false,
702            0.5,
703            1.5,
704        ),
705    ];
706
707    for (id, name, reasoning, input_cost, output_cost) in models {
708        map.insert(
709            id.to_string(),
710            Model {
711                id: extract_model_name(id).to_string(),
712                name: name.to_string(),
713                api: Api::OpenAiCompletions,
714                provider: "openrouter".to_string(),
715                base_url: "https://openrouter.ai/api/v1".to_string(),
716                reasoning,
717                input: vec![InputModality::Text],
718                cost: Cost {
719                    input: input_cost,
720                    output: output_cost,
721                    cache_read: 0.0,
722                    cache_write: 0.0,
723                },
724                context_window: 128_000,
725                max_tokens: 32_000,
726                headers: [
727                    ("HTTP-Referer".to_string(), "https://oxicode-ai".to_string()),
728                    ("X-Title".to_string(), "oxicode-ai".to_string()),
729                ]
730                .into_iter()
731                .collect(),
732                compat: default_compat_for_provider("openrouter"),
733            },
734        );
735    }
736}
737
738fn add_azure_models(map: &mut HashMap<String, Model>) {
739    let models = [
740        ("azure-openai/gpt-4o", "GPT-4o", false, 2.5, 10.0),
741        ("azure-openai/gpt-4o-mini", "GPT-4o Mini", false, 0.15, 0.60),
742        ("azure-openai/gpt-4-turbo", "GPT-4 Turbo", false, 10.0, 30.0),
743    ];
744
745    for (id, name, reasoning, input_cost, output_cost) in models {
746        map.insert(
747            id.to_string(),
748            Model {
749                id: extract_model_name(id).to_string(),
750                name: name.to_string(),
751                api: Api::AzureOpenAiResponses,
752                provider: "azure-openai".to_string(),
753                base_url: "https://{your-resource-name}.openai.azure.com".to_string(),
754                reasoning,
755                input: vec![InputModality::Text, InputModality::Image],
756                cost: Cost {
757                    input: input_cost,
758                    output: output_cost,
759                    cache_read: 0.0,
760                    cache_write: 0.0,
761                },
762                context_window: 128_000,
763                max_tokens: 32_000,
764                headers: Default::default(),
765                compat: Some(crate::CompatSettings {
766                    supports_store: false,
767                    supports_developer_role: false,
768                    supports_reasoning_effort: false,
769                    supports_usage_in_streaming: false,
770                    max_tokens_field: Some(crate::MaxTokensField::MaxCompletionTokens),
771                    requires_tool_result_name: true,
772                    requires_assistant_after_tool_result: false,
773                    requires_thinking_as_text: false,
774                    thinking_format: None,
775                }),
776            },
777        );
778    }
779}
780
781fn add_zai_models(map: &mut HashMap<String, Model>) {
782    let models = [
783        ("zai/glm-4.7", "GLM-4.7", true, 0.0, 0.0),
784        ("zai/glm-5-turbo", "GLM-5-Turbo", true, 0.0, 0.0),
785        ("zai/glm-5.1", "GLM-5.1", true, 0.0, 0.0),
786        ("zai/glm-5v-turbo", "GLM-5V-Turbo", true, 0.0, 0.0),
787        ("zai/glm-4.5-air", "GLM-4.5-Air", true, 0.0, 0.0),
788    ];
789
790    for (id, name, reasoning, input_cost, output_cost) in models {
791        map.insert(
792            id.to_string(),
793            Model {
794                id: extract_model_name(id).to_string(),
795                name: name.to_string(),
796                api: Api::OpenAiCompletions,
797                provider: "zai".to_string(),
798                base_url: "https://api.z.ai/api/coding/paas/v4".to_string(),
799                reasoning,
800                input: vec![InputModality::Text],
801                cost: Cost {
802                    input: input_cost,
803                    output: output_cost,
804                    cache_read: 0.0,
805                    cache_write: 0.0,
806                },
807                context_window: 200_000,
808                max_tokens: 131_072,
809                headers: Default::default(),
810                compat: default_compat_for_provider("zai"),
811            },
812        );
813    }
814}
815
816fn add_minimax_models(map: &mut HashMap<String, Model>) {
817    let models = [
818        ("minimax/MiniMax-M2.7", "MiniMax-M2.7", true, 0.0, 0.0),
819        (
820            "minimax/MiniMax-M2.7-highspeed",
821            "MiniMax-M2.7-highspeed",
822            true,
823            0.0,
824            0.0,
825        ),
826    ];
827
828    for (id, name, reasoning, input_cost, output_cost) in models {
829        map.insert(
830            id.to_string(),
831            Model {
832                id: extract_model_name(id).to_string(),
833                name: name.to_string(),
834                api: Api::AnthropicMessages,
835                provider: "minimax".to_string(),
836                base_url: "https://api.minimax.io".to_string(),
837                reasoning,
838                input: vec![InputModality::Text],
839                cost: Cost {
840                    input: input_cost,
841                    output: output_cost,
842                    cache_read: 0.06,
843                    cache_write: 0.375,
844                },
845                context_window: 204_800,
846                max_tokens: 131_072,
847                headers: Default::default(),
848                compat: default_compat_for_provider("minimax"),
849            },
850        );
851    }
852}
853
854/// Lightweight model registry for SDK/engine usage.
855///
856/// Stores model metadata (provider, base_url, API type, costs) without
857/// authentication details. For CLI usage with auth integration, see
858/// `oxicode_store::CliModelRegistry`.
859#[derive(Default)]
860pub struct ModelRegistry {
861    static_models: HashMap<String, Model>,
862    dynamic_models: parking_lot::RwLock<HashMap<String, Model>>,
863}
864
865impl ModelRegistry {
866    /// Create a new empty registry.
867    pub fn new() -> Self {
868        Self {
869            static_models: HashMap::new(),
870            dynamic_models: RwLock::new(HashMap::new()),
871        }
872    }
873
874    /// Create a registry pre-populated with all built-in static models.
875    ///
876    /// This loads models from the embedded static database.
877    pub fn from_static() -> Self {
878        Self {
879            static_models: STATIC_MODELS.clone(),
880            dynamic_models: RwLock::new(HashMap::new()),
881        }
882    }
883
884    /// Register a model at runtime.
885    ///
886    /// If a model with the same `provider/model_id` key already exists,
887    /// the new one replaces it.
888    pub fn register(&self, model: Model) {
889        let key = format!("{}/{}", model.provider, model.id);
890        self.dynamic_models.write().insert(key, model);
891    }
892
893    /// Unregister a previously registered dynamic model.
894    pub fn unregister(&self, provider: &str, model_id: &str) {
895        let key = format!("{}/{}", provider, model_id);
896        self.dynamic_models.write().remove(&key);
897    }
898
899    /// Look up a model by provider and model ID.
900    ///
901    /// Dynamic models take priority over static ones.
902    pub fn lookup(&self, provider: &str, model_id: &str) -> Option<Model> {
903        let key = format!("{}/{}", provider, model_id);
904        // Dynamic models take priority
905        if let Some(m) = self.dynamic_models.read().get(&key) {
906            return Some(m.clone());
907        }
908        // Then static models
909        self.static_models.get(&key).cloned()
910    }
911
912    /// Get a model by provider/model ID (static models only).
913    pub fn get(provider: &str, model_id: &str) -> Option<&'static Model> {
914        let key = format!("{}/{}", provider, model_id);
915        STATIC_MODELS.get(&key)
916    }
917
918    /// Get all models from a provider (static only).
919    pub fn get_by_provider(provider: &str) -> Vec<&'static Model> {
920        STATIC_MODELS
921            .values()
922            .filter(|m| m.provider == provider)
923            .collect()
924    }
925
926    /// Get all available models (static only).
927    pub fn all() -> Vec<&'static Model> {
928        STATIC_MODELS.values().collect()
929    }
930
931    /// Get all dynamically registered models.
932    pub fn dynamic_models(&self) -> Vec<Model> {
933        self.dynamic_models.read().values().cloned().collect()
934    }
935
936    /// Get all registered model IDs as `provider/model` strings.
937    pub fn model_ids(&self) -> Vec<String> {
938        let static_ids: Vec<String> = self.static_models.keys().cloned().collect();
939        let dynamic_ids: Vec<String> = self.dynamic_models.read().keys().cloned().collect();
940        static_ids.into_iter().chain(dynamic_ids).collect()
941    }
942
943    /// Search models by pattern (static only).
944    pub fn search(pattern: &str) -> Vec<&'static Model> {
945        let pattern_lower = pattern.to_lowercase();
946        STATIC_MODELS
947            .values()
948            .filter(|m| {
949                m.id.to_lowercase().contains(&pattern_lower)
950                    || m.name.to_lowercase().contains(&pattern_lower)
951            })
952            .collect()
953    }
954}
955
956// ── Global registry instance ────────────────────────────────────────
957
958/// Global model registry instance (for convenience functions).
959static GLOBAL_REGISTRY: LazyLock<ModelRegistry> = LazyLock::new(ModelRegistry::from_static);
960
961// ── Convenience functions using global registry ─────────────────────
962
963/// Register a model at runtime.
964///
965/// Call this during startup for each custom provider's model.
966/// If a model with the same `provider/model_id` key already exists,
967/// the new one replaces it.
968pub fn register_model(model: Model) {
969    GLOBAL_REGISTRY.register(model);
970}
971
972/// Unregister a previously registered dynamic model.
973pub fn unregister_model(provider: &str, model_id: &str) {
974    GLOBAL_REGISTRY.unregister(provider, model_id);
975}
976
977/// Look up a model by provider and model ID, checking both dynamic and static registries.
978///
979/// Dynamic models take priority over static ones.
980pub fn lookup_model(provider: &str, model_id: &str) -> Option<Model> {
981    GLOBAL_REGISTRY.lookup(provider, model_id)
982}
983
984/// Convenience function to get a model (static registry only – use [`lookup_model`] for dynamic too)
985pub fn get_model(provider: &str, model_id: &str) -> Option<&'static Model> {
986    ModelRegistry::get(provider, model_id)
987}
988
989/// Get all available providers
990pub fn get_providers() -> Vec<&'static str> {
991    let mut providers: Vec<&'static str> = STATIC_MODELS
992        .values()
993        .map(|m| m.provider.as_str())
994        .collect();
995    providers.sort();
996    providers.dedup();
997    providers
998}
999
1000/// Get all models from a provider
1001pub fn get_models(provider: &str) -> Vec<&'static Model> {
1002    ModelRegistry::get_by_provider(provider)
1003}
1004
1005/// Get all dynamically registered models.
1006pub fn dynamic_models() -> Vec<Model> {
1007    GLOBAL_REGISTRY.dynamic_models()
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    #[test]
1015    fn test_get_model() {
1016        let model = get_model("openai", "gpt-4o");
1017        assert!(model.is_some());
1018        let model = model.unwrap();
1019        assert_eq!(model.provider, "openai");
1020        // Note: gpt-4o has reasoning enabled
1021    }
1022
1023    #[test]
1024    fn test_get_providers() {
1025        let providers = get_providers();
1026        assert!(providers.contains(&"openai"));
1027        assert!(providers.contains(&"anthropic"));
1028        assert!(providers.contains(&"google"));
1029        assert!(providers.contains(&"deepseek"));
1030        assert!(providers.contains(&"mistral"));
1031        assert!(providers.contains(&"groq"));
1032    }
1033
1034    #[test]
1035    fn test_deepseek_model() {
1036        let model = get_model("deepseek", "deepseek-chat");
1037        assert!(model.is_some());
1038        let model = model.unwrap();
1039        assert_eq!(model.provider, "deepseek");
1040        assert_eq!(model.base_url, "https://api.deepseek.com");
1041    }
1042
1043    #[test]
1044    fn test_deepseek_v4_models() {
1045        let flash = get_model("deepseek", "deepseek-v4-flash");
1046        assert!(flash.is_some(), "deepseek-v4-flash should be registered");
1047        let flash = flash.unwrap();
1048        assert_eq!(flash.provider, "deepseek");
1049        assert_eq!(flash.context_window, 1_000_000);
1050        assert_eq!(flash.max_tokens, 384_000);
1051        assert!(flash.reasoning);
1052
1053        let pro = get_model("deepseek", "deepseek-v4-pro");
1054        assert!(pro.is_some(), "deepseek-v4-pro should be registered");
1055        let pro = pro.unwrap();
1056        assert_eq!(pro.provider, "deepseek");
1057        assert_eq!(pro.context_window, 1_000_000);
1058        assert_eq!(pro.max_tokens, 384_000);
1059        assert!(pro.reasoning);
1060        // V4 Pro is more expensive than V4 Flash
1061        assert!(pro.cost.input > flash.cost.input);
1062    }
1063
1064    #[test]
1065    fn test_minimax_m3_available_via_catalog() {
1066        // MiniMax-M3 (released 2026-06-01) ships via the models.dev snapshot,
1067        // NOT the hand-maintained static registry — the static numbers would
1068        // shadow catalog refreshes. Pin the catalog contract here: 1M context,
1069        // 128K output, multimodal input (Text + Image).
1070        let m3 = crate::model_db::get_model_entry("minimax", "MiniMax-M3")
1071            .expect("models.dev snapshot must carry MiniMax-M3");
1072        assert_eq!(m3.context_window, 1_000_000);
1073        assert_eq!(m3.max_tokens, 128_000);
1074        assert!(m3.reasoning, "M3 supports thinking");
1075        assert!(m3.input.contains(&InputModality::Text));
1076        assert!(
1077            m3.input.contains(&InputModality::Image),
1078            "M3 is natively multimodal — must accept image input"
1079        );
1080    }
1081
1082    #[test]
1083    fn test_search_models() {
1084        let results = ModelRegistry::search("gpt");
1085        assert!(!results.is_empty());
1086        assert!(
1087            results
1088                .iter()
1089                .all(|m| m.name.to_lowercase().contains("gpt"))
1090        );
1091    }
1092
1093    #[test]
1094    fn test_model_registry_instance() {
1095        let registry = ModelRegistry::from_static();
1096        assert!(registry.lookup("openai", "gpt-4o").is_some());
1097        assert!(registry.lookup("fake", "fake-model").is_none());
1098    }
1099
1100    #[test]
1101    fn static_registry_numerics_match_catalog() {
1102        // Every static entry the models.dev catalog knows must carry the
1103        // catalog's numeric metadata. Before `refresh_numerics_from_catalog`,
1104        // hand-written values (gemini-2.5-pro 2M, zai/glm-4.7 200k, …)
1105        // shadowed the catalog in `resolve_model_from_id` (static wins
1106        // over catalog) and drifted from reality.
1107        let mut checked = 0;
1108        for provider in get_providers() {
1109            for model in get_models(provider) {
1110                let Some(entry) = crate::model_db::get_model_entry(provider, &model.id) else {
1111                    continue; // catalog-only model, nothing to compare
1112                };
1113                if entry.context_window > 0 {
1114                    assert_eq!(
1115                        model.context_window, entry.context_window as usize,
1116                        "{provider}/{} context window must match models.dev",
1117                        model.id
1118                    );
1119                }
1120                if entry.max_tokens > 0 {
1121                    assert_eq!(
1122                        model.max_tokens, entry.max_tokens as usize,
1123                        "{provider}/{} max output tokens must match models.dev",
1124                        model.id
1125                    );
1126                }
1127                checked += 1;
1128            }
1129        }
1130        assert!(checked > 20, "expected a meaningful overlap, got {checked}");
1131    }
1132
1133    #[test]
1134    fn gemini_2_5_pro_uses_models_dev_window() {
1135        // Concrete regression: the hand-written entry said 2_000_000 while
1136        // models.dev (and Google's own docs for the GA API) say 1_048_576.
1137        let m = get_model("google", "gemini-2.5-pro").expect("static gemini-2.5-pro");
1138        assert_eq!(m.context_window, 1_048_576);
1139    }
1140
1141    #[test]
1142    fn test_model_registry_register_dynamic() {
1143        let registry = ModelRegistry::new();
1144        let custom_model = Model {
1145            id: "custom-model".to_string(),
1146            name: "Custom Model".to_string(),
1147            api: Api::OpenAiCompletions,
1148            provider: "custom".to_string(),
1149            base_url: "https://custom.example.com".to_string(),
1150            reasoning: false,
1151            input: vec![InputModality::Text],
1152            cost: Cost {
1153                input: 1.0,
1154                output: 2.0,
1155                cache_read: 0.5,
1156                cache_write: 5.0,
1157            },
1158            context_window: 100_000,
1159            max_tokens: 8192,
1160            headers: Default::default(),
1161            compat: None,
1162        };
1163        registry.register(custom_model.clone());
1164        assert!(registry.lookup("custom", "custom-model").is_some());
1165    }
1166}