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
608/// Filter resolved aliases by visibility config.
609/// - `include` patterns: keep only aliases where at least one pattern matches
610/// - `exclude` patterns: remove aliases where any pattern matches
611/// - No config (both None): return all aliases unchanged
612pub 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/// Best-effort provider inference from model ID prefixes.
664/// Returns None for unrecognized patterns.
665#[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// ---------------------------------------------------------------------------
698// Tests
699// ---------------------------------------------------------------------------
700
701#[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    // -- glob_match tests --
771
772    #[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    // -- auto_resolve tests --
818
819    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        // Newest date wins
846        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        // Should skip -latest even though it has a newer date
875        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        // Same date — shorter ID wins
914        assert_eq!(result, Some("claude-opus-4".to_string()));
915    }
916
917    // -- merge_model_config tests --
918
919    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        // Empty consumer + no deps = builtins only
935        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        // Dep overrides builtin
973        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        // First dep wins
1029        assert_eq!(
1030            merged.get("custom").unwrap().spec,
1031            ModelSpec::Pinned {
1032                model: "model-a".to_string(),
1033                provider: None
1034            }
1035        );
1036        // Should have warned
1037        let warnings = diag.drain();
1038        assert_eq!(warnings.len(), 1);
1039        assert_eq!(warnings[0].code, "model-alias-conflict");
1040    }
1041
1042    // -- resolve_all tests --
1043
1044    #[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        // No cache → auto-resolve can't match → alias omitted from results
1206        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    // -- serde roundtrip tests --
1453
1454    #[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}