1use std::collections::HashSet;
12use std::path::Path;
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use crate::diagnostic::DiagnosticCollector;
18use crate::error::MarsError;
19
20pub mod harness;
21
22#[derive(Debug, Clone, PartialEq, Serialize)]
29pub struct ModelAlias {
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub harness: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub description: Option<String>,
34 #[serde(flatten)]
35 pub spec: ModelSpec,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum ModelSpec {
41 Pinned {
43 model: String,
44 provider: Option<String>,
45 },
46 AutoResolve {
48 provider: String,
49 match_patterns: Vec<String>,
50 exclude_patterns: Vec<String>,
51 },
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum HarnessSource {
58 Explicit,
59 AutoDetected,
60 Unavailable,
61}
62
63#[derive(Debug, Clone, Serialize)]
65pub struct ResolvedAlias {
66 pub name: String,
67 pub model_id: String,
68 pub provider: String,
69 pub harness: Option<String>,
70 pub harness_source: HarnessSource,
71 pub harness_candidates: Vec<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub description: Option<String>,
74}
75
76impl Serialize for ModelSpec {
78 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
79 use serde::ser::SerializeMap;
80 match self {
81 ModelSpec::Pinned { model, provider } => {
82 let mut count = 1;
83 if provider.is_some() {
84 count += 1;
85 }
86 let mut map = serializer.serialize_map(Some(count))?;
87 map.serialize_entry("model", model)?;
88 if let Some(provider) = provider {
89 map.serialize_entry("provider", provider)?;
90 }
91 map.end()
92 }
93 ModelSpec::AutoResolve {
94 provider,
95 match_patterns,
96 exclude_patterns,
97 } => {
98 let mut count = 2; if !exclude_patterns.is_empty() {
100 count += 1;
101 }
102 let mut map = serializer.serialize_map(Some(count))?;
103 map.serialize_entry("provider", provider)?;
104 map.serialize_entry("match", match_patterns)?;
105 if !exclude_patterns.is_empty() {
106 map.serialize_entry("exclude", exclude_patterns)?;
107 }
108 map.end()
109 }
110 }
111 }
112}
113
114#[derive(Debug, Deserialize)]
116struct RawModelAlias {
117 harness: Option<String>,
118 #[serde(default)]
119 description: Option<String>,
120 #[serde(default)]
122 model: Option<String>,
123 #[serde(default)]
125 provider: Option<String>,
126 #[serde(default, rename = "match")]
127 match_patterns: Option<Vec<String>>,
128 #[serde(default)]
129 exclude: Option<Vec<String>>,
130}
131
132impl<'de> Deserialize<'de> for ModelAlias {
133 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
134 let raw = RawModelAlias::deserialize(deserializer)?;
135
136 let has_model = raw.model.is_some();
137 let has_match = raw.match_patterns.is_some();
138
139 if has_model && has_match {
140 return Err(serde::de::Error::custom(
141 "model alias cannot have both 'model' and 'match' — use one or the other",
142 ));
143 }
144
145 let spec = if let Some(model) = raw.model {
146 ModelSpec::Pinned {
147 model,
148 provider: raw.provider,
149 }
150 } else if let Some(match_patterns) = raw.match_patterns {
151 let provider = raw.provider.ok_or_else(|| {
152 serde::de::Error::custom(
153 "auto-resolve model alias requires 'provider' when 'match' is specified",
154 )
155 })?;
156 ModelSpec::AutoResolve {
157 provider,
158 match_patterns,
159 exclude_patterns: raw.exclude.unwrap_or_default(),
160 }
161 } else {
162 return Err(serde::de::Error::custom(
163 "model alias must have either 'model' (pinned) or 'match' (auto-resolve)",
164 ));
165 };
166
167 Ok(ModelAlias {
168 harness: raw.harness,
169 description: raw.description,
170 spec,
171 })
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ModelsCache {
182 pub models: Vec<CachedModel>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub fetched_at: Option<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct CachedModel {
190 pub id: String,
191 pub provider: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub release_date: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub description: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub context_window: Option<u64>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub max_output: Option<u64>,
200}
201
202const CACHE_FILE: &str = "models-cache.json";
203
204pub fn read_cache(mars_dir: &Path) -> Result<ModelsCache, MarsError> {
206 let path = mars_dir.join(CACHE_FILE);
207 match std::fs::read_to_string(&path) {
208 Ok(content) => {
209 let cache: ModelsCache =
210 serde_json::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
211 message: format!("failed to parse models cache: {e}"),
212 })?;
213 Ok(cache)
214 }
215 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsCache {
216 models: Vec::new(),
217 fetched_at: None,
218 }),
219 Err(e) => Err(MarsError::Io(e)),
220 }
221}
222
223pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
225 std::fs::create_dir_all(mars_dir)?;
226 let path = mars_dir.join(CACHE_FILE);
227 let tmp_path = mars_dir.join(".models-cache.json.tmp");
228 let content =
229 serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
230 message: format!("failed to serialize models cache: {e}"),
231 })?;
232 std::fs::write(&tmp_path, content)?;
233 std::fs::rename(&tmp_path, &path)?;
234 Ok(())
235}
236
237pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
242 let url = "https://models.dev/api.json";
243 let response = ureq::get(url).call().map_err(|e| MarsError::Http {
244 url: url.to_string(),
245 status: 0,
246 message: format!("failed to fetch models catalog: {e}"),
247 })?;
248 let body = response
249 .into_body()
250 .read_to_string()
251 .map_err(|e| MarsError::Http {
252 url: url.to_string(),
253 status: 0,
254 message: format!("failed to read response body: {e}"),
255 })?;
256 let raw: serde_json::Value =
257 serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
258 message: format!("failed to parse models API response: {e}"),
259 })?;
260
261 parse_models_dev_catalog(&raw)
262}
263
264fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
265 let providers = raw
266 .as_object()
267 .ok_or_else(|| crate::error::ConfigError::Invalid {
268 message: "models API response must be an object keyed by provider".to_string(),
269 })?;
270
271 let mut models = Vec::new();
272
273 for (provider_key, provider_obj) in providers {
274 if !is_major_provider(provider_key) {
275 continue;
276 }
277
278 let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
279 continue;
280 };
281
282 for model_obj in provider_models.values() {
283 let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
284 continue;
285 };
286 let release_date = model_obj
287 .get("release_date")
288 .and_then(|v| v.as_str())
289 .map(str::to_string);
290 let description = model_obj
291 .get("name")
292 .and_then(|v| v.as_str())
293 .map(str::to_string);
294 let context_window = model_obj
295 .get("limit")
296 .and_then(|v| v.get("context"))
297 .and_then(|v| v.as_u64());
298 let max_output = model_obj
299 .get("limit")
300 .and_then(|v| v.get("output"))
301 .and_then(|v| v.as_u64());
302
303 models.push(CachedModel {
304 id: model_id.to_string(),
305 provider: normalize_provider(provider_key),
306 release_date,
307 description,
308 context_window,
309 max_output,
310 });
311 }
312 }
313
314 Ok(models)
315}
316
317fn is_major_provider(provider_key: &str) -> bool {
318 matches!(
319 provider_key,
320 "anthropic"
321 | "openai"
322 | "google"
323 | "meta-llama"
324 | "meta"
325 | "mistralai"
326 | "mistral"
327 | "deepseek"
328 | "cohere"
329 )
330}
331
332fn normalize_provider(slug: &str) -> String {
334 match slug {
335 "anthropic" => "Anthropic".to_string(),
336 "openai" => "OpenAI".to_string(),
337 "google" => "Google".to_string(),
338 "meta-llama" | "meta" => "Meta".to_string(),
339 "mistralai" | "mistral" => "Mistral".to_string(),
340 "deepseek" => "DeepSeek".to_string(),
341 "cohere" => "Cohere".to_string(),
342 _ => slug.to_string(),
343 }
344}
345
346pub fn auto_resolve(
360 provider: &str,
361 match_patterns: &[String],
362 exclude_patterns: &[String],
363 cache: &ModelsCache,
364) -> Option<String> {
365 let mut candidates: Vec<&CachedModel> = cache
366 .models
367 .iter()
368 .filter(|m| {
369 m.provider.eq_ignore_ascii_case(provider)
371 })
372 .filter(|m| {
373 !m.id.ends_with("-latest")
375 })
376 .filter(|m| {
377 match_patterns.iter().all(|p| glob_match(p, &m.id))
379 })
380 .filter(|m| {
381 !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
383 })
384 .collect();
385
386 candidates.sort_by(|a, b| {
388 let date_cmp = b
389 .release_date
390 .as_deref()
391 .unwrap_or("")
392 .cmp(a.release_date.as_deref().unwrap_or(""));
393 date_cmp.then_with(|| a.id.len().cmp(&b.id.len()))
394 });
395
396 candidates.first().map(|m| m.id.clone())
397}
398
399pub fn glob_match(pattern: &str, text: &str) -> bool {
402 let segments: Vec<&str> = pattern.split('*').collect();
404
405 if segments.len() == 1 {
406 return pattern == text;
408 }
409
410 let mut pos = 0;
411
412 if let Some(first) = segments.first()
414 && !first.is_empty()
415 {
416 if !text.starts_with(first) {
417 return false;
418 }
419 pos = first.len();
420 }
421
422 if let Some(last) = segments.last()
424 && !last.is_empty()
425 && !text[pos..].ends_with(last)
426 {
427 return false;
428 }
429
430 let end = if let Some(last) = segments.last() {
432 if !last.is_empty() {
433 text.len() - last.len()
434 } else {
435 text.len()
436 }
437 } else {
438 text.len()
439 };
440
441 for segment in &segments[1..segments.len().saturating_sub(1)] {
442 if segment.is_empty() {
443 continue;
444 }
445 if let Some(idx) = text[pos..end].find(segment) {
446 pos += idx + segment.len();
447 } else {
448 return false;
449 }
450 }
451
452 pos <= end
453}
454
455pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
463 let mut m = IndexMap::new();
464 let add = |m: &mut IndexMap<String, ModelAlias>,
465 name: &str,
466 provider: &str,
467 match_patterns: &[&str],
468 exclude: &[&str]| {
469 m.insert(
470 name.to_string(),
471 ModelAlias {
472 harness: None,
473 description: None,
474 spec: ModelSpec::AutoResolve {
475 provider: provider.to_string(),
476 match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
477 exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
478 },
479 },
480 );
481 };
482 add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
483 add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
484 add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
485 add(
486 &mut m,
487 "codex",
488 "openai",
489 &["*codex*"],
490 &["*-mini", "*-spark", "*-max"],
491 );
492 add(
493 &mut m,
494 "gpt",
495 "openai",
496 &["gpt-5*"],
497 &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
498 );
499 add(
500 &mut m,
501 "gemini",
502 "google",
503 &["gemini*", "*pro*"],
504 &["*-customtools"],
505 );
506 m
507}
508
509pub struct ResolvedDepModels {
515 pub source_name: String,
516 pub models: IndexMap<String, ModelAlias>,
517}
518
519pub fn merge_model_config(
525 consumer: &IndexMap<String, ModelAlias>,
526 deps: &[ResolvedDepModels],
527 diag: &mut DiagnosticCollector,
528) -> IndexMap<String, ModelAlias> {
529 let mut merged = IndexMap::new();
530 let builtins = builtin_aliases();
531
532 for (name, alias) in &builtins {
534 merged.insert(name.clone(), alias.clone());
535 }
536
537 let mut dep_provided: std::collections::HashSet<String> = std::collections::HashSet::new();
539
540 for dep in deps {
542 for (name, alias) in &dep.models {
543 if consumer.contains_key(name) {
544 continue;
546 }
547 if dep_provided.contains(name) {
548 diag.warn_with_context(
550 "model-alias-conflict",
551 format!(
552 "model alias `{name}` defined by both `{}` and earlier dependency — using earlier definition",
553 dep.source_name
554 ),
555 dep.source_name.clone(),
556 );
557 } else {
558 merged.insert(name.clone(), alias.clone());
560 dep_provided.insert(name.clone());
561 }
562 }
563 }
564
565 for (name, alias) in consumer {
567 merged.insert(name.clone(), alias.clone());
568 }
569
570 merged
571}
572
573pub fn resolve_all(
577 aliases: &IndexMap<String, ModelAlias>,
578 cache: &ModelsCache,
579) -> IndexMap<String, ResolvedAlias> {
580 let installed = harness::detect_installed_harnesses();
581 let mut resolved = IndexMap::new();
582
583 for (name, alias) in aliases {
584 let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
585 continue; };
587
588 let candidates = harness::harness_candidates_for_provider(&provider);
589 let (h, source) = resolve_harness(alias, &provider, &installed);
590
591 resolved.insert(
592 name.clone(),
593 ResolvedAlias {
594 name: name.clone(),
595 model_id,
596 provider,
597 harness: h,
598 harness_source: source,
599 harness_candidates: candidates,
600 description: alias.description.clone(),
601 },
602 );
603 }
604
605 resolved
606}
607
608fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
609 match &alias.spec {
610 ModelSpec::Pinned { model, provider } => {
611 let p = provider
612 .clone()
613 .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
614 .unwrap_or_else(|| "unknown".to_string());
615 Some((model.clone(), p))
616 }
617 ModelSpec::AutoResolve {
618 provider,
619 match_patterns,
620 exclude_patterns,
621 } => {
622 let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
623 Some((id, provider.clone()))
624 }
625 }
626}
627
628fn resolve_harness(
629 alias: &ModelAlias,
630 provider: &str,
631 installed: &HashSet<String>,
632) -> (Option<String>, HarnessSource) {
633 if let Some(h) = &alias.harness {
634 if installed.contains(h) {
635 (Some(h.clone()), HarnessSource::Explicit)
636 } else {
637 (Some(h.clone()), HarnessSource::Unavailable)
638 }
639 } else {
640 match harness::resolve_harness_for_provider(provider, installed) {
641 Some(h) => (Some(h), HarnessSource::AutoDetected),
642 None => (None, HarnessSource::Unavailable),
643 }
644 }
645}
646
647#[allow(dead_code)]
650fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
651 let id = model_id.to_lowercase();
652 if id.starts_with("claude-") {
653 return Some("anthropic");
654 }
655 if id.starts_with("gpt-")
656 || id.starts_with("o1")
657 || id.starts_with("o3")
658 || id.starts_with("o4")
659 || id.starts_with("codex-")
660 {
661 return Some("openai");
662 }
663 if id.starts_with("gemini") {
664 return Some("google");
665 }
666 if id.starts_with("llama") {
667 return Some("meta");
668 }
669 if id.starts_with("mistral") || id.starts_with("codestral") {
670 return Some("mistral");
671 }
672 if id.starts_with("deepseek") {
673 return Some("deepseek");
674 }
675 if id.starts_with("command") {
676 return Some("cohere");
677 }
678 None
679}
680
681#[cfg(test)]
686mod tests {
687 use super::*;
688 use std::collections::HashSet;
689
690 #[test]
691 fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
692 let raw = serde_json::json!({
693 "anthropic": {
694 "models": {
695 "claude-opus-4-6": {
696 "id": "claude-opus-4-6",
697 "name": "Claude Opus 4.6",
698 "release_date": "2026-02-05",
699 "limit": {
700 "context": 1000000,
701 "output": 128000
702 }
703 }
704 }
705 },
706 "openai": {
707 "models": {
708 "gpt-5": {
709 "id": "gpt-5",
710 "name": "GPT-5"
711 }
712 }
713 },
714 "random-host": {
715 "models": {
716 "foo": {
717 "id": "foo"
718 }
719 }
720 }
721 });
722
723 let models = parse_models_dev_catalog(&raw).unwrap();
724 assert_eq!(models.len(), 2);
725
726 let opus = models
727 .iter()
728 .find(|m| m.id == "claude-opus-4-6")
729 .expect("missing claude-opus-4-6");
730 assert_eq!(opus.provider, "Anthropic");
731 assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
732 assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
733 assert_eq!(opus.context_window, Some(1_000_000));
734 assert_eq!(opus.max_output, Some(128_000));
735
736 let gpt = models
737 .iter()
738 .find(|m| m.id == "gpt-5")
739 .expect("missing gpt-5");
740 assert_eq!(gpt.provider, "OpenAI");
741 assert_eq!(gpt.release_date, None);
742 assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
743 assert_eq!(gpt.context_window, None);
744 assert_eq!(gpt.max_output, None);
745 }
746
747 #[test]
748 fn parse_models_dev_catalog_requires_object_root() {
749 let raw = serde_json::json!(["not", "an", "object"]);
750 let err = parse_models_dev_catalog(&raw).unwrap_err();
751 assert!(err.to_string().contains("keyed by provider"));
752 }
753
754 #[test]
757 fn glob_exact_match() {
758 assert!(glob_match("claude-opus-4", "claude-opus-4"));
759 assert!(!glob_match("claude-opus-4", "claude-opus-5"));
760 }
761
762 #[test]
763 fn glob_star_suffix() {
764 assert!(glob_match("claude-opus-*", "claude-opus-4"));
765 assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
766 assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
767 }
768
769 #[test]
770 fn glob_star_prefix() {
771 assert!(glob_match("*-opus-4", "claude-opus-4"));
772 assert!(!glob_match("*-opus-4", "claude-opus-5"));
773 }
774
775 #[test]
776 fn glob_star_middle() {
777 assert!(glob_match("claude-*-4", "claude-opus-4"));
778 assert!(glob_match("claude-*-4", "claude-sonnet-4"));
779 assert!(!glob_match("claude-*-4", "claude-opus-5"));
780 }
781
782 #[test]
783 fn glob_multiple_stars() {
784 assert!(glob_match("*claude*opus*", "claude-opus-4"));
785 assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
786 assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
787 }
788
789 #[test]
790 fn glob_star_only() {
791 assert!(glob_match("*", "anything"));
792 assert!(glob_match("*", ""));
793 }
794
795 #[test]
796 fn glob_empty_pattern() {
797 assert!(glob_match("", ""));
798 assert!(!glob_match("", "something"));
799 }
800
801 fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
804 ModelsCache {
805 models: models
806 .into_iter()
807 .map(|(id, provider, date)| CachedModel {
808 id: id.to_string(),
809 provider: provider.to_string(),
810 release_date: date.map(String::from),
811 description: None,
812 context_window: None,
813 max_output: None,
814 })
815 .collect(),
816 fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
817 }
818 }
819
820 #[test]
821 fn auto_resolve_basic() {
822 let cache = make_cache(vec![
823 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
824 ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
825 ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
826 ]);
827
828 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
829 assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
831 }
832
833 #[test]
834 fn auto_resolve_exclude() {
835 let cache = make_cache(vec![
836 ("gpt-5", "OpenAI", Some("2025-06-01")),
837 ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
838 ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
839 ]);
840
841 let result = auto_resolve(
842 "OpenAI",
843 &["gpt-*".to_string()],
844 &["gpt-3*".to_string(), "gpt-4o*".to_string()],
845 &cache,
846 );
847 assert_eq!(result, Some("gpt-5".to_string()));
848 }
849
850 #[test]
851 fn auto_resolve_skip_latest() {
852 let cache = make_cache(vec![
853 ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
854 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
855 ]);
856
857 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
858 assert_eq!(result, Some("claude-opus-4".to_string()));
860 }
861
862 #[test]
863 fn auto_resolve_empty_cache() {
864 let cache = ModelsCache {
865 models: Vec::new(),
866 fetched_at: None,
867 };
868
869 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
870 assert_eq!(result, None);
871 }
872
873 #[test]
874 fn auto_resolve_no_match() {
875 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
876
877 let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
878 assert_eq!(result, None);
879 }
880
881 #[test]
882 fn auto_resolve_provider_case_insensitive() {
883 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
884
885 let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
886 assert_eq!(result, Some("claude-opus-4".to_string()));
887 }
888
889 #[test]
890 fn auto_resolve_shortest_id_tiebreaker() {
891 let cache = make_cache(vec![
892 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
893 ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
894 ]);
895
896 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
897 assert_eq!(result, Some("claude-opus-4".to_string()));
899 }
900
901 fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
904 ModelAlias {
905 harness: harness.map(|h| h.to_string()),
906 description: None,
907 spec: ModelSpec::Pinned {
908 model: model.to_string(),
909 provider: None,
910 },
911 }
912 }
913
914 #[test]
915 fn merge_empty_returns_builtins() {
916 let mut diag = DiagnosticCollector::new();
917 let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
918 assert!(merged.contains_key("opus"));
920 assert!(merged.contains_key("sonnet"));
921 assert!(merged.contains_key("codex"));
922 }
923
924 #[test]
925 fn merge_consumer_overrides_dependency_alias() {
926 let mut consumer = IndexMap::new();
927 consumer.insert(
928 "opus".to_string(),
929 pinned_alias(Some("custom"), "my-opus-model"),
930 );
931
932 let mut diag = DiagnosticCollector::new();
933 let merged = merge_model_config(&consumer, &[], &mut diag);
934 assert_eq!(
935 merged.get("opus").unwrap().spec,
936 ModelSpec::Pinned {
937 model: "my-opus-model".to_string(),
938 provider: None
939 }
940 );
941 }
942
943 #[test]
944 fn merge_dep_overrides_builtin() {
945 let dep = ResolvedDepModels {
946 source_name: "my-pkg".to_string(),
947 models: {
948 let mut m = IndexMap::new();
949 m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
950 m
951 },
952 };
953
954 let mut diag = DiagnosticCollector::new();
955 let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
956 assert_eq!(
958 merged.get("opus").unwrap().spec,
959 ModelSpec::Pinned {
960 model: "pkg-opus".to_string(),
961 provider: None
962 }
963 );
964 }
965
966 #[test]
967 fn merge_consumer_beats_dep() {
968 let mut consumer = IndexMap::new();
969 consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
970
971 let dep = ResolvedDepModels {
972 source_name: "pkg".to_string(),
973 models: {
974 let mut m = IndexMap::new();
975 m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
976 m
977 },
978 };
979
980 let mut diag = DiagnosticCollector::new();
981 let merged = merge_model_config(&consumer, &[dep], &mut diag);
982 assert_eq!(
983 merged.get("opus").unwrap().spec,
984 ModelSpec::Pinned {
985 model: "consumer-opus".to_string(),
986 provider: None
987 }
988 );
989 }
990
991 #[test]
992 fn merge_dep_conflict_warns() {
993 let dep1 = ResolvedDepModels {
994 source_name: "pkg-a".to_string(),
995 models: {
996 let mut m = IndexMap::new();
997 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
998 m
999 },
1000 };
1001 let dep2 = ResolvedDepModels {
1002 source_name: "pkg-b".to_string(),
1003 models: {
1004 let mut m = IndexMap::new();
1005 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1006 m
1007 },
1008 };
1009
1010 let mut diag = DiagnosticCollector::new();
1011 let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1012 assert_eq!(
1014 merged.get("custom").unwrap().spec,
1015 ModelSpec::Pinned {
1016 model: "model-a".to_string(),
1017 provider: None
1018 }
1019 );
1020 let warnings = diag.drain();
1022 assert_eq!(warnings.len(), 1);
1023 assert_eq!(warnings[0].code, "model-alias-conflict");
1024 }
1025
1026 #[test]
1029 fn resolve_all_pinned() {
1030 let mut aliases = IndexMap::new();
1031 aliases.insert(
1032 "fast".to_string(),
1033 pinned_alias(Some("claude"), "claude-haiku-4-5"),
1034 );
1035
1036 let cache = ModelsCache {
1037 models: Vec::new(),
1038 fetched_at: None,
1039 };
1040
1041 let resolved = resolve_all(&aliases, &cache);
1042 let entry = resolved.get("fast").unwrap();
1043 assert_eq!(entry.model_id, "claude-haiku-4-5");
1044 assert_eq!(entry.provider, "anthropic");
1045 }
1046
1047 #[test]
1048 fn resolve_all_pinned_with_provider() {
1049 let mut aliases = IndexMap::new();
1050 aliases.insert(
1051 "fast".to_string(),
1052 ModelAlias {
1053 harness: None,
1054 description: None,
1055 spec: ModelSpec::Pinned {
1056 model: "gpt-5.3-codex".to_string(),
1057 provider: Some("openai".to_string()),
1058 },
1059 },
1060 );
1061
1062 let cache = ModelsCache {
1063 models: Vec::new(),
1064 fetched_at: None,
1065 };
1066
1067 let resolved = resolve_all(&aliases, &cache);
1068 let entry = resolved.get("fast").unwrap();
1069 assert_eq!(entry.model_id, "gpt-5.3-codex");
1070 assert_eq!(entry.provider, "openai");
1071 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1072 }
1073
1074 #[test]
1075 fn resolve_all_pinned_auto_detect_harness() {
1076 let mut aliases = IndexMap::new();
1077 aliases.insert(
1078 "opus".to_string(),
1079 ModelAlias {
1080 harness: None,
1081 description: None,
1082 spec: ModelSpec::Pinned {
1083 model: "claude-opus-4-6".to_string(),
1084 provider: Some("anthropic".to_string()),
1085 },
1086 },
1087 );
1088
1089 let cache = ModelsCache {
1090 models: Vec::new(),
1091 fetched_at: None,
1092 };
1093
1094 let resolved = resolve_all(&aliases, &cache);
1095 let entry = resolved.get("opus").unwrap();
1096 assert_eq!(entry.model_id, "claude-opus-4-6");
1097 assert_eq!(entry.provider, "anthropic");
1098
1099 let installed = harness::detect_installed_harnesses();
1100 let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1101 let expected_source = if expected_harness.is_some() {
1102 HarnessSource::AutoDetected
1103 } else {
1104 HarnessSource::Unavailable
1105 };
1106
1107 assert_eq!(entry.harness, expected_harness);
1108 assert_eq!(entry.harness_source, expected_source);
1109 }
1110
1111 #[test]
1112 fn resolve_all_auto_detect_harness() {
1113 let mut aliases = IndexMap::new();
1114 aliases.insert(
1115 "gpt".to_string(),
1116 ModelAlias {
1117 harness: None,
1118 description: None,
1119 spec: ModelSpec::AutoResolve {
1120 provider: "openai".to_string(),
1121 match_patterns: vec!["gpt-5*".to_string()],
1122 exclude_patterns: vec![],
1123 },
1124 },
1125 );
1126 let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
1127
1128 let resolved = resolve_all(&aliases, &cache);
1129 let entry = resolved.get("gpt").unwrap();
1130 assert_eq!(entry.model_id, "gpt-5");
1131 assert_eq!(entry.provider, "openai");
1132 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1133 match entry.harness_source {
1134 HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
1135 HarnessSource::Unavailable => assert!(entry.harness.is_none()),
1136 HarnessSource::Explicit => panic!("unexpected explicit harness source"),
1137 }
1138 }
1139
1140 #[test]
1141 fn resolve_all_unavailable_harness_still_included() {
1142 let mut aliases = IndexMap::new();
1143 aliases.insert(
1144 "opus".to_string(),
1145 ModelAlias {
1146 harness: Some("missing-harness-xyz".to_string()),
1147 description: None,
1148 spec: ModelSpec::Pinned {
1149 model: "claude-opus-4-6".to_string(),
1150 provider: None,
1151 },
1152 },
1153 );
1154
1155 let cache = ModelsCache {
1156 models: Vec::new(),
1157 fetched_at: None,
1158 };
1159
1160 let resolved = resolve_all(&aliases, &cache);
1161 let entry = resolved.get("opus").unwrap();
1162 assert_eq!(entry.model_id, "claude-opus-4-6");
1163 assert_eq!(entry.provider, "anthropic");
1164 assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
1165 assert_eq!(entry.harness_source, HarnessSource::Unavailable);
1166 }
1167
1168 #[test]
1169 fn resolve_all_empty_cache_omits_unresolvable() {
1170 let mut aliases = IndexMap::new();
1171 aliases.insert(
1172 "opus".to_string(),
1173 ModelAlias {
1174 harness: Some("claude".to_string()),
1175 description: None,
1176 spec: ModelSpec::AutoResolve {
1177 provider: "Anthropic".to_string(),
1178 match_patterns: vec!["claude-opus-*".to_string()],
1179 exclude_patterns: vec![],
1180 },
1181 },
1182 );
1183 let cache = ModelsCache {
1184 models: Vec::new(),
1185 fetched_at: None,
1186 };
1187
1188 let resolved = resolve_all(&aliases, &cache);
1189 assert!(!resolved.contains_key("opus"));
1191 }
1192
1193 #[test]
1194 fn resolve_model_and_provider_pinned_explicit_provider() {
1195 let alias = ModelAlias {
1196 harness: None,
1197 description: None,
1198 spec: ModelSpec::Pinned {
1199 model: "claude-opus-4-6".to_string(),
1200 provider: Some("anthropic".to_string()),
1201 },
1202 };
1203 let cache = ModelsCache {
1204 models: Vec::new(),
1205 fetched_at: None,
1206 };
1207
1208 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1209 assert_eq!(
1210 resolved,
1211 ("claude-opus-4-6".to_string(), "anthropic".to_string())
1212 );
1213 }
1214
1215 #[test]
1216 fn resolve_model_and_provider_pinned_inferred() {
1217 let alias = ModelAlias {
1218 harness: None,
1219 description: None,
1220 spec: ModelSpec::Pinned {
1221 model: "claude-opus-4-6".to_string(),
1222 provider: None,
1223 },
1224 };
1225 let cache = ModelsCache {
1226 models: Vec::new(),
1227 fetched_at: None,
1228 };
1229
1230 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1231 assert_eq!(
1232 resolved,
1233 ("claude-opus-4-6".to_string(), "anthropic".to_string())
1234 );
1235 }
1236
1237 #[test]
1238 fn resolve_model_and_provider_pinned_unknown() {
1239 let alias = ModelAlias {
1240 harness: None,
1241 description: None,
1242 spec: ModelSpec::Pinned {
1243 model: "my-custom-model".to_string(),
1244 provider: None,
1245 },
1246 };
1247 let cache = ModelsCache {
1248 models: Vec::new(),
1249 fetched_at: None,
1250 };
1251
1252 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1253 assert_eq!(
1254 resolved,
1255 ("my-custom-model".to_string(), "unknown".to_string())
1256 );
1257 }
1258
1259 #[test]
1260 fn resolve_model_and_provider_auto_resolve() {
1261 let alias = ModelAlias {
1262 harness: None,
1263 description: None,
1264 spec: ModelSpec::AutoResolve {
1265 provider: "openai".to_string(),
1266 match_patterns: vec!["gpt-5*".to_string()],
1267 exclude_patterns: vec![],
1268 },
1269 };
1270 let cache = make_cache(vec![
1271 ("gpt-4o", "OpenAI", Some("2024-06-01")),
1272 ("gpt-5", "OpenAI", Some("2025-06-01")),
1273 ]);
1274
1275 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1276 assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
1277 }
1278
1279 #[test]
1280 fn resolve_harness_explicit_installed() {
1281 let alias = ModelAlias {
1282 harness: Some("claude".to_string()),
1283 description: None,
1284 spec: ModelSpec::Pinned {
1285 model: "claude-opus-4-6".to_string(),
1286 provider: None,
1287 },
1288 };
1289 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1290
1291 let resolved = resolve_harness(&alias, "anthropic", &installed);
1292 assert_eq!(
1293 resolved,
1294 (Some("claude".to_string()), HarnessSource::Explicit)
1295 );
1296 }
1297
1298 #[test]
1299 fn resolve_harness_explicit_not_installed() {
1300 let alias = ModelAlias {
1301 harness: Some("claude".to_string()),
1302 description: None,
1303 spec: ModelSpec::Pinned {
1304 model: "claude-opus-4-6".to_string(),
1305 provider: None,
1306 },
1307 };
1308 let installed = HashSet::new();
1309
1310 let resolved = resolve_harness(&alias, "anthropic", &installed);
1311 assert_eq!(
1312 resolved,
1313 (Some("claude".to_string()), HarnessSource::Unavailable)
1314 );
1315 }
1316
1317 #[test]
1318 fn resolve_harness_auto_detected() {
1319 let alias = ModelAlias {
1320 harness: None,
1321 description: None,
1322 spec: ModelSpec::Pinned {
1323 model: "claude-opus-4-6".to_string(),
1324 provider: Some("anthropic".to_string()),
1325 },
1326 };
1327 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1328
1329 let resolved = resolve_harness(&alias, "anthropic", &installed);
1330 assert_eq!(
1331 resolved,
1332 (Some("claude".to_string()), HarnessSource::AutoDetected)
1333 );
1334 }
1335
1336 #[test]
1337 fn resolve_harness_unavailable() {
1338 let alias = ModelAlias {
1339 harness: None,
1340 description: None,
1341 spec: ModelSpec::Pinned {
1342 model: "claude-opus-4-6".to_string(),
1343 provider: Some("anthropic".to_string()),
1344 },
1345 };
1346 let installed = HashSet::new();
1347
1348 let resolved = resolve_harness(&alias, "anthropic", &installed);
1349 assert_eq!(resolved, (None, HarnessSource::Unavailable));
1350 }
1351
1352 #[test]
1353 fn resolve_harness_unavailable_no_provider_match() {
1354 let alias = ModelAlias {
1355 harness: None,
1356 description: None,
1357 spec: ModelSpec::Pinned {
1358 model: "my-custom-model".to_string(),
1359 provider: Some("unknown".to_string()),
1360 },
1361 };
1362 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1363
1364 let resolved = resolve_harness(&alias, "unknown", &installed);
1365 assert_eq!(resolved, (None, HarnessSource::Unavailable));
1366 }
1367
1368 #[test]
1371 fn harness_source_serializes_snake_case() {
1372 assert_eq!(
1373 serde_json::to_string(&HarnessSource::Explicit).unwrap(),
1374 "\"explicit\""
1375 );
1376 assert_eq!(
1377 serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
1378 "\"auto_detected\""
1379 );
1380 assert_eq!(
1381 serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
1382 "\"unavailable\""
1383 );
1384 }
1385
1386 #[test]
1387 fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
1388 let toml_str = r#"
1389[models.fast]
1390harness = "claude"
1391model = "claude-haiku-4-5"
1392description = "Fast and cheap"
1393"#;
1394
1395 #[derive(Debug, Deserialize)]
1396 struct Wrapper {
1397 models: IndexMap<String, ModelAlias>,
1398 }
1399
1400 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1401 let alias = parsed.models.get("fast").unwrap();
1402 assert_eq!(
1403 alias.spec,
1404 ModelSpec::Pinned {
1405 model: "claude-haiku-4-5".to_string(),
1406 provider: None
1407 }
1408 );
1409 assert_eq!(alias.harness.as_deref(), Some("claude"));
1410 assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
1411
1412 let json = serde_json::to_string(alias).unwrap();
1413 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1414 assert_eq!(roundtripped, *alias);
1415 }
1416
1417 #[test]
1418 fn model_alias_pinned_toml_roundtrip_without_harness() {
1419 let toml_str = r#"
1420[models.fast]
1421model = "claude-haiku-4-5"
1422"#;
1423
1424 #[derive(Debug, Deserialize)]
1425 struct Wrapper {
1426 models: IndexMap<String, ModelAlias>,
1427 }
1428
1429 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1430 let alias = parsed.models.get("fast").unwrap();
1431 assert_eq!(alias.harness, None);
1432 assert_eq!(
1433 alias.spec,
1434 ModelSpec::Pinned {
1435 model: "claude-haiku-4-5".to_string(),
1436 provider: None
1437 }
1438 );
1439
1440 let json = serde_json::to_string(alias).unwrap();
1441 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1442 assert!(value.get("harness").is_none());
1443 assert!(value.get("provider").is_none());
1444 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1445 assert_eq!(roundtripped, *alias);
1446 }
1447
1448 #[test]
1449 fn model_alias_pinned_toml_roundtrip_with_provider() {
1450 let toml_str = r#"
1451[models.fast]
1452model = "claude-haiku-4-5"
1453provider = "anthropic"
1454"#;
1455
1456 #[derive(Debug, Deserialize)]
1457 struct Wrapper {
1458 models: IndexMap<String, ModelAlias>,
1459 }
1460
1461 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1462 let alias = parsed.models.get("fast").unwrap();
1463 assert_eq!(alias.harness, None);
1464 assert_eq!(
1465 alias.spec,
1466 ModelSpec::Pinned {
1467 model: "claude-haiku-4-5".to_string(),
1468 provider: Some("anthropic".to_string())
1469 }
1470 );
1471
1472 let json = serde_json::to_string(alias).unwrap();
1473 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1474 assert_eq!(
1475 value.get("provider").and_then(serde_json::Value::as_str),
1476 Some("anthropic")
1477 );
1478 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1479 assert_eq!(roundtripped, *alias);
1480 }
1481
1482 #[test]
1483 fn model_alias_pinned_json_roundtrip_with_provider() {
1484 let json = r#"{
1485 "model": "gpt-5.3-codex",
1486 "provider": "openai"
1487 }"#;
1488
1489 let alias: ModelAlias = serde_json::from_str(json).unwrap();
1490 assert_eq!(alias.harness, None);
1491 assert_eq!(alias.description, None);
1492 assert_eq!(
1493 alias.spec,
1494 ModelSpec::Pinned {
1495 model: "gpt-5.3-codex".to_string(),
1496 provider: Some("openai".to_string())
1497 }
1498 );
1499
1500 let encoded = serde_json::to_string(&alias).unwrap();
1501 let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
1502 assert_eq!(roundtripped, alias);
1503 }
1504
1505 #[test]
1506 fn model_alias_auto_resolve_toml_roundtrip() {
1507 let toml_str = r#"
1508[models.opus]
1509harness = "claude"
1510provider = "Anthropic"
1511match = ["claude-opus-*"]
1512exclude = ["claude-opus-3*"]
1513description = "Best reasoning"
1514"#;
1515
1516 #[derive(Debug, Deserialize)]
1517 struct Wrapper {
1518 models: IndexMap<String, ModelAlias>,
1519 }
1520
1521 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1522 let alias = parsed.models.get("opus").unwrap();
1523 assert_eq!(alias.harness.as_deref(), Some("claude"));
1524 match &alias.spec {
1525 ModelSpec::AutoResolve {
1526 provider,
1527 match_patterns,
1528 exclude_patterns,
1529 } => {
1530 assert_eq!(provider, "Anthropic");
1531 assert_eq!(match_patterns, &["claude-opus-*"]);
1532 assert_eq!(exclude_patterns, &["claude-opus-3*"]);
1533 }
1534 _ => panic!("expected AutoResolve"),
1535 }
1536 }
1537
1538 #[test]
1539 fn model_alias_both_model_and_match_errors() {
1540 let toml_str = r#"
1541[models.bad]
1542harness = "claude"
1543model = "some-model"
1544match = ["pattern-*"]
1545"#;
1546
1547 #[derive(Debug, Deserialize)]
1548 struct Wrapper {
1549 #[expect(dead_code)]
1550 models: IndexMap<String, ModelAlias>,
1551 }
1552
1553 let result = toml::from_str::<Wrapper>(toml_str);
1554 assert!(result.is_err());
1555 let err_msg = result.unwrap_err().to_string();
1556 assert!(err_msg.contains("both"));
1557 }
1558
1559 #[test]
1560 fn model_alias_neither_model_nor_match_errors() {
1561 let toml_str = r#"
1562[models.bad]
1563harness = "claude"
1564"#;
1565
1566 #[derive(Debug, Deserialize)]
1567 struct Wrapper {
1568 #[expect(dead_code)]
1569 models: IndexMap<String, ModelAlias>,
1570 }
1571
1572 let result = toml::from_str::<Wrapper>(toml_str);
1573 assert!(result.is_err());
1574 }
1575
1576 #[test]
1577 fn infer_provider_from_model_id_detects_known_prefixes() {
1578 assert_eq!(
1579 infer_provider_from_model_id("claude-opus-4-6"),
1580 Some("anthropic")
1581 );
1582 assert_eq!(
1583 infer_provider_from_model_id("gpt-5.3-codex"),
1584 Some("openai")
1585 );
1586 assert_eq!(
1587 infer_provider_from_model_id("gemini-2.5-pro"),
1588 Some("google")
1589 );
1590 assert_eq!(
1591 infer_provider_from_model_id("llama-4-maverick"),
1592 Some("meta")
1593 );
1594 assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
1595 assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
1596 assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
1597 assert_eq!(
1598 infer_provider_from_model_id("codex-mini-latest"),
1599 Some("openai")
1600 );
1601 assert_eq!(
1602 infer_provider_from_model_id("mistral-large"),
1603 Some("mistral")
1604 );
1605 assert_eq!(
1606 infer_provider_from_model_id("codestral-latest"),
1607 Some("mistral")
1608 );
1609 assert_eq!(
1610 infer_provider_from_model_id("deepseek-chat"),
1611 Some("deepseek")
1612 );
1613 assert_eq!(
1614 infer_provider_from_model_id("command-r-plus"),
1615 Some("cohere")
1616 );
1617 }
1618
1619 #[test]
1620 fn infer_provider_from_model_id_returns_none_for_unknown_model() {
1621 assert_eq!(infer_provider_from_model_id("unknown-model"), None);
1622 }
1623
1624 #[test]
1625 fn infer_provider_from_model_id_returns_none_for_empty_string() {
1626 assert_eq!(infer_provider_from_model_id(""), None);
1627 }
1628
1629 #[test]
1630 fn infer_provider_from_model_id_is_case_insensitive() {
1631 assert_eq!(
1632 infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
1633 Some("anthropic")
1634 );
1635 assert_eq!(
1636 infer_provider_from_model_id("GPT-5.3-codex"),
1637 Some("openai")
1638 );
1639 assert_eq!(
1640 infer_provider_from_model_id("CoDeStRaL-latest"),
1641 Some("mistral")
1642 );
1643 }
1644}