Skip to main content

mars_agents/models/
mod.rs

1//! Model catalog — two-mode aliases (pinned + auto-resolve),
2//! dependency-tree config merge, and models cache lifecycle.
3//!
4//! Model aliases map short names (opus, sonnet, codex) to concrete model IDs.
5//! Two modes:
6//! - **Pinned**: explicit model ID, no resolution needed.
7//! - **AutoResolve**: pattern-based resolution against a cached model catalog.
8//!
9//! Merge precedence: consumer > deps (declaration order).
10
11use 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// ---------------------------------------------------------------------------
23// Core types
24// ---------------------------------------------------------------------------
25
26/// A model alias — either pinned to a specific model ID or auto-resolved
27/// against the models cache at resolution time.
28#[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/// How a model alias resolves to a concrete model ID.
39#[derive(Debug, Clone, PartialEq)]
40pub enum ModelSpec {
41    /// Explicit model ID — no resolution needed.
42    Pinned {
43        model: String,
44        provider: Option<String>,
45    },
46    /// Pattern-based resolution against models cache.
47    AutoResolve {
48        provider: String,
49        match_patterns: Vec<String>,
50        exclude_patterns: Vec<String>,
51    },
52}
53
54/// How the harness was determined.
55#[derive(Debug, Clone, PartialEq, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum HarnessSource {
58    Explicit,
59    AutoDetected,
60    Unavailable,
61}
62
63/// Fully resolved model alias — everything a consumer needs to launch.
64#[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
76// Custom Serialize for ModelSpec to flatten into parent
77impl 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; // provider + match
99                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/// Raw deserialization helper — distinguished by field presence.
115#[derive(Debug, Deserialize)]
116struct RawModelAlias {
117    harness: Option<String>,
118    #[serde(default)]
119    description: Option<String>,
120    // Pinned mode
121    #[serde(default)]
122    model: Option<String>,
123    // AutoResolve mode
124    #[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// ---------------------------------------------------------------------------
176// Models cache
177// ---------------------------------------------------------------------------
178
179/// Cached model catalog from external API.
180#[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/// A single model entry in the cache.
188#[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
204/// Read models cache from `.mars/models-cache.json`.
205pub 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
223/// Write models cache to `.mars/models-cache.json` (atomic via tmp+rename).
224pub 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
237/// Fetch models from the models.dev API.
238///
239/// Returns a list of cached model entries. On network failure, returns an error
240/// (callers should fall back to existing cache or explicit pinned IDs).
241pub 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
332/// Normalize models.dev provider keys to canonical names.
333fn 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
346// ---------------------------------------------------------------------------
347// Auto-resolve algorithm
348// ---------------------------------------------------------------------------
349
350/// Resolve an auto-resolve spec against the models cache.
351///
352/// Algorithm:
353/// 1. Filter by provider (case-insensitive)
354/// 2. All match patterns must hit (AND)
355/// 3. No exclude patterns may hit (OR)
356/// 4. Skip entries ending with `-latest` (synthetic aliases)
357/// 5. Sort by newest release_date, then shortest ID
358/// 6. Pick first
359pub 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            // Provider match (case-insensitive)
370            m.provider.eq_ignore_ascii_case(provider)
371        })
372        .filter(|m| {
373            // Skip -latest suffix (synthetic aliases)
374            !m.id.ends_with("-latest")
375        })
376        .filter(|m| {
377            // All match patterns must hit (AND)
378            match_patterns.iter().all(|p| glob_match(p, &m.id))
379        })
380        .filter(|m| {
381            // No exclude patterns may hit (OR)
382            !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
383        })
384        .collect();
385
386    // Sort: newest release_date first, then shortest ID (tiebreaker)
387    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
399/// Simple glob matching: `*` matches any sequence of characters.
400/// Everything else is literal. Case-sensitive.
401pub fn glob_match(pattern: &str, text: &str) -> bool {
402    // Split pattern on '*' and match segments in order
403    let segments: Vec<&str> = pattern.split('*').collect();
404
405    if segments.len() == 1 {
406        // No wildcards — exact match
407        return pattern == text;
408    }
409
410    let mut pos = 0;
411
412    // First segment must be a prefix
413    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    // Last segment must be a suffix
423    if let Some(last) = segments.last()
424        && !last.is_empty()
425        && !text[pos..].ends_with(last)
426    {
427        return false;
428    }
429
430    // Middle segments must appear in order
431    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
455// ---------------------------------------------------------------------------
456// Builtin aliases — bare convenience mappings, no descriptions
457// ---------------------------------------------------------------------------
458
459/// Minimal builtin aliases so common model names work out of the box.
460/// No descriptions — packages layer those on top.
461/// Precedence: consumer > deps > builtins.
462pub 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
509// ---------------------------------------------------------------------------
510// Dependency-tree merge
511// ---------------------------------------------------------------------------
512
513/// Info about a resolved dependency's model config.
514pub struct ResolvedDepModels {
515    pub source_name: String,
516    pub models: IndexMap<String, ModelAlias>,
517}
518
519/// Merge model aliases from dependency tree.
520///
521/// Precedence: consumer > deps (declaration order) > builtins.
522/// When two deps define the same alias, first in declaration order wins
523/// with a diagnostic warning.
524pub 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    // Layer 0 (lowest): builtins
533    for (name, alias) in &builtins {
534        merged.insert(name.clone(), alias.clone());
535    }
536
537    // Track which aliases were set by a dep (vs builtin)
538    let mut dep_provided: std::collections::HashSet<String> = std::collections::HashSet::new();
539
540    // Layer 1: dependencies (override builtins silently, first dep wins on conflicts)
541    for dep in deps {
542        for (name, alias) in &dep.models {
543            if consumer.contains_key(name) {
544                // Consumer will override — skip dep's version silently
545                continue;
546            }
547            if dep_provided.contains(name) {
548                // Two deps define same alias — first dep wins, warn
549                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                // Override builtin or insert new
559                merged.insert(name.clone(), alias.clone());
560                dep_provided.insert(name.clone());
561            }
562        }
563    }
564
565    // Layer 2 (highest): consumer config
566    for (name, alias) in consumer {
567        merged.insert(name.clone(), alias.clone());
568    }
569
570    merged
571}
572
573/// Resolve all aliases to concrete model IDs + harnesses.
574///
575/// Harness detection is encapsulated — callers don't pass installed harnesses.
576pub 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; // unresolvable — omit
586        };
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/// Best-effort provider inference from model ID prefixes.
648/// Returns None for unrecognized patterns.
649#[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// ---------------------------------------------------------------------------
682// Tests
683// ---------------------------------------------------------------------------
684
685#[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    // -- glob_match tests --
755
756    #[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    // -- auto_resolve tests --
802
803    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        // Newest date wins
830        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        // Should skip -latest even though it has a newer date
859        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        // Same date — shorter ID wins
898        assert_eq!(result, Some("claude-opus-4".to_string()));
899    }
900
901    // -- merge_model_config tests --
902
903    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        // Empty consumer + no deps = builtins only
919        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        // Dep overrides builtin
957        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        // First dep wins
1013        assert_eq!(
1014            merged.get("custom").unwrap().spec,
1015            ModelSpec::Pinned {
1016                model: "model-a".to_string(),
1017                provider: None
1018            }
1019        );
1020        // Should have warned
1021        let warnings = diag.drain();
1022        assert_eq!(warnings.len(), 1);
1023        assert_eq!(warnings[0].code, "model-alias-conflict");
1024    }
1025
1026    // -- resolve_all tests --
1027
1028    #[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        // No cache → auto-resolve can't match → alias omitted from results
1190        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    // -- serde roundtrip tests --
1369
1370    #[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}