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
608pub fn filter_by_visibility(
613 mut aliases: IndexMap<String, ResolvedAlias>,
614 visibility: &crate::config::ModelVisibility,
615) -> IndexMap<String, ResolvedAlias> {
616 if let Some(includes) = &visibility.include {
617 aliases.retain(|name, _| includes.iter().any(|p| glob_match(p, name)));
618 } else if let Some(excludes) = &visibility.exclude {
619 aliases.retain(|name, _| !excludes.iter().any(|p| glob_match(p, name)));
620 }
621 aliases
622}
623
624fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
625 match &alias.spec {
626 ModelSpec::Pinned { model, provider } => {
627 let p = provider
628 .clone()
629 .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
630 .unwrap_or_else(|| "unknown".to_string());
631 Some((model.clone(), p))
632 }
633 ModelSpec::AutoResolve {
634 provider,
635 match_patterns,
636 exclude_patterns,
637 } => {
638 let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
639 Some((id, provider.clone()))
640 }
641 }
642}
643
644fn resolve_harness(
645 alias: &ModelAlias,
646 provider: &str,
647 installed: &HashSet<String>,
648) -> (Option<String>, HarnessSource) {
649 if let Some(h) = &alias.harness {
650 if installed.contains(h) {
651 (Some(h.clone()), HarnessSource::Explicit)
652 } else {
653 (Some(h.clone()), HarnessSource::Unavailable)
654 }
655 } else {
656 match harness::resolve_harness_for_provider(provider, installed) {
657 Some(h) => (Some(h), HarnessSource::AutoDetected),
658 None => (None, HarnessSource::Unavailable),
659 }
660 }
661}
662
663#[allow(dead_code)]
666fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
667 let id = model_id.to_lowercase();
668 if id.starts_with("claude-") {
669 return Some("anthropic");
670 }
671 if id.starts_with("gpt-")
672 || id.starts_with("o1")
673 || id.starts_with("o3")
674 || id.starts_with("o4")
675 || id.starts_with("codex-")
676 {
677 return Some("openai");
678 }
679 if id.starts_with("gemini") {
680 return Some("google");
681 }
682 if id.starts_with("llama") {
683 return Some("meta");
684 }
685 if id.starts_with("mistral") || id.starts_with("codestral") {
686 return Some("mistral");
687 }
688 if id.starts_with("deepseek") {
689 return Some("deepseek");
690 }
691 if id.starts_with("command") {
692 return Some("cohere");
693 }
694 None
695}
696
697#[cfg(test)]
702mod tests {
703 use super::*;
704 use std::collections::HashSet;
705
706 #[test]
707 fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
708 let raw = serde_json::json!({
709 "anthropic": {
710 "models": {
711 "claude-opus-4-6": {
712 "id": "claude-opus-4-6",
713 "name": "Claude Opus 4.6",
714 "release_date": "2026-02-05",
715 "limit": {
716 "context": 1000000,
717 "output": 128000
718 }
719 }
720 }
721 },
722 "openai": {
723 "models": {
724 "gpt-5": {
725 "id": "gpt-5",
726 "name": "GPT-5"
727 }
728 }
729 },
730 "random-host": {
731 "models": {
732 "foo": {
733 "id": "foo"
734 }
735 }
736 }
737 });
738
739 let models = parse_models_dev_catalog(&raw).unwrap();
740 assert_eq!(models.len(), 2);
741
742 let opus = models
743 .iter()
744 .find(|m| m.id == "claude-opus-4-6")
745 .expect("missing claude-opus-4-6");
746 assert_eq!(opus.provider, "Anthropic");
747 assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
748 assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
749 assert_eq!(opus.context_window, Some(1_000_000));
750 assert_eq!(opus.max_output, Some(128_000));
751
752 let gpt = models
753 .iter()
754 .find(|m| m.id == "gpt-5")
755 .expect("missing gpt-5");
756 assert_eq!(gpt.provider, "OpenAI");
757 assert_eq!(gpt.release_date, None);
758 assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
759 assert_eq!(gpt.context_window, None);
760 assert_eq!(gpt.max_output, None);
761 }
762
763 #[test]
764 fn parse_models_dev_catalog_requires_object_root() {
765 let raw = serde_json::json!(["not", "an", "object"]);
766 let err = parse_models_dev_catalog(&raw).unwrap_err();
767 assert!(err.to_string().contains("keyed by provider"));
768 }
769
770 #[test]
773 fn glob_exact_match() {
774 assert!(glob_match("claude-opus-4", "claude-opus-4"));
775 assert!(!glob_match("claude-opus-4", "claude-opus-5"));
776 }
777
778 #[test]
779 fn glob_star_suffix() {
780 assert!(glob_match("claude-opus-*", "claude-opus-4"));
781 assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
782 assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
783 }
784
785 #[test]
786 fn glob_star_prefix() {
787 assert!(glob_match("*-opus-4", "claude-opus-4"));
788 assert!(!glob_match("*-opus-4", "claude-opus-5"));
789 }
790
791 #[test]
792 fn glob_star_middle() {
793 assert!(glob_match("claude-*-4", "claude-opus-4"));
794 assert!(glob_match("claude-*-4", "claude-sonnet-4"));
795 assert!(!glob_match("claude-*-4", "claude-opus-5"));
796 }
797
798 #[test]
799 fn glob_multiple_stars() {
800 assert!(glob_match("*claude*opus*", "claude-opus-4"));
801 assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
802 assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
803 }
804
805 #[test]
806 fn glob_star_only() {
807 assert!(glob_match("*", "anything"));
808 assert!(glob_match("*", ""));
809 }
810
811 #[test]
812 fn glob_empty_pattern() {
813 assert!(glob_match("", ""));
814 assert!(!glob_match("", "something"));
815 }
816
817 fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
820 ModelsCache {
821 models: models
822 .into_iter()
823 .map(|(id, provider, date)| CachedModel {
824 id: id.to_string(),
825 provider: provider.to_string(),
826 release_date: date.map(String::from),
827 description: None,
828 context_window: None,
829 max_output: None,
830 })
831 .collect(),
832 fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
833 }
834 }
835
836 #[test]
837 fn auto_resolve_basic() {
838 let cache = make_cache(vec![
839 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
840 ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
841 ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
842 ]);
843
844 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
845 assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
847 }
848
849 #[test]
850 fn auto_resolve_exclude() {
851 let cache = make_cache(vec![
852 ("gpt-5", "OpenAI", Some("2025-06-01")),
853 ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
854 ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
855 ]);
856
857 let result = auto_resolve(
858 "OpenAI",
859 &["gpt-*".to_string()],
860 &["gpt-3*".to_string(), "gpt-4o*".to_string()],
861 &cache,
862 );
863 assert_eq!(result, Some("gpt-5".to_string()));
864 }
865
866 #[test]
867 fn auto_resolve_skip_latest() {
868 let cache = make_cache(vec![
869 ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
870 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
871 ]);
872
873 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
874 assert_eq!(result, Some("claude-opus-4".to_string()));
876 }
877
878 #[test]
879 fn auto_resolve_empty_cache() {
880 let cache = ModelsCache {
881 models: Vec::new(),
882 fetched_at: None,
883 };
884
885 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
886 assert_eq!(result, None);
887 }
888
889 #[test]
890 fn auto_resolve_no_match() {
891 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
892
893 let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
894 assert_eq!(result, None);
895 }
896
897 #[test]
898 fn auto_resolve_provider_case_insensitive() {
899 let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
900
901 let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
902 assert_eq!(result, Some("claude-opus-4".to_string()));
903 }
904
905 #[test]
906 fn auto_resolve_shortest_id_tiebreaker() {
907 let cache = make_cache(vec![
908 ("claude-opus-4", "Anthropic", Some("2025-03-01")),
909 ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
910 ]);
911
912 let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
913 assert_eq!(result, Some("claude-opus-4".to_string()));
915 }
916
917 fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
920 ModelAlias {
921 harness: harness.map(|h| h.to_string()),
922 description: None,
923 spec: ModelSpec::Pinned {
924 model: model.to_string(),
925 provider: None,
926 },
927 }
928 }
929
930 #[test]
931 fn merge_empty_returns_builtins() {
932 let mut diag = DiagnosticCollector::new();
933 let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
934 assert!(merged.contains_key("opus"));
936 assert!(merged.contains_key("sonnet"));
937 assert!(merged.contains_key("codex"));
938 }
939
940 #[test]
941 fn merge_consumer_overrides_dependency_alias() {
942 let mut consumer = IndexMap::new();
943 consumer.insert(
944 "opus".to_string(),
945 pinned_alias(Some("custom"), "my-opus-model"),
946 );
947
948 let mut diag = DiagnosticCollector::new();
949 let merged = merge_model_config(&consumer, &[], &mut diag);
950 assert_eq!(
951 merged.get("opus").unwrap().spec,
952 ModelSpec::Pinned {
953 model: "my-opus-model".to_string(),
954 provider: None
955 }
956 );
957 }
958
959 #[test]
960 fn merge_dep_overrides_builtin() {
961 let dep = ResolvedDepModels {
962 source_name: "my-pkg".to_string(),
963 models: {
964 let mut m = IndexMap::new();
965 m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
966 m
967 },
968 };
969
970 let mut diag = DiagnosticCollector::new();
971 let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
972 assert_eq!(
974 merged.get("opus").unwrap().spec,
975 ModelSpec::Pinned {
976 model: "pkg-opus".to_string(),
977 provider: None
978 }
979 );
980 }
981
982 #[test]
983 fn merge_consumer_beats_dep() {
984 let mut consumer = IndexMap::new();
985 consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
986
987 let dep = ResolvedDepModels {
988 source_name: "pkg".to_string(),
989 models: {
990 let mut m = IndexMap::new();
991 m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
992 m
993 },
994 };
995
996 let mut diag = DiagnosticCollector::new();
997 let merged = merge_model_config(&consumer, &[dep], &mut diag);
998 assert_eq!(
999 merged.get("opus").unwrap().spec,
1000 ModelSpec::Pinned {
1001 model: "consumer-opus".to_string(),
1002 provider: None
1003 }
1004 );
1005 }
1006
1007 #[test]
1008 fn merge_dep_conflict_warns() {
1009 let dep1 = ResolvedDepModels {
1010 source_name: "pkg-a".to_string(),
1011 models: {
1012 let mut m = IndexMap::new();
1013 m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1014 m
1015 },
1016 };
1017 let dep2 = ResolvedDepModels {
1018 source_name: "pkg-b".to_string(),
1019 models: {
1020 let mut m = IndexMap::new();
1021 m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1022 m
1023 },
1024 };
1025
1026 let mut diag = DiagnosticCollector::new();
1027 let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1028 assert_eq!(
1030 merged.get("custom").unwrap().spec,
1031 ModelSpec::Pinned {
1032 model: "model-a".to_string(),
1033 provider: None
1034 }
1035 );
1036 let warnings = diag.drain();
1038 assert_eq!(warnings.len(), 1);
1039 assert_eq!(warnings[0].code, "model-alias-conflict");
1040 }
1041
1042 #[test]
1045 fn resolve_all_pinned() {
1046 let mut aliases = IndexMap::new();
1047 aliases.insert(
1048 "fast".to_string(),
1049 pinned_alias(Some("claude"), "claude-haiku-4-5"),
1050 );
1051
1052 let cache = ModelsCache {
1053 models: Vec::new(),
1054 fetched_at: None,
1055 };
1056
1057 let resolved = resolve_all(&aliases, &cache);
1058 let entry = resolved.get("fast").unwrap();
1059 assert_eq!(entry.model_id, "claude-haiku-4-5");
1060 assert_eq!(entry.provider, "anthropic");
1061 }
1062
1063 #[test]
1064 fn resolve_all_pinned_with_provider() {
1065 let mut aliases = IndexMap::new();
1066 aliases.insert(
1067 "fast".to_string(),
1068 ModelAlias {
1069 harness: None,
1070 description: None,
1071 spec: ModelSpec::Pinned {
1072 model: "gpt-5.3-codex".to_string(),
1073 provider: Some("openai".to_string()),
1074 },
1075 },
1076 );
1077
1078 let cache = ModelsCache {
1079 models: Vec::new(),
1080 fetched_at: None,
1081 };
1082
1083 let resolved = resolve_all(&aliases, &cache);
1084 let entry = resolved.get("fast").unwrap();
1085 assert_eq!(entry.model_id, "gpt-5.3-codex");
1086 assert_eq!(entry.provider, "openai");
1087 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1088 }
1089
1090 #[test]
1091 fn resolve_all_pinned_auto_detect_harness() {
1092 let mut aliases = IndexMap::new();
1093 aliases.insert(
1094 "opus".to_string(),
1095 ModelAlias {
1096 harness: None,
1097 description: None,
1098 spec: ModelSpec::Pinned {
1099 model: "claude-opus-4-6".to_string(),
1100 provider: Some("anthropic".to_string()),
1101 },
1102 },
1103 );
1104
1105 let cache = ModelsCache {
1106 models: Vec::new(),
1107 fetched_at: None,
1108 };
1109
1110 let resolved = resolve_all(&aliases, &cache);
1111 let entry = resolved.get("opus").unwrap();
1112 assert_eq!(entry.model_id, "claude-opus-4-6");
1113 assert_eq!(entry.provider, "anthropic");
1114
1115 let installed = harness::detect_installed_harnesses();
1116 let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1117 let expected_source = if expected_harness.is_some() {
1118 HarnessSource::AutoDetected
1119 } else {
1120 HarnessSource::Unavailable
1121 };
1122
1123 assert_eq!(entry.harness, expected_harness);
1124 assert_eq!(entry.harness_source, expected_source);
1125 }
1126
1127 #[test]
1128 fn resolve_all_auto_detect_harness() {
1129 let mut aliases = IndexMap::new();
1130 aliases.insert(
1131 "gpt".to_string(),
1132 ModelAlias {
1133 harness: None,
1134 description: None,
1135 spec: ModelSpec::AutoResolve {
1136 provider: "openai".to_string(),
1137 match_patterns: vec!["gpt-5*".to_string()],
1138 exclude_patterns: vec![],
1139 },
1140 },
1141 );
1142 let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
1143
1144 let resolved = resolve_all(&aliases, &cache);
1145 let entry = resolved.get("gpt").unwrap();
1146 assert_eq!(entry.model_id, "gpt-5");
1147 assert_eq!(entry.provider, "openai");
1148 assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1149 match entry.harness_source {
1150 HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
1151 HarnessSource::Unavailable => assert!(entry.harness.is_none()),
1152 HarnessSource::Explicit => panic!("unexpected explicit harness source"),
1153 }
1154 }
1155
1156 #[test]
1157 fn resolve_all_unavailable_harness_still_included() {
1158 let mut aliases = IndexMap::new();
1159 aliases.insert(
1160 "opus".to_string(),
1161 ModelAlias {
1162 harness: Some("missing-harness-xyz".to_string()),
1163 description: None,
1164 spec: ModelSpec::Pinned {
1165 model: "claude-opus-4-6".to_string(),
1166 provider: None,
1167 },
1168 },
1169 );
1170
1171 let cache = ModelsCache {
1172 models: Vec::new(),
1173 fetched_at: None,
1174 };
1175
1176 let resolved = resolve_all(&aliases, &cache);
1177 let entry = resolved.get("opus").unwrap();
1178 assert_eq!(entry.model_id, "claude-opus-4-6");
1179 assert_eq!(entry.provider, "anthropic");
1180 assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
1181 assert_eq!(entry.harness_source, HarnessSource::Unavailable);
1182 }
1183
1184 #[test]
1185 fn resolve_all_empty_cache_omits_unresolvable() {
1186 let mut aliases = IndexMap::new();
1187 aliases.insert(
1188 "opus".to_string(),
1189 ModelAlias {
1190 harness: Some("claude".to_string()),
1191 description: None,
1192 spec: ModelSpec::AutoResolve {
1193 provider: "Anthropic".to_string(),
1194 match_patterns: vec!["claude-opus-*".to_string()],
1195 exclude_patterns: vec![],
1196 },
1197 },
1198 );
1199 let cache = ModelsCache {
1200 models: Vec::new(),
1201 fetched_at: None,
1202 };
1203
1204 let resolved = resolve_all(&aliases, &cache);
1205 assert!(!resolved.contains_key("opus"));
1207 }
1208
1209 fn make_resolved_alias(name: &str) -> ResolvedAlias {
1210 ResolvedAlias {
1211 name: name.to_string(),
1212 model_id: format!("model-{name}"),
1213 provider: "openai".to_string(),
1214 harness: Some("codex".to_string()),
1215 harness_source: HarnessSource::Explicit,
1216 harness_candidates: vec!["codex".to_string()],
1217 description: None,
1218 }
1219 }
1220
1221 #[test]
1222 fn filter_by_visibility_include_mode_keeps_matches_only() {
1223 let mut aliases = IndexMap::new();
1224 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1225 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1226 aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
1227
1228 let filtered = filter_by_visibility(
1229 aliases,
1230 &crate::config::ModelVisibility {
1231 include: Some(vec!["opus*".to_string(), "gpt-*".to_string()]),
1232 exclude: None,
1233 },
1234 );
1235
1236 assert_eq!(filtered.len(), 2);
1237 assert!(filtered.contains_key("opus"));
1238 assert!(filtered.contains_key("gpt-5"));
1239 assert!(!filtered.contains_key("sonnet"));
1240 }
1241
1242 #[test]
1243 fn filter_by_visibility_exclude_mode_removes_matches() {
1244 let mut aliases = IndexMap::new();
1245 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1246 aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
1247 aliases.insert(
1248 "deprecated-gpt".to_string(),
1249 make_resolved_alias("deprecated-gpt"),
1250 );
1251
1252 let filtered = filter_by_visibility(
1253 aliases,
1254 &crate::config::ModelVisibility {
1255 include: None,
1256 exclude: Some(vec!["test-*".to_string(), "deprecated-*".to_string()]),
1257 },
1258 );
1259
1260 assert_eq!(filtered.len(), 1);
1261 assert!(filtered.contains_key("opus"));
1262 assert!(!filtered.contains_key("test-opus"));
1263 assert!(!filtered.contains_key("deprecated-gpt"));
1264 }
1265
1266 #[test]
1267 fn filter_by_visibility_empty_config_returns_all() {
1268 let mut aliases = IndexMap::new();
1269 aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1270 aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1271 let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
1272 assert_eq!(filtered.len(), 2);
1273 assert!(filtered.contains_key("opus"));
1274 assert!(filtered.contains_key("sonnet"));
1275 }
1276
1277 #[test]
1278 fn resolve_model_and_provider_pinned_explicit_provider() {
1279 let alias = ModelAlias {
1280 harness: None,
1281 description: None,
1282 spec: ModelSpec::Pinned {
1283 model: "claude-opus-4-6".to_string(),
1284 provider: Some("anthropic".to_string()),
1285 },
1286 };
1287 let cache = ModelsCache {
1288 models: Vec::new(),
1289 fetched_at: None,
1290 };
1291
1292 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1293 assert_eq!(
1294 resolved,
1295 ("claude-opus-4-6".to_string(), "anthropic".to_string())
1296 );
1297 }
1298
1299 #[test]
1300 fn resolve_model_and_provider_pinned_inferred() {
1301 let alias = ModelAlias {
1302 harness: None,
1303 description: None,
1304 spec: ModelSpec::Pinned {
1305 model: "claude-opus-4-6".to_string(),
1306 provider: None,
1307 },
1308 };
1309 let cache = ModelsCache {
1310 models: Vec::new(),
1311 fetched_at: None,
1312 };
1313
1314 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1315 assert_eq!(
1316 resolved,
1317 ("claude-opus-4-6".to_string(), "anthropic".to_string())
1318 );
1319 }
1320
1321 #[test]
1322 fn resolve_model_and_provider_pinned_unknown() {
1323 let alias = ModelAlias {
1324 harness: None,
1325 description: None,
1326 spec: ModelSpec::Pinned {
1327 model: "my-custom-model".to_string(),
1328 provider: None,
1329 },
1330 };
1331 let cache = ModelsCache {
1332 models: Vec::new(),
1333 fetched_at: None,
1334 };
1335
1336 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1337 assert_eq!(
1338 resolved,
1339 ("my-custom-model".to_string(), "unknown".to_string())
1340 );
1341 }
1342
1343 #[test]
1344 fn resolve_model_and_provider_auto_resolve() {
1345 let alias = ModelAlias {
1346 harness: None,
1347 description: None,
1348 spec: ModelSpec::AutoResolve {
1349 provider: "openai".to_string(),
1350 match_patterns: vec!["gpt-5*".to_string()],
1351 exclude_patterns: vec![],
1352 },
1353 };
1354 let cache = make_cache(vec![
1355 ("gpt-4o", "OpenAI", Some("2024-06-01")),
1356 ("gpt-5", "OpenAI", Some("2025-06-01")),
1357 ]);
1358
1359 let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1360 assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
1361 }
1362
1363 #[test]
1364 fn resolve_harness_explicit_installed() {
1365 let alias = ModelAlias {
1366 harness: Some("claude".to_string()),
1367 description: None,
1368 spec: ModelSpec::Pinned {
1369 model: "claude-opus-4-6".to_string(),
1370 provider: None,
1371 },
1372 };
1373 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1374
1375 let resolved = resolve_harness(&alias, "anthropic", &installed);
1376 assert_eq!(
1377 resolved,
1378 (Some("claude".to_string()), HarnessSource::Explicit)
1379 );
1380 }
1381
1382 #[test]
1383 fn resolve_harness_explicit_not_installed() {
1384 let alias = ModelAlias {
1385 harness: Some("claude".to_string()),
1386 description: None,
1387 spec: ModelSpec::Pinned {
1388 model: "claude-opus-4-6".to_string(),
1389 provider: None,
1390 },
1391 };
1392 let installed = HashSet::new();
1393
1394 let resolved = resolve_harness(&alias, "anthropic", &installed);
1395 assert_eq!(
1396 resolved,
1397 (Some("claude".to_string()), HarnessSource::Unavailable)
1398 );
1399 }
1400
1401 #[test]
1402 fn resolve_harness_auto_detected() {
1403 let alias = ModelAlias {
1404 harness: None,
1405 description: None,
1406 spec: ModelSpec::Pinned {
1407 model: "claude-opus-4-6".to_string(),
1408 provider: Some("anthropic".to_string()),
1409 },
1410 };
1411 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1412
1413 let resolved = resolve_harness(&alias, "anthropic", &installed);
1414 assert_eq!(
1415 resolved,
1416 (Some("claude".to_string()), HarnessSource::AutoDetected)
1417 );
1418 }
1419
1420 #[test]
1421 fn resolve_harness_unavailable() {
1422 let alias = ModelAlias {
1423 harness: None,
1424 description: None,
1425 spec: ModelSpec::Pinned {
1426 model: "claude-opus-4-6".to_string(),
1427 provider: Some("anthropic".to_string()),
1428 },
1429 };
1430 let installed = HashSet::new();
1431
1432 let resolved = resolve_harness(&alias, "anthropic", &installed);
1433 assert_eq!(resolved, (None, HarnessSource::Unavailable));
1434 }
1435
1436 #[test]
1437 fn resolve_harness_unavailable_no_provider_match() {
1438 let alias = ModelAlias {
1439 harness: None,
1440 description: None,
1441 spec: ModelSpec::Pinned {
1442 model: "my-custom-model".to_string(),
1443 provider: Some("unknown".to_string()),
1444 },
1445 };
1446 let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1447
1448 let resolved = resolve_harness(&alias, "unknown", &installed);
1449 assert_eq!(resolved, (None, HarnessSource::Unavailable));
1450 }
1451
1452 #[test]
1455 fn harness_source_serializes_snake_case() {
1456 assert_eq!(
1457 serde_json::to_string(&HarnessSource::Explicit).unwrap(),
1458 "\"explicit\""
1459 );
1460 assert_eq!(
1461 serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
1462 "\"auto_detected\""
1463 );
1464 assert_eq!(
1465 serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
1466 "\"unavailable\""
1467 );
1468 }
1469
1470 #[test]
1471 fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
1472 let toml_str = r#"
1473[models.fast]
1474harness = "claude"
1475model = "claude-haiku-4-5"
1476description = "Fast and cheap"
1477"#;
1478
1479 #[derive(Debug, Deserialize)]
1480 struct Wrapper {
1481 models: IndexMap<String, ModelAlias>,
1482 }
1483
1484 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1485 let alias = parsed.models.get("fast").unwrap();
1486 assert_eq!(
1487 alias.spec,
1488 ModelSpec::Pinned {
1489 model: "claude-haiku-4-5".to_string(),
1490 provider: None
1491 }
1492 );
1493 assert_eq!(alias.harness.as_deref(), Some("claude"));
1494 assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
1495
1496 let json = serde_json::to_string(alias).unwrap();
1497 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1498 assert_eq!(roundtripped, *alias);
1499 }
1500
1501 #[test]
1502 fn model_alias_pinned_toml_roundtrip_without_harness() {
1503 let toml_str = r#"
1504[models.fast]
1505model = "claude-haiku-4-5"
1506"#;
1507
1508 #[derive(Debug, Deserialize)]
1509 struct Wrapper {
1510 models: IndexMap<String, ModelAlias>,
1511 }
1512
1513 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1514 let alias = parsed.models.get("fast").unwrap();
1515 assert_eq!(alias.harness, None);
1516 assert_eq!(
1517 alias.spec,
1518 ModelSpec::Pinned {
1519 model: "claude-haiku-4-5".to_string(),
1520 provider: None
1521 }
1522 );
1523
1524 let json = serde_json::to_string(alias).unwrap();
1525 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1526 assert!(value.get("harness").is_none());
1527 assert!(value.get("provider").is_none());
1528 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1529 assert_eq!(roundtripped, *alias);
1530 }
1531
1532 #[test]
1533 fn model_alias_pinned_toml_roundtrip_with_provider() {
1534 let toml_str = r#"
1535[models.fast]
1536model = "claude-haiku-4-5"
1537provider = "anthropic"
1538"#;
1539
1540 #[derive(Debug, Deserialize)]
1541 struct Wrapper {
1542 models: IndexMap<String, ModelAlias>,
1543 }
1544
1545 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1546 let alias = parsed.models.get("fast").unwrap();
1547 assert_eq!(alias.harness, None);
1548 assert_eq!(
1549 alias.spec,
1550 ModelSpec::Pinned {
1551 model: "claude-haiku-4-5".to_string(),
1552 provider: Some("anthropic".to_string())
1553 }
1554 );
1555
1556 let json = serde_json::to_string(alias).unwrap();
1557 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1558 assert_eq!(
1559 value.get("provider").and_then(serde_json::Value::as_str),
1560 Some("anthropic")
1561 );
1562 let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1563 assert_eq!(roundtripped, *alias);
1564 }
1565
1566 #[test]
1567 fn model_alias_pinned_json_roundtrip_with_provider() {
1568 let json = r#"{
1569 "model": "gpt-5.3-codex",
1570 "provider": "openai"
1571 }"#;
1572
1573 let alias: ModelAlias = serde_json::from_str(json).unwrap();
1574 assert_eq!(alias.harness, None);
1575 assert_eq!(alias.description, None);
1576 assert_eq!(
1577 alias.spec,
1578 ModelSpec::Pinned {
1579 model: "gpt-5.3-codex".to_string(),
1580 provider: Some("openai".to_string())
1581 }
1582 );
1583
1584 let encoded = serde_json::to_string(&alias).unwrap();
1585 let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
1586 assert_eq!(roundtripped, alias);
1587 }
1588
1589 #[test]
1590 fn model_alias_auto_resolve_toml_roundtrip() {
1591 let toml_str = r#"
1592[models.opus]
1593harness = "claude"
1594provider = "Anthropic"
1595match = ["claude-opus-*"]
1596exclude = ["claude-opus-3*"]
1597description = "Best reasoning"
1598"#;
1599
1600 #[derive(Debug, Deserialize)]
1601 struct Wrapper {
1602 models: IndexMap<String, ModelAlias>,
1603 }
1604
1605 let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1606 let alias = parsed.models.get("opus").unwrap();
1607 assert_eq!(alias.harness.as_deref(), Some("claude"));
1608 match &alias.spec {
1609 ModelSpec::AutoResolve {
1610 provider,
1611 match_patterns,
1612 exclude_patterns,
1613 } => {
1614 assert_eq!(provider, "Anthropic");
1615 assert_eq!(match_patterns, &["claude-opus-*"]);
1616 assert_eq!(exclude_patterns, &["claude-opus-3*"]);
1617 }
1618 _ => panic!("expected AutoResolve"),
1619 }
1620 }
1621
1622 #[test]
1623 fn model_alias_both_model_and_match_errors() {
1624 let toml_str = r#"
1625[models.bad]
1626harness = "claude"
1627model = "some-model"
1628match = ["pattern-*"]
1629"#;
1630
1631 #[derive(Debug, Deserialize)]
1632 struct Wrapper {
1633 #[expect(dead_code)]
1634 models: IndexMap<String, ModelAlias>,
1635 }
1636
1637 let result = toml::from_str::<Wrapper>(toml_str);
1638 assert!(result.is_err());
1639 let err_msg = result.unwrap_err().to_string();
1640 assert!(err_msg.contains("both"));
1641 }
1642
1643 #[test]
1644 fn model_alias_neither_model_nor_match_errors() {
1645 let toml_str = r#"
1646[models.bad]
1647harness = "claude"
1648"#;
1649
1650 #[derive(Debug, Deserialize)]
1651 struct Wrapper {
1652 #[expect(dead_code)]
1653 models: IndexMap<String, ModelAlias>,
1654 }
1655
1656 let result = toml::from_str::<Wrapper>(toml_str);
1657 assert!(result.is_err());
1658 }
1659
1660 #[test]
1661 fn infer_provider_from_model_id_detects_known_prefixes() {
1662 assert_eq!(
1663 infer_provider_from_model_id("claude-opus-4-6"),
1664 Some("anthropic")
1665 );
1666 assert_eq!(
1667 infer_provider_from_model_id("gpt-5.3-codex"),
1668 Some("openai")
1669 );
1670 assert_eq!(
1671 infer_provider_from_model_id("gemini-2.5-pro"),
1672 Some("google")
1673 );
1674 assert_eq!(
1675 infer_provider_from_model_id("llama-4-maverick"),
1676 Some("meta")
1677 );
1678 assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
1679 assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
1680 assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
1681 assert_eq!(
1682 infer_provider_from_model_id("codex-mini-latest"),
1683 Some("openai")
1684 );
1685 assert_eq!(
1686 infer_provider_from_model_id("mistral-large"),
1687 Some("mistral")
1688 );
1689 assert_eq!(
1690 infer_provider_from_model_id("codestral-latest"),
1691 Some("mistral")
1692 );
1693 assert_eq!(
1694 infer_provider_from_model_id("deepseek-chat"),
1695 Some("deepseek")
1696 );
1697 assert_eq!(
1698 infer_provider_from_model_id("command-r-plus"),
1699 Some("cohere")
1700 );
1701 }
1702
1703 #[test]
1704 fn infer_provider_from_model_id_returns_none_for_unknown_model() {
1705 assert_eq!(infer_provider_from_model_id("unknown-model"), None);
1706 }
1707
1708 #[test]
1709 fn infer_provider_from_model_id_returns_none_for_empty_string() {
1710 assert_eq!(infer_provider_from_model_id(""), None);
1711 }
1712
1713 #[test]
1714 fn infer_provider_from_model_id_is_case_insensitive() {
1715 assert_eq!(
1716 infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
1717 Some("anthropic")
1718 );
1719 assert_eq!(
1720 infer_provider_from_model_id("GPT-5.3-codex"),
1721 Some("openai")
1722 );
1723 assert_eq!(
1724 infer_provider_from_model_id("CoDeStRaL-latest"),
1725 Some("mistral")
1726 );
1727 }
1728}