Skip to main content

harn_vm/
llm_config.rs

1use serde::Deserialize;
2use std::collections::BTreeMap;
3use std::sync::OnceLock;
4
5static CONFIG: OnceLock<ProvidersConfig> = OnceLock::new();
6static CONFIG_PATH: OnceLock<String> = OnceLock::new();
7
8// =============================================================================
9// Config structs
10// =============================================================================
11
12#[derive(Debug, Clone, Deserialize, Default)]
13pub struct ProvidersConfig {
14    #[serde(default)]
15    pub providers: BTreeMap<String, ProviderDef>,
16    #[serde(default)]
17    pub aliases: BTreeMap<String, AliasDef>,
18    #[serde(default)]
19    pub inference_rules: Vec<InferenceRule>,
20    #[serde(default)]
21    pub tier_rules: Vec<TierRule>,
22    #[serde(default)]
23    pub tier_defaults: TierDefaults,
24    #[serde(default)]
25    pub model_defaults: BTreeMap<String, BTreeMap<String, toml::Value>>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ProviderDef {
30    pub base_url: String,
31    #[serde(default)]
32    pub base_url_env: Option<String>,
33    #[serde(default = "default_bearer")]
34    pub auth_style: String,
35    #[serde(default)]
36    pub auth_header: Option<String>,
37    #[serde(default)]
38    pub auth_env: AuthEnv,
39    #[serde(default)]
40    pub extra_headers: BTreeMap<String, String>,
41    #[serde(default)]
42    pub chat_endpoint: String,
43    #[serde(default)]
44    pub completion_endpoint: Option<String>,
45    #[serde(default)]
46    pub healthcheck: Option<HealthcheckDef>,
47    #[serde(default)]
48    pub features: Vec<String>,
49    /// Fallback provider name to try if this provider fails.
50    #[serde(default)]
51    pub fallback: Option<String>,
52    /// Number of retries before falling back (default 0).
53    #[serde(default)]
54    pub retry_count: Option<u32>,
55    /// Delay between retries in milliseconds (default 1000).
56    #[serde(default)]
57    pub retry_delay_ms: Option<u64>,
58    /// Maximum requests per minute. None = unlimited.
59    #[serde(default)]
60    pub rpm: Option<u32>,
61}
62
63impl Default for ProviderDef {
64    fn default() -> Self {
65        Self {
66            base_url: String::new(),
67            base_url_env: None,
68            auth_style: default_bearer(),
69            auth_header: None,
70            auth_env: AuthEnv::None,
71            extra_headers: BTreeMap::new(),
72            chat_endpoint: String::new(),
73            completion_endpoint: None,
74            healthcheck: None,
75            features: Vec::new(),
76            fallback: None,
77            retry_count: None,
78            retry_delay_ms: None,
79            rpm: None,
80        }
81    }
82}
83
84fn default_bearer() -> String {
85    "bearer".to_string()
86}
87
88/// Auth env var name(s) for the provider. Can be a single string or an array
89/// (tried in order until one is set).
90#[derive(Debug, Clone, Deserialize, Default)]
91#[serde(untagged)]
92pub enum AuthEnv {
93    #[default]
94    None,
95    Single(String),
96    Multiple(Vec<String>),
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct HealthcheckDef {
101    pub method: String,
102    #[serde(default)]
103    pub path: Option<String>,
104    #[serde(default)]
105    pub url: Option<String>,
106    #[serde(default)]
107    pub body: Option<String>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111pub struct AliasDef {
112    pub id: String,
113    pub provider: String,
114    /// Per-model tool format override: "native" or "text". When set, this
115    /// takes precedence over the provider-level default. Models with strong
116    /// tool-calling fine-tuning (Kimi-K2.5, GPT-4o) should use "native";
117    /// models better served by text-based tool calling use "text".
118    #[serde(default)]
119    pub tool_format: Option<String>,
120}
121
122#[derive(Debug, Clone, Deserialize)]
123pub struct InferenceRule {
124    #[serde(default)]
125    pub pattern: Option<String>,
126    #[serde(default)]
127    pub contains: Option<String>,
128    #[serde(default)]
129    pub exact: Option<String>,
130    pub provider: String,
131}
132
133#[derive(Debug, Clone, Deserialize)]
134pub struct TierRule {
135    #[serde(default)]
136    pub pattern: Option<String>,
137    #[serde(default)]
138    pub contains: Option<String>,
139    #[serde(default)]
140    pub exact: Option<String>,
141    pub tier: String,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145pub struct TierDefaults {
146    #[serde(default = "default_mid")]
147    pub default: String,
148}
149
150impl Default for TierDefaults {
151    fn default() -> Self {
152        Self {
153            default: default_mid(),
154        }
155    }
156}
157
158fn default_mid() -> String {
159    "mid".to_string()
160}
161
162// =============================================================================
163// Config loading
164// =============================================================================
165
166/// Load and cache the providers config. Called once at VM startup.
167pub fn load_config() -> &'static ProvidersConfig {
168    CONFIG.get_or_init(|| {
169        let verbose_config_logging = matches!(
170            std::env::var("HARN_VERBOSE_CONFIG").ok().as_deref(),
171            Some("1" | "true" | "TRUE" | "yes" | "YES")
172        ) || matches!(
173            std::env::var("HARN_ACP_VERBOSE").ok().as_deref(),
174            Some("1" | "true" | "TRUE" | "yes" | "YES")
175        );
176        // Try explicit env var path first
177        if let Ok(path) = std::env::var("HARN_PROVIDERS_CONFIG") {
178            match std::fs::read_to_string(&path) {
179                Ok(content) => match toml::from_str::<ProvidersConfig>(&content) {
180                    Ok(config) => {
181                        if verbose_config_logging {
182                            eprintln!(
183                                "[llm_config] Loaded {} providers, {} aliases from {}",
184                                config.providers.len(),
185                                config.aliases.len(),
186                                path
187                            );
188                        }
189                        let _ = CONFIG_PATH.set(path);
190                        return config;
191                    }
192                    Err(e) => eprintln!("[llm_config] TOML parse error in {}: {}", path, e),
193                },
194                Err(e) => eprintln!("[llm_config] Cannot read {}: {}", path, e),
195            }
196        }
197        // Try ~/.config/harn/providers.toml
198        if let Some(home) = dirs_or_home() {
199            let path = format!("{home}/.config/harn/providers.toml");
200            if let Ok(content) = std::fs::read_to_string(&path) {
201                if let Ok(config) = toml::from_str::<ProvidersConfig>(&content) {
202                    let _ = CONFIG_PATH.set(path);
203                    return config;
204                }
205            }
206        }
207        // Fallback: built-in defaults
208        default_config()
209    })
210}
211
212/// Returns the filesystem path of the currently-loaded providers config, if
213/// any. Returns `None` when built-in defaults are active.
214pub fn loaded_config_path() -> Option<std::path::PathBuf> {
215    // Trigger lazy init so CONFIG_PATH gets populated if a file was loaded.
216    let _ = load_config();
217    CONFIG_PATH.get().map(std::path::PathBuf::from)
218}
219
220/// Resolve a model alias to (model_id, provider_name).
221pub fn resolve_model(alias: &str) -> (String, Option<String>) {
222    let config = load_config();
223    if let Some(a) = config.aliases.get(alias) {
224        return (a.id.clone(), Some(a.provider.clone()));
225    }
226    (alias.to_string(), None)
227}
228
229/// Infer provider from a model ID using inference rules.
230pub fn infer_provider(model_id: &str) -> String {
231    let config = load_config();
232    for rule in &config.inference_rules {
233        if let Some(exact) = &rule.exact {
234            if model_id == exact {
235                return rule.provider.clone();
236            }
237        }
238        if let Some(pattern) = &rule.pattern {
239            if glob_match(pattern, model_id) {
240                return rule.provider.clone();
241            }
242        }
243        if let Some(substr) = &rule.contains {
244            if model_id.contains(substr.as_str()) {
245                return rule.provider.clone();
246            }
247        }
248    }
249    // Fallback to hardcoded inference.
250    // Order matters: `local:` must beat the generic `:` → ollama rule, and
251    // any prefix-based rule must beat the generic `/` → openrouter rule for
252    // ids like `local:owner/model`.
253    if model_id.starts_with("local:") {
254        return "local".to_string();
255    }
256    if model_id.starts_with("claude-") {
257        return "anthropic".to_string();
258    }
259    if model_id.starts_with("gpt-") || model_id.starts_with("o1") || model_id.starts_with("o3") {
260        return "openai".to_string();
261    }
262    if model_id.contains('/') {
263        return "openrouter".to_string();
264    }
265    if model_id.contains(':') {
266        return "ollama".to_string();
267    }
268    "anthropic".to_string()
269}
270
271/// Get model tier ("small", "mid", "frontier").
272pub fn model_tier(model_id: &str) -> String {
273    let config = load_config();
274    for rule in &config.tier_rules {
275        if let Some(exact) = &rule.exact {
276            if model_id == exact {
277                return rule.tier.clone();
278            }
279        }
280        if let Some(pattern) = &rule.pattern {
281            if glob_match(pattern, model_id) {
282                return rule.tier.clone();
283            }
284        }
285        if let Some(substr) = &rule.contains {
286            if model_id.contains(substr.as_str()) {
287                return rule.tier.clone();
288            }
289        }
290    }
291    // Fallback
292    let lower = model_id.to_lowercase();
293    if lower.contains("9b") || lower.contains("a3b") {
294        return "small".to_string();
295    }
296    if lower.starts_with("claude-") || lower == "gpt-4o" {
297        return "frontier".to_string();
298    }
299    config.tier_defaults.default.clone()
300}
301
302/// Get provider config for resolving base_url, auth, etc.
303pub fn provider_config(name: &str) -> Option<&'static ProviderDef> {
304    load_config().providers.get(name)
305}
306
307/// Get model-specific default parameters (temperature, etc.).
308/// Matches glob patterns in model_defaults keys.
309pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
310    let config = load_config();
311    let mut params = BTreeMap::new();
312    for (pattern, defaults) in &config.model_defaults {
313        if glob_match(pattern, model_id) {
314            for (k, v) in defaults {
315                params.insert(k.clone(), v.clone());
316            }
317        }
318    }
319    params
320}
321
322/// Get list of configured provider names.
323pub fn provider_names() -> Vec<String> {
324    load_config().providers.keys().cloned().collect()
325}
326
327/// Check if a provider advertises a feature (e.g., "native_tools").
328pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
329    provider_config(provider)
330        .map(|p| p.features.iter().any(|f| f == feature))
331        .unwrap_or(false)
332}
333
334/// Resolve the default tool format for a model+provider combination.
335/// Priority: alias `tool_format` (matched by model ID) > provider feature > "text".
336pub fn default_tool_format(model: &str, provider: &str) -> String {
337    let config = load_config();
338    // Check aliases — match by model ID + provider, or by alias name
339    for (name, alias) in &config.aliases {
340        let matches = (alias.id == model && alias.provider == provider) || name == model;
341        if matches {
342            if let Some(ref fmt) = alias.tool_format {
343                return fmt.clone();
344            }
345        }
346    }
347    // Fall back to provider feature
348    if provider_has_feature(provider, "native_tools") {
349        "native".to_string()
350    } else {
351        "text".to_string()
352    }
353}
354
355/// Resolve a tier or alias into a concrete model/provider pair.
356pub fn resolve_tier_model(
357    target: &str,
358    preferred_provider: Option<&str>,
359) -> Option<(String, String)> {
360    let config = load_config();
361
362    if let Some(alias) = config.aliases.get(target) {
363        return Some((alias.id.clone(), alias.provider.clone()));
364    }
365
366    let candidate_aliases = if let Some(provider) = preferred_provider {
367        vec![
368            format!("{provider}/{target}"),
369            format!("{provider}:{target}"),
370            format!("tier/{target}"),
371            target.to_string(),
372        ]
373    } else {
374        vec![format!("tier/{target}"), target.to_string()]
375    };
376
377    for alias_name in candidate_aliases {
378        if let Some(alias) = config.aliases.get(&alias_name) {
379            return Some((alias.id.clone(), alias.provider.clone()));
380        }
381    }
382
383    None
384}
385
386/// Return all configured alias-backed model/provider pairs whose resolved
387/// model falls into the requested capability tier. The result is de-duplicated
388/// and sorted deterministically by provider then model id.
389pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
390    let config = load_config();
391    let mut seen = std::collections::BTreeSet::new();
392    let mut candidates = Vec::new();
393
394    for alias in config.aliases.values() {
395        let pair = (alias.id.clone(), alias.provider.clone());
396        if seen.contains(&pair) {
397            continue;
398        }
399        if model_tier(&alias.id) == target {
400            seen.insert(pair.clone());
401            candidates.push(pair);
402        }
403    }
404
405    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
406        provider_a
407            .cmp(provider_b)
408            .then_with(|| model_a.cmp(model_b))
409    });
410    candidates
411}
412
413// =============================================================================
414// Helpers
415// =============================================================================
416
417/// Simple glob matching for patterns like "claude-*", "qwen/*", "ollama:*".
418fn glob_match(pattern: &str, input: &str) -> bool {
419    if let Some(prefix) = pattern.strip_suffix('*') {
420        input.starts_with(prefix)
421    } else if let Some(suffix) = pattern.strip_prefix('*') {
422        input.ends_with(suffix)
423    } else if pattern.contains('*') {
424        let parts: Vec<&str> = pattern.split('*').collect();
425        if parts.len() == 2 {
426            input.starts_with(parts[0]) && input.ends_with(parts[1])
427        } else {
428            input == pattern
429        }
430    } else {
431        input == pattern
432    }
433}
434
435fn dirs_or_home() -> Option<String> {
436    std::env::var("HOME").ok()
437}
438
439/// Resolve the effective base URL for a provider, checking the `base_url_env`
440/// override first, then falling back to the configured `base_url`.
441pub fn resolve_base_url(pdef: &ProviderDef) -> String {
442    if let Some(env_name) = &pdef.base_url_env {
443        if let Ok(val) = std::env::var(env_name) {
444            // Strip surrounding quotes that some .env parsers leave intact.
445            let trimmed = val.trim().trim_matches('"').trim_matches('\'');
446            if !trimmed.is_empty() {
447                return trimmed.to_string();
448            }
449        }
450    }
451    pdef.base_url.clone()
452}
453
454// =============================================================================
455// Built-in default config (matches current hardcoded behavior)
456// =============================================================================
457
458fn default_config() -> ProvidersConfig {
459    let mut config = ProvidersConfig::default();
460
461    // Anthropic
462    config.providers.insert(
463        "anthropic".to_string(),
464        ProviderDef {
465            base_url: "https://api.anthropic.com/v1".to_string(),
466            auth_style: "header".to_string(),
467            auth_header: Some("x-api-key".to_string()),
468            auth_env: AuthEnv::Single("ANTHROPIC_API_KEY".to_string()),
469            extra_headers: BTreeMap::from([(
470                "anthropic-version".to_string(),
471                "2023-06-01".to_string(),
472            )]),
473            chat_endpoint: "/messages".to_string(),
474            completion_endpoint: None,
475            healthcheck: Some(HealthcheckDef {
476                method: "POST".to_string(),
477                path: Some("/messages/count_tokens".to_string()),
478                url: None,
479                body: Some(
480                    r#"{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"x"}]}"#
481                        .to_string(),
482                ),
483            }),
484            features: vec!["prompt_caching".to_string(), "thinking".to_string()],
485            ..Default::default()
486        },
487    );
488
489    // OpenAI
490    config.providers.insert(
491        "openai".to_string(),
492        ProviderDef {
493            base_url: "https://api.openai.com/v1".to_string(),
494            auth_style: "bearer".to_string(),
495            auth_env: AuthEnv::Single("OPENAI_API_KEY".to_string()),
496            chat_endpoint: "/chat/completions".to_string(),
497            completion_endpoint: Some("/completions".to_string()),
498            healthcheck: Some(HealthcheckDef {
499                method: "GET".to_string(),
500                path: Some("/models".to_string()),
501                url: None,
502                body: None,
503            }),
504            ..Default::default()
505        },
506    );
507
508    // OpenRouter
509    config.providers.insert(
510        "openrouter".to_string(),
511        ProviderDef {
512            base_url: "https://openrouter.ai/api/v1".to_string(),
513            auth_style: "bearer".to_string(),
514            auth_env: AuthEnv::Single("OPENROUTER_API_KEY".to_string()),
515            chat_endpoint: "/chat/completions".to_string(),
516            completion_endpoint: Some("/completions".to_string()),
517            healthcheck: Some(HealthcheckDef {
518                method: "GET".to_string(),
519                path: Some("/auth/key".to_string()),
520                url: None,
521                body: None,
522            }),
523            ..Default::default()
524        },
525    );
526
527    // HuggingFace
528    config.providers.insert(
529        "huggingface".to_string(),
530        ProviderDef {
531            base_url: "https://router.huggingface.co/v1".to_string(),
532            auth_style: "bearer".to_string(),
533            auth_env: AuthEnv::Multiple(vec![
534                "HF_TOKEN".to_string(),
535                "HUGGINGFACE_API_KEY".to_string(),
536            ]),
537            chat_endpoint: "/chat/completions".to_string(),
538            completion_endpoint: Some("/completions".to_string()),
539            healthcheck: Some(HealthcheckDef {
540                method: "GET".to_string(),
541                url: Some("https://huggingface.co/api/whoami-v2".to_string()),
542                path: None,
543                body: None,
544            }),
545            ..Default::default()
546        },
547    );
548
549    // Ollama default. Note: Burin overrides this to `/v1/chat/completions`
550    // via its bundled `providers.toml` (loaded by setting
551    // `HARN_PROVIDERS_CONFIG` in the host process). The OpenAI-compat
552    // path bypasses Ollama's per-model tool-call post-processors
553    // (qwen3coder.go, qwen35.go) which raise HTTP 500s on text-mode
554    // responses for the Qwen3.5 family. The default here stays on
555    // `/api/chat` so the harn-vm test stub keeps working with Ollama's
556    // native NDJSON wire format.
557    config.providers.insert(
558        "ollama".to_string(),
559        ProviderDef {
560            base_url: "http://localhost:11434".to_string(),
561            base_url_env: Some("OLLAMA_HOST".to_string()),
562            auth_style: "none".to_string(),
563            chat_endpoint: "/api/chat".to_string(),
564            completion_endpoint: Some("/api/generate".to_string()),
565            healthcheck: Some(HealthcheckDef {
566                method: "GET".to_string(),
567                path: Some("/api/tags".to_string()),
568                url: None,
569                body: None,
570            }),
571            ..Default::default()
572        },
573    );
574
575    // Together AI (OpenAI-compatible)
576    config.providers.insert(
577        "together".to_string(),
578        ProviderDef {
579            base_url: "https://api.together.xyz/v1".to_string(),
580            base_url_env: Some("TOGETHER_AI_BASE_URL".to_string()),
581            auth_style: "bearer".to_string(),
582            auth_env: AuthEnv::Single("TOGETHER_AI_API_KEY".to_string()),
583            chat_endpoint: "/chat/completions".to_string(),
584            completion_endpoint: Some("/completions".to_string()),
585            healthcheck: Some(HealthcheckDef {
586                method: "GET".to_string(),
587                path: Some("/models".to_string()),
588                url: None,
589                body: None,
590            }),
591            ..Default::default()
592        },
593    );
594
595    // Local OpenAI-compatible server
596    config.providers.insert(
597        "local".to_string(),
598        ProviderDef {
599            base_url: "http://localhost:8000".to_string(),
600            base_url_env: Some("LOCAL_LLM_BASE_URL".to_string()),
601            auth_style: "none".to_string(),
602            chat_endpoint: "/v1/chat/completions".to_string(),
603            completion_endpoint: Some("/v1/completions".to_string()),
604            healthcheck: Some(HealthcheckDef {
605                method: "GET".to_string(),
606                path: Some("/v1/models".to_string()),
607                url: None,
608                body: None,
609            }),
610            ..Default::default()
611        },
612    );
613
614    // Default inference rules
615    config.inference_rules = vec![
616        InferenceRule {
617            pattern: Some("claude-*".to_string()),
618            contains: None,
619            exact: None,
620            provider: "anthropic".to_string(),
621        },
622        InferenceRule {
623            pattern: Some("gpt-*".to_string()),
624            contains: None,
625            exact: None,
626            provider: "openai".to_string(),
627        },
628        InferenceRule {
629            pattern: Some("o1*".to_string()),
630            contains: None,
631            exact: None,
632            provider: "openai".to_string(),
633        },
634        InferenceRule {
635            pattern: Some("o3*".to_string()),
636            contains: None,
637            exact: None,
638            provider: "openai".to_string(),
639        },
640        InferenceRule {
641            pattern: Some("local:*".to_string()),
642            contains: None,
643            exact: None,
644            provider: "local".to_string(),
645        },
646        InferenceRule {
647            pattern: None,
648            contains: Some("/".to_string()),
649            exact: None,
650            provider: "openrouter".to_string(),
651        },
652        InferenceRule {
653            pattern: None,
654            contains: Some(":".to_string()),
655            exact: None,
656            provider: "ollama".to_string(),
657        },
658    ];
659
660    // Default tier rules
661    config.tier_rules = vec![
662        TierRule {
663            contains: Some("9b".to_string()),
664            pattern: None,
665            exact: None,
666            tier: "small".to_string(),
667        },
668        TierRule {
669            contains: Some("a3b".to_string()),
670            pattern: None,
671            exact: None,
672            tier: "small".to_string(),
673        },
674        TierRule {
675            contains: Some("gemma-4-e2b".to_string()),
676            pattern: None,
677            exact: None,
678            tier: "small".to_string(),
679        },
680        TierRule {
681            contains: Some("gemma-4-e4b".to_string()),
682            pattern: None,
683            exact: None,
684            tier: "small".to_string(),
685        },
686        TierRule {
687            contains: Some("gemma-4-26b".to_string()),
688            pattern: None,
689            exact: None,
690            tier: "mid".to_string(),
691        },
692        TierRule {
693            contains: Some("gemma-4-31b".to_string()),
694            pattern: None,
695            exact: None,
696            tier: "frontier".to_string(),
697        },
698        TierRule {
699            contains: Some("gemma4:26b".to_string()),
700            pattern: None,
701            exact: None,
702            tier: "mid".to_string(),
703        },
704        TierRule {
705            contains: Some("gemma4:31b".to_string()),
706            pattern: None,
707            exact: None,
708            tier: "frontier".to_string(),
709        },
710        TierRule {
711            pattern: Some("claude-*".to_string()),
712            contains: None,
713            exact: None,
714            tier: "frontier".to_string(),
715        },
716        TierRule {
717            exact: Some("gpt-4o".to_string()),
718            contains: None,
719            pattern: None,
720            tier: "frontier".to_string(),
721        },
722    ];
723
724    config.tier_defaults = TierDefaults {
725        default: "mid".to_string(),
726    };
727
728    config.aliases.insert(
729        "frontier".to_string(),
730        AliasDef {
731            id: "claude-sonnet-4-20250514".to_string(),
732            provider: "anthropic".to_string(),
733            tool_format: None,
734        },
735    );
736    config.aliases.insert(
737        "tier/frontier".to_string(),
738        AliasDef {
739            id: "claude-sonnet-4-20250514".to_string(),
740            provider: "anthropic".to_string(),
741            tool_format: None,
742        },
743    );
744    config.aliases.insert(
745        "mid".to_string(),
746        AliasDef {
747            id: "gpt-4o-mini".to_string(),
748            provider: "openai".to_string(),
749            tool_format: None,
750        },
751    );
752    config.aliases.insert(
753        "tier/mid".to_string(),
754        AliasDef {
755            id: "gpt-4o-mini".to_string(),
756            provider: "openai".to_string(),
757            tool_format: None,
758        },
759    );
760    config.aliases.insert(
761        "small".to_string(),
762        AliasDef {
763            id: "Qwen/Qwen3.5-9B".to_string(),
764            provider: "openrouter".to_string(),
765            tool_format: None,
766        },
767    );
768    config.aliases.insert(
769        "tier/small".to_string(),
770        AliasDef {
771            id: "Qwen/Qwen3.5-9B".to_string(),
772            provider: "openrouter".to_string(),
773            tool_format: None,
774        },
775    );
776    config.aliases.insert(
777        "local-gemma4".to_string(),
778        AliasDef {
779            id: "gemma-4-26b-a4b-it".to_string(),
780            provider: "local".to_string(),
781            tool_format: None,
782        },
783    );
784    config.aliases.insert(
785        "local-gemma4-26b".to_string(),
786        AliasDef {
787            id: "gemma-4-26b-a4b-it".to_string(),
788            provider: "local".to_string(),
789            tool_format: None,
790        },
791    );
792    config.aliases.insert(
793        "local-gemma4-31b".to_string(),
794        AliasDef {
795            id: "gemma-4-31b-it".to_string(),
796            provider: "local".to_string(),
797            tool_format: None,
798        },
799    );
800    config.aliases.insert(
801        "local-gemma4-e4b".to_string(),
802        AliasDef {
803            id: "gemma-4-e4b-it".to_string(),
804            provider: "local".to_string(),
805            tool_format: None,
806        },
807    );
808    config.aliases.insert(
809        "local-gemma4-e2b".to_string(),
810        AliasDef {
811            id: "gemma-4-e2b-it".to_string(),
812            provider: "local".to_string(),
813            tool_format: None,
814        },
815    );
816
817    config
818}
819
820// =============================================================================
821// Unit tests
822// =============================================================================
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    #[test]
829    fn test_glob_match_prefix() {
830        assert!(glob_match("claude-*", "claude-sonnet-4-20250514"));
831        assert!(glob_match("gpt-*", "gpt-4o"));
832        assert!(!glob_match("claude-*", "gpt-4o"));
833    }
834
835    #[test]
836    fn test_glob_match_suffix() {
837        assert!(glob_match("*-latest", "llama3.2-latest"));
838        assert!(!glob_match("*-latest", "llama3.2"));
839    }
840
841    #[test]
842    fn test_glob_match_middle() {
843        assert!(glob_match("claude-*-latest", "claude-sonnet-latest"));
844        assert!(!glob_match("claude-*-latest", "claude-sonnet-beta"));
845    }
846
847    #[test]
848    fn test_glob_match_exact() {
849        assert!(glob_match("gpt-4o", "gpt-4o"));
850        assert!(!glob_match("gpt-4o", "gpt-4o-mini"));
851    }
852
853    #[test]
854    fn test_infer_provider_from_defaults() {
855        // These test the fallback logic (after rules)
856        assert_eq!(infer_provider("claude-sonnet-4-20250514"), "anthropic");
857        assert_eq!(infer_provider("gpt-4o"), "openai");
858        assert_eq!(infer_provider("o1-preview"), "openai");
859        assert_eq!(infer_provider("o3-mini"), "openai");
860        assert_eq!(infer_provider("qwen/qwen3-coder"), "openrouter");
861        assert_eq!(infer_provider("llama3.2:latest"), "ollama");
862        assert_eq!(infer_provider("unknown-model"), "anthropic");
863    }
864
865    #[test]
866    fn test_infer_provider_local_prefix() {
867        // `local:` must route to the local OpenAI-compatible provider, not
868        // ollama (which would otherwise swallow everything containing `:`).
869        assert_eq!(infer_provider("local:gemma-4-e4b-it"), "local");
870        assert_eq!(infer_provider("local:qwen2.5"), "local");
871        // Even when the id also contains `/`, the `local:` prefix wins.
872        assert_eq!(infer_provider("local:owner/model"), "local");
873    }
874
875    #[test]
876    fn test_model_tier_from_defaults() {
877        assert_eq!(model_tier("claude-sonnet-4-20250514"), "frontier");
878        assert_eq!(model_tier("gpt-4o"), "frontier");
879        assert_eq!(model_tier("Qwen3.5-9B"), "small");
880        assert_eq!(model_tier("deepseek-v3"), "mid");
881    }
882
883    #[test]
884    fn test_resolve_model_unknown_alias() {
885        let (id, provider) = resolve_model("gpt-4o");
886        assert_eq!(id, "gpt-4o");
887        assert!(provider.is_none());
888    }
889
890    #[test]
891    fn test_provider_names() {
892        let names = provider_names();
893        assert!(names.len() >= 7);
894        assert!(names.contains(&"anthropic".to_string()));
895        assert!(names.contains(&"together".to_string()));
896        assert!(names.contains(&"local".to_string()));
897        assert!(names.contains(&"openai".to_string()));
898        assert!(names.contains(&"ollama".to_string()));
899    }
900
901    #[test]
902    fn test_resolve_tier_model_default_aliases() {
903        let (model, provider) = resolve_tier_model("frontier", None).unwrap();
904        assert_eq!(model, "claude-sonnet-4-20250514");
905        assert_eq!(provider, "anthropic");
906
907        let (model, provider) = resolve_tier_model("small", None).unwrap();
908        assert_eq!(model, "Qwen/Qwen3.5-9B");
909        assert_eq!(provider, "openrouter");
910    }
911
912    #[test]
913    fn test_resolve_tier_model_prefers_provider_scoped_aliases() {
914        let (model, provider) = resolve_tier_model("mid", Some("openai")).unwrap();
915        assert_eq!(model, "gpt-4o-mini");
916        assert_eq!(provider, "openai");
917    }
918
919    #[test]
920    fn test_provider_config_anthropic() {
921        let pdef = provider_config("anthropic").unwrap();
922        assert_eq!(pdef.auth_style, "header");
923        assert_eq!(pdef.auth_header.as_deref(), Some("x-api-key"));
924    }
925
926    #[test]
927    fn test_resolve_base_url_no_env() {
928        let pdef = ProviderDef {
929            base_url: "https://example.com".to_string(),
930            ..Default::default()
931        };
932        assert_eq!(resolve_base_url(&pdef), "https://example.com");
933    }
934
935    #[test]
936    fn test_default_config_roundtrip() {
937        let config = default_config();
938        assert!(!config.providers.is_empty());
939        assert!(!config.inference_rules.is_empty());
940        assert!(!config.tier_rules.is_empty());
941        assert_eq!(config.tier_defaults.default, "mid");
942    }
943
944    #[test]
945    fn test_model_params_empty() {
946        let params = model_params("claude-sonnet-4-20250514");
947        // Default config has no model_defaults, so should be empty
948        assert!(params.is_empty());
949    }
950}