Skip to main content

harn_vm/
llm_config.rs

1use serde::{Deserialize, Serialize};
2use std::cell::RefCell;
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::{OnceLock, RwLock};
5
6static CONFIG: OnceLock<ProvidersConfig> = OnceLock::new();
7static CONFIG_PATH: OnceLock<String> = OnceLock::new();
8static RUNTIME_CATALOG_OVERLAY: OnceLock<RwLock<Option<ProvidersConfig>>> = OnceLock::new();
9
10thread_local! {
11    /// Thread-local provider config overlays installed by the CLI after it
12    /// reads the nearest `harn.toml` plus any installed package manifests.
13    /// Kept thread-local so tests and multi-VM hosts can scope extensions to
14    /// the current run without mutating the process-wide default config.
15    static USER_OVERRIDES: RefCell<Option<ProvidersConfig>> = const { RefCell::new(None) };
16}
17
18#[derive(Debug, Clone, Deserialize, Default)]
19pub struct ProvidersConfig {
20    #[serde(default)]
21    pub default_provider: Option<String>,
22    #[serde(default)]
23    pub providers: BTreeMap<String, ProviderDef>,
24    #[serde(default)]
25    pub aliases: BTreeMap<String, AliasDef>,
26    #[serde(default)]
27    pub alias_tool_calling: BTreeMap<String, AliasToolCallingDef>,
28    #[serde(default)]
29    pub models: BTreeMap<String, ModelDef>,
30    #[serde(default)]
31    pub qc_defaults: BTreeMap<String, String>,
32    #[serde(default)]
33    pub inference_rules: Vec<InferenceRule>,
34    #[serde(default)]
35    pub tier_rules: Vec<TierRule>,
36    #[serde(default)]
37    pub tier_defaults: TierDefaults,
38    #[serde(default)]
39    pub model_defaults: BTreeMap<String, BTreeMap<String, toml::Value>>,
40    #[serde(default)]
41    pub model_roles: BTreeMap<String, BTreeMap<String, toml::Value>>,
42}
43
44impl ProvidersConfig {
45    pub fn is_empty(&self) -> bool {
46        self.default_provider.is_none()
47            && self.providers.is_empty()
48            && self.aliases.is_empty()
49            && self.alias_tool_calling.is_empty()
50            && self.models.is_empty()
51            && self.qc_defaults.is_empty()
52            && self.inference_rules.is_empty()
53            && self.tier_rules.is_empty()
54            && self.model_defaults.is_empty()
55            && self.model_roles.is_empty()
56            && self.tier_defaults.default == default_mid()
57    }
58
59    pub fn merge_from(&mut self, overlay: &ProvidersConfig) {
60        for (name, provider) in &overlay.providers {
61            match self.providers.get_mut(name) {
62                Some(existing) => existing.merge_from(provider),
63                None => {
64                    self.providers.insert(name.clone(), provider.clone());
65                }
66            }
67        }
68        self.aliases.extend(overlay.aliases.clone());
69        self.alias_tool_calling
70            .extend(overlay.alias_tool_calling.clone());
71        self.models.extend(overlay.models.clone());
72        self.qc_defaults.extend(overlay.qc_defaults.clone());
73
74        if overlay.default_provider.is_some() {
75            self.default_provider = overlay.default_provider.clone();
76        }
77
78        if !overlay.inference_rules.is_empty() {
79            let mut merged = overlay.inference_rules.clone();
80            merged.extend(self.inference_rules.clone());
81            self.inference_rules = merged;
82        }
83
84        if !overlay.tier_rules.is_empty() {
85            let mut merged = overlay.tier_rules.clone();
86            merged.extend(self.tier_rules.clone());
87            self.tier_rules = merged;
88        }
89
90        if overlay.tier_defaults.default != default_mid() {
91            self.tier_defaults = overlay.tier_defaults.clone();
92        }
93
94        for (pattern, defaults) in &overlay.model_defaults {
95            self.model_defaults
96                .entry(pattern.clone())
97                .or_default()
98                .extend(defaults.clone());
99        }
100
101        for (role, defaults) in &overlay.model_roles {
102            self.model_roles
103                .entry(role.clone())
104                .or_default()
105                .extend(defaults.clone());
106        }
107    }
108}
109
110#[derive(Debug, Clone)]
111pub struct ProviderDef {
112    pub display_name: Option<String>,
113    pub icon: Option<String>,
114    /// Provider protocol. Omitted providers use Harn's normal HTTP provider
115    /// path; `acp` launches an Agent Client Protocol server and drives it as
116    /// an agent-backed provider.
117    pub protocol: Option<String>,
118    pub base_url: String,
119    pub base_url_env: Option<String>,
120    pub auth_style: String,
121    pub auth_header: Option<String>,
122    pub auth_env: AuthEnv,
123    pub extra_headers: BTreeMap<String, String>,
124    pub chat_endpoint: String,
125    pub completion_endpoint: Option<String>,
126    pub command: Option<String>,
127    pub args: Vec<String>,
128    pub env: BTreeMap<String, String>,
129    pub cwd: Option<String>,
130    pub mcp_servers: Vec<serde_json::Value>,
131    pub healthcheck: Option<HealthcheckDef>,
132    /// Local runtime lifecycle metadata used by `harn local launch/stop`.
133    /// This is intentionally separate from provider process fields such as
134    /// `command`/`args`, which are used for ACP or external provider adapters.
135    pub local_runtime: Option<LocalRuntimeDef>,
136    pub features: Vec<String>,
137    /// Fallback provider name to try if this provider fails.
138    pub fallback: Option<String>,
139    /// Number of retries before falling back (default 0).
140    pub retry_count: Option<u32>,
141    /// Delay between retries in milliseconds (default 1000).
142    pub retry_delay_ms: Option<u64>,
143    /// Maximum requests per minute. None = unlimited.
144    pub rpm: Option<u32>,
145    /// Rich provider quota metadata. `rpm` remains as a legacy shorthand;
146    /// when both are present, this nested shape is the authoritative catalog
147    /// record and callers can still read the flattened `rpm`.
148    pub rate_limits: Option<RateLimitsDef>,
149    /// Provider/catalog pricing in USD per 1k input tokens.
150    pub cost_per_1k_in: Option<f64>,
151    /// Provider/catalog pricing in USD per 1k output tokens.
152    pub cost_per_1k_out: Option<f64>,
153    /// Observed or configured p50 latency in milliseconds.
154    pub latency_p50_ms: Option<u64>,
155    #[doc(hidden)]
156    pub auth_style_explicit: bool,
157}
158
159#[derive(Debug, Clone, Deserialize)]
160struct ProviderDefWire {
161    #[serde(default)]
162    display_name: Option<String>,
163    #[serde(default)]
164    icon: Option<String>,
165    #[serde(default)]
166    protocol: Option<String>,
167    #[serde(default)]
168    base_url: String,
169    #[serde(default)]
170    base_url_env: Option<String>,
171    #[serde(default)]
172    auth_style: Option<String>,
173    #[serde(default)]
174    auth_header: Option<String>,
175    #[serde(default)]
176    auth_env: AuthEnv,
177    #[serde(default)]
178    extra_headers: BTreeMap<String, String>,
179    #[serde(default)]
180    chat_endpoint: String,
181    #[serde(default)]
182    completion_endpoint: Option<String>,
183    #[serde(default)]
184    command: Option<String>,
185    #[serde(default)]
186    args: Vec<String>,
187    #[serde(default)]
188    env: BTreeMap<String, String>,
189    #[serde(default)]
190    cwd: Option<String>,
191    #[serde(default)]
192    mcp_servers: Vec<serde_json::Value>,
193    #[serde(default)]
194    healthcheck: Option<HealthcheckDef>,
195    #[serde(default)]
196    local_runtime: Option<LocalRuntimeDef>,
197    #[serde(default)]
198    features: Vec<String>,
199    #[serde(default)]
200    fallback: Option<String>,
201    #[serde(default)]
202    retry_count: Option<u32>,
203    #[serde(default)]
204    retry_delay_ms: Option<u64>,
205    #[serde(default)]
206    rpm: Option<u32>,
207    #[serde(default)]
208    rate_limits: Option<RateLimitsDef>,
209    #[serde(default)]
210    cost_per_1k_in: Option<f64>,
211    #[serde(default)]
212    cost_per_1k_out: Option<f64>,
213    #[serde(default)]
214    latency_p50_ms: Option<u64>,
215}
216
217impl<'de> Deserialize<'de> for ProviderDef {
218    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
219    where
220        D: serde::Deserializer<'de>,
221    {
222        let wire = ProviderDefWire::deserialize(deserializer)?;
223        let auth_style_explicit = wire.auth_style.is_some();
224        Ok(Self {
225            display_name: wire.display_name,
226            icon: wire.icon,
227            protocol: wire.protocol,
228            base_url: wire.base_url,
229            base_url_env: wire.base_url_env,
230            auth_style: wire.auth_style.unwrap_or_else(default_bearer),
231            auth_header: wire.auth_header,
232            auth_env: wire.auth_env,
233            extra_headers: wire.extra_headers,
234            chat_endpoint: wire.chat_endpoint,
235            completion_endpoint: wire.completion_endpoint,
236            command: wire.command,
237            args: wire.args,
238            env: wire.env,
239            cwd: wire.cwd,
240            mcp_servers: wire.mcp_servers,
241            healthcheck: wire.healthcheck,
242            local_runtime: wire.local_runtime,
243            features: wire.features,
244            fallback: wire.fallback,
245            retry_count: wire.retry_count,
246            retry_delay_ms: wire.retry_delay_ms,
247            rpm: wire.rpm,
248            rate_limits: wire.rate_limits,
249            cost_per_1k_in: wire.cost_per_1k_in,
250            cost_per_1k_out: wire.cost_per_1k_out,
251            latency_p50_ms: wire.latency_p50_ms,
252            auth_style_explicit,
253        })
254    }
255}
256
257impl Default for ProviderDef {
258    fn default() -> Self {
259        Self {
260            display_name: None,
261            icon: None,
262            protocol: None,
263            base_url: String::new(),
264            base_url_env: None,
265            auth_style: default_bearer(),
266            auth_header: None,
267            auth_env: AuthEnv::None,
268            extra_headers: BTreeMap::new(),
269            chat_endpoint: String::new(),
270            completion_endpoint: None,
271            command: None,
272            args: Vec::new(),
273            env: BTreeMap::new(),
274            cwd: None,
275            mcp_servers: Vec::new(),
276            healthcheck: None,
277            local_runtime: None,
278            features: Vec::new(),
279            fallback: None,
280            retry_count: None,
281            retry_delay_ms: None,
282            rpm: None,
283            rate_limits: None,
284            cost_per_1k_in: None,
285            cost_per_1k_out: None,
286            latency_p50_ms: None,
287            auth_style_explicit: false,
288        }
289    }
290}
291
292impl ProviderDef {
293    fn merge_from(&mut self, overlay: &ProviderDef) {
294        merge_option(&mut self.display_name, &overlay.display_name);
295        merge_option(&mut self.icon, &overlay.icon);
296        merge_option(&mut self.protocol, &overlay.protocol);
297        merge_string(&mut self.base_url, &overlay.base_url);
298        merge_option(&mut self.base_url_env, &overlay.base_url_env);
299        let overlay_uses_default_auth_style = overlay.auth_style == default_bearer();
300        if overlay.auth_style_explicit
301            || !overlay_uses_default_auth_style
302            || self.auth_style == default_bearer()
303        {
304            self.auth_style = overlay.auth_style.clone();
305            self.auth_style_explicit |=
306                overlay.auth_style_explicit || !overlay_uses_default_auth_style;
307        }
308        merge_option(&mut self.auth_header, &overlay.auth_header);
309        if !overlay.auth_env.is_none() {
310            self.auth_env = overlay.auth_env.clone();
311        }
312        self.extra_headers.extend(overlay.extra_headers.clone());
313        merge_string(&mut self.chat_endpoint, &overlay.chat_endpoint);
314        merge_option(&mut self.completion_endpoint, &overlay.completion_endpoint);
315        merge_option(&mut self.command, &overlay.command);
316        merge_vec(&mut self.args, &overlay.args);
317        self.env.extend(overlay.env.clone());
318        merge_option(&mut self.cwd, &overlay.cwd);
319        merge_vec(&mut self.mcp_servers, &overlay.mcp_servers);
320        merge_option(&mut self.healthcheck, &overlay.healthcheck);
321        merge_option(&mut self.local_runtime, &overlay.local_runtime);
322        merge_vec(&mut self.features, &overlay.features);
323        merge_option(&mut self.fallback, &overlay.fallback);
324        merge_option(&mut self.retry_count, &overlay.retry_count);
325        merge_option(&mut self.retry_delay_ms, &overlay.retry_delay_ms);
326        merge_option(&mut self.rpm, &overlay.rpm);
327        merge_option(&mut self.rate_limits, &overlay.rate_limits);
328        merge_option(&mut self.cost_per_1k_in, &overlay.cost_per_1k_in);
329        merge_option(&mut self.cost_per_1k_out, &overlay.cost_per_1k_out);
330        merge_option(&mut self.latency_p50_ms, &overlay.latency_p50_ms);
331    }
332}
333
334fn merge_option<T: Clone>(base: &mut Option<T>, overlay: &Option<T>) {
335    if overlay.is_some() {
336        *base = overlay.clone();
337    }
338}
339
340fn merge_string(base: &mut String, overlay: &str) {
341    if !overlay.is_empty() {
342        *base = overlay.to_string();
343    }
344}
345
346fn merge_vec<T: Clone>(base: &mut Vec<T>, overlay: &[T]) {
347    if !overlay.is_empty() {
348        *base = overlay.to_vec();
349    }
350}
351
352fn default_bearer() -> String {
353    "bearer".to_string()
354}
355
356/// Auth env var name(s) for the provider. Can be a single string or an array
357/// (tried in order until one is set).
358#[derive(Debug, Clone, Deserialize, Default)]
359#[serde(untagged)]
360pub enum AuthEnv {
361    #[default]
362    None,
363    Single(String),
364    Multiple(Vec<String>),
365}
366
367impl AuthEnv {
368    fn is_none(&self) -> bool {
369        matches!(self, AuthEnv::None)
370    }
371}
372
373#[derive(Debug, Clone, Deserialize)]
374pub struct HealthcheckDef {
375    pub method: String,
376    #[serde(default)]
377    pub path: Option<String>,
378    #[serde(default)]
379    pub url: Option<String>,
380    #[serde(default)]
381    pub body: Option<String>,
382}
383
384#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
385pub struct LocalRuntimeDef {
386    /// Lifecycle style: `daemon_api` for runtimes with their own resident
387    /// daemon (Ollama), `managed_process` for Harn-spawned servers.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub kind: Option<String>,
390    /// Command Harn should execute for managed-process runtimes.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub command: Option<String>,
393    /// Default model source/path/repo. User overlays may set this; embedded
394    /// catalog rows avoid machine-specific absolute paths except examples.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub model_source: Option<String>,
397    /// Environment variable that can provide a model source.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub model_source_env: Option<String>,
400    /// Default port when the provider base URL has none.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub default_port: Option<u16>,
403    /// Argument names used by the runtime CLI.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub model_arg: Option<String>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub served_model_arg: Option<String>,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub host_arg: Option<String>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub port_arg: Option<String>,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub ctx_arg: Option<String>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub parallel_arg: Option<String>,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub gpu_layers_arg: Option<String>,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub cache_type_k_arg: Option<String>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub cache_type_v_arg: Option<String>,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub cache_ram_arg: Option<String>,
424    /// Extra arguments Harn applies by default when launching this runtime.
425    #[serde(default, skip_serializing_if = "Vec::is_empty")]
426    pub default_args: Vec<String>,
427    /// Stop strategy: `keep_alive_zero`, `pid`, or `external`.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub stop: Option<String>,
430    /// Official docs/source URL for the lifecycle contract.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub source_url: Option<String>,
433    /// YYYY-MM-DD date when the local runtime row was last verified.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub last_verified: Option<String>,
436    /// Short operational note surfaced by CLI docs/help.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub notes: Option<String>,
439}
440
441#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
442pub struct LocalMemoryDef {
443    /// Empirical resident memory observed for this route/runtime.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub measured_resident_gib: Option<f64>,
446    /// Context size used for the empirical measurement.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub measured_context_window: Option<u64>,
449    /// KV-cache type used for the empirical measurement.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub measured_cache_type: Option<String>,
452    /// Approximate non-context resident footprint for this model/runtime.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub base_resident_gib: Option<f64>,
455    /// Approximate GiB consumed by KV cache per 1,000 context tokens at the
456    /// default cache type.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub kv_cache_gib_per_1k_ctx: Option<f64>,
459    /// Cache-type multiplier relative to `kv_cache_gib_per_1k_ctx`.
460    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
461    pub cache_type_multipliers: BTreeMap<String, f64>,
462    /// Cache type assumed when the launch command does not set K/V cache.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub default_cache_type: Option<String>,
465    /// Minimum headroom Harn should leave for the OS and other apps.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub safety_margin_gib: Option<f64>,
468    /// Highest context Harn should recommend automatically from this row.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub max_recommended_context: Option<u64>,
471    /// Official or empirical source for the sizing row.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub source_url: Option<String>,
474    /// YYYY-MM-DD date when the sizing row was last verified.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub last_verified: Option<String>,
477    /// Short operational note surfaced by CLI diagnostics/docs.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub notes: Option<String>,
480}
481
482impl LocalMemoryDef {
483    pub fn is_empty(&self) -> bool {
484        self.measured_resident_gib.is_none()
485            && self.measured_context_window.is_none()
486            && self.measured_cache_type.is_none()
487            && self.base_resident_gib.is_none()
488            && self.kv_cache_gib_per_1k_ctx.is_none()
489            && self.cache_type_multipliers.is_empty()
490            && self.default_cache_type.is_none()
491            && self.safety_margin_gib.is_none()
492            && self.max_recommended_context.is_none()
493            && self.source_url.is_none()
494            && self.last_verified.is_none()
495            && self.notes.is_none()
496    }
497}
498
499#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
500pub struct AliasDef {
501    pub id: String,
502    pub provider: String,
503    /// Per-model tool format override: "native" or "text". When set, this
504    /// takes precedence over the provider-level default. Models with strong
505    /// tool-calling fine-tuning (Kimi-K2.5, GPT-4o) should use "native";
506    /// models better served by text-based tool calling use "text".
507    #[serde(default)]
508    pub tool_format: Option<String>,
509}
510
511#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
512pub struct AliasToolCallingDef {
513    #[serde(default)]
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub native: Option<String>,
516    #[serde(default)]
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub text: Option<String>,
519    #[serde(default)]
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub streaming_native: Option<String>,
522    #[serde(default)]
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub fallback_mode: Option<String>,
525    #[serde(default)]
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub failure_reason: Option<String>,
528    #[serde(default)]
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub last_probe_at: Option<String>,
531}
532
533#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
534pub struct ModelPricing {
535    pub input_per_mtok: f64,
536    pub output_per_mtok: f64,
537    #[serde(default)]
538    pub cache_read_per_mtok: Option<f64>,
539    #[serde(default)]
540    pub cache_write_per_mtok: Option<f64>,
541}
542
543/// Provider or model quota metadata. Providers publish these along several
544/// axes, and any one exhausted bucket can trigger throttling.
545#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
546pub struct RateLimitsDef {
547    /// Requests per minute.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub rpm: Option<u32>,
550    /// Requests per hour.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub rph: Option<u32>,
553    /// Requests per day.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub rpd: Option<u32>,
556    /// Total tokens per minute.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub tpm: Option<u64>,
559    /// Total tokens per hour.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub tph: Option<u64>,
562    /// Total tokens per day.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub tpd: Option<u64>,
565    /// Input tokens per minute, when the provider splits input/output quotas.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub input_tpm: Option<u64>,
568    /// Output tokens per minute, when the provider splits input/output quotas.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub output_tpm: Option<u64>,
571    /// Concurrent in-flight requests, if published.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub concurrency: Option<u32>,
574    /// Account tier or route class these limits describe.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub tier: Option<String>,
577    /// Official source URL for the row.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub source_url: Option<String>,
580    /// YYYY-MM-DD date when the row was last verified.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub last_verified: Option<String>,
583    /// Free-text caveat for account-dependent or burst limits.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub notes: Option<String>,
586}
587
588impl RateLimitsDef {
589    pub fn is_empty(&self) -> bool {
590        self.rpm.is_none()
591            && self.rph.is_none()
592            && self.rpd.is_none()
593            && self.tpm.is_none()
594            && self.tph.is_none()
595            && self.tpd.is_none()
596            && self.input_tpm.is_none()
597            && self.output_tpm.is_none()
598            && self.concurrency.is_none()
599            && self.tier.is_none()
600            && self.source_url.is_none()
601            && self.last_verified.is_none()
602            && self.notes.is_none()
603    }
604
605    pub fn with_rpm_fallback(mut self, rpm: Option<u32>) -> Option<Self> {
606        if self.rpm.is_none() {
607            self.rpm = rpm;
608        }
609        (!self.is_empty()).then_some(self)
610    }
611}
612
613/// Logical-model facts separated from provider serving routes. These fields
614/// describe the underlying weights or public model family, not Harn's alias or
615/// provider/model selector.
616#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
617pub struct ModelArchitectureDef {
618    /// Total parameter count in billions.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub parameter_count_b: Option<f64>,
621    /// Active parameter count in billions for MoE models.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub active_parameter_count_b: Option<f64>,
624    /// True for mixture-of-experts models.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub moe: Option<bool>,
627    /// Quantization advertised by this route, if route-specific.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub quantization: Option<String>,
630    /// Numeric precision advertised by this route, if known.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub precision: Option<String>,
633    /// License identifier or short label.
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub license: Option<String>,
636    /// Tokenizer family or implementation hint.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub tokenizer: Option<String>,
639    /// Public knowledge cutoff claim, when published.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub knowledge_cutoff: Option<String>,
642    /// Official source URL for these facts.
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub source_url: Option<String>,
645    /// YYYY-MM-DD date when these facts were last verified.
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub last_verified: Option<String>,
648}
649
650impl ModelArchitectureDef {
651    pub fn is_empty(&self) -> bool {
652        self.parameter_count_b.is_none()
653            && self.active_parameter_count_b.is_none()
654            && self.moe.is_none()
655            && self.quantization.is_none()
656            && self.precision.is_none()
657            && self.license.is_none()
658            && self.tokenizer.is_none()
659            && self.knowledge_cutoff.is_none()
660            && self.source_url.is_none()
661            && self.last_verified.is_none()
662    }
663}
664
665/// Optional accelerated-serving ("fast mode") tier for a model. Off by
666/// default: its presence only *describes* that the provider offers a
667/// faster, premium-priced serving path running the same weights — callers
668/// must explicitly opt in via the provider's request knob, so nothing here
669/// changes default behavior. Deliberately provider-agnostic: Anthropic
670/// exposes the tier as `speed = "fast"` (beta-gated), while OpenAI uses
671/// `service_tier = "fast"` / `"priority"`. Premium pricing is stored as
672/// absolute per-MTok rates rather than a single multiplier because
673/// providers price the tier asymmetrically (Anthropic Opus 4.8 is 2x
674/// standard; Opus 4.6/4.7 fast mode is 6x).
675#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
676pub struct FastModeDef {
677    /// Request field that opts into the fast tier (e.g. "speed" for
678    /// Anthropic, "service_tier" for OpenAI).
679    pub param: String,
680    /// Value to send on `param` (e.g. "fast", "priority").
681    pub value: String,
682    /// Provider beta/feature header required to use the tier, if any
683    /// (e.g. Anthropic "fast-mode-2026-02-01").
684    #[serde(default)]
685    pub beta_header: Option<String>,
686    /// Output-tokens-per-second speedup vs standard serving (e.g. 2.5).
687    #[serde(default)]
688    pub otps_speedup: Option<f64>,
689    /// Lifecycle of the fast tier: "ga" | "research_preview" |
690    /// "deprecated". None when unspecified.
691    #[serde(default)]
692    pub status: Option<String>,
693    /// Premium pricing charged while the fast tier is active (absolute
694    /// per-MTok rates, not a multiplier on standard pricing).
695    #[serde(default)]
696    pub pricing: Option<ModelPricing>,
697    /// Free-text note: constraints, deprecation timeline, etc.
698    #[serde(default)]
699    pub note: Option<String>,
700}
701
702#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
703pub struct ModelDef {
704    pub name: String,
705    pub provider: String,
706    pub context_window: u64,
707    /// Provider-independent logical model id, when multiple serving routes map
708    /// to the same weights or model family.
709    #[serde(default)]
710    pub logical_model: Option<String>,
711    /// Equivalence class for failover/escalation candidates. Entries in the
712    /// same group are capability-compatible alternatives, not byte-identical
713    /// APIs; callers must still re-render transcripts for the target provider.
714    #[serde(default)]
715    pub equivalence_group: Option<String>,
716    /// Serving-route detail such as "serverless", "priority", "fp8", or a
717    /// provider route slug. This is intentionally separate from `name`.
718    #[serde(default)]
719    pub served_variant: Option<String>,
720    /// Provider-native model id to send on the wire. Defaults to the catalog
721    /// key. Required when two providers expose the same native id and Harn
722    /// needs a unique catalog key for each route.
723    #[serde(default)]
724    pub wire_model: Option<String>,
725    /// Preferred API dialect for the route, e.g. `openai_chat`,
726    /// `openai_responses`, `anthropic_messages`, `gemini_generate_content`.
727    #[serde(default)]
728    pub api_dialect: Option<String>,
729    /// Route-specific token/request quota metadata.
730    #[serde(default)]
731    pub rate_limits: Option<RateLimitsDef>,
732    /// Underlying model architecture facts separated from the provider id.
733    #[serde(default)]
734    pub architecture: Option<ModelArchitectureDef>,
735    /// Local launch memory-sizing hints used by `harn local launch`.
736    #[serde(default)]
737    pub local_memory: Option<LocalMemoryDef>,
738    #[serde(default)]
739    pub runtime_context_window: Option<u64>,
740    #[serde(default)]
741    pub stream_timeout: Option<f64>,
742    #[serde(default)]
743    pub capabilities: Vec<String>,
744    #[serde(default)]
745    pub pricing: Option<ModelPricing>,
746    #[serde(default)]
747    pub deprecated: bool,
748    #[serde(default)]
749    pub deprecation_note: Option<String>,
750    /// Structured replacement pointer: the catalog id of the model that
751    /// supersedes this one (e.g. an older Opus row points at the newest
752    /// Opus). Lets release tooling express "migrate to X" in a
753    /// machine-readable way instead of burying it in `deprecation_note`
754    /// free text. A model may be superseded without being `deprecated`
755    /// (a newer option exists but this one is still fully supported);
756    /// pair it with `deprecated = true` once a sunset is announced.
757    #[serde(default)]
758    pub superseded_by: Option<String>,
759    /// Accelerated-serving ("fast mode") tier metadata, when the model's
760    /// provider offers one. Off by default — see [`FastModeDef`]. None for
761    /// models with no faster serving path.
762    #[serde(default)]
763    pub fast_mode: Option<FastModeDef>,
764    #[serde(default)]
765    pub quality_tags: Vec<String>,
766    /// Whether the model can be reached over a normal API-key serverless call,
767    /// or only via a dedicated/provisioned endpoint that the caller must spin
768    /// up out-of-band. Providers like Together list dedicated-only routes
769    /// alongside serverless ones in `/v1/models`, so this metadata lets clients
770    /// avoid presenting them as one-click options.
771    #[serde(default)]
772    pub availability: ModelAvailability,
773    /// Popular-consensus tier label. Enum-typed string: "small" | "mid" |
774    /// "frontier" | "reasoning". Self-declared per model (no pattern-matched
775    /// rule table) so the catalog is the single source of truth. When None
776    /// the resolver returns the catalog default ("mid"). Use the richer
777    /// `strengths` + `benchmarks` fields to pick models for specific
778    /// workloads — `tier` exists only as a coarse popular-consensus shortcut.
779    #[serde(default)]
780    pub tier: Option<String>,
781    /// True when the model weights are downloadable / self-hostable
782    /// (open-weight / open-source license, regardless of commercial-use
783    /// restrictions). False when weights are closed (Anthropic, OpenAI,
784    /// Google, etc.). None when the catalog row predates the migration.
785    #[serde(default)]
786    pub open_weight: Option<bool>,
787    /// Workload-shaped strength tags. Conventional values include
788    /// `coding`, `summarization`, `long_context`, `tool_use`, `reasoning`,
789    /// `vision`, `speed`, `cheap`, `agentic`. Selectors should treat
790    /// missing entries as "no claim" rather than "no strength."
791    #[serde(default)]
792    pub strengths: Vec<String>,
793    /// Public benchmark numbers, keyed by a snake_case identifier
794    /// (`swe_bench_verified`, `humaneval`, `aa_intelligence_index`, etc.).
795    /// Values are the raw published scores. The selector layer is free
796    /// to normalize per benchmark; the catalog records the canonical
797    /// score so future readers can audit the source.
798    #[serde(default)]
799    pub benchmarks: BTreeMap<String, f64>,
800    /// Normalized model-family token used as a diversity signal for
801    /// reviewer selection. Distinct from provider: hosted wrappers should
802    /// keep the underlying family (for example OpenRouter-hosted Claude
803    /// still uses `anthropic-claude`).
804    #[serde(default)]
805    pub family: Option<String>,
806    /// Narrower family lineage used by option-pack calibration.
807    #[serde(default)]
808    pub lineage: Option<String>,
809    /// Preferred reviewer families for critique/review workloads.
810    #[serde(default)]
811    pub complementary_with: Vec<String>,
812    /// Author families, lineages, model ids, or provider/model selectors
813    /// this row should not review.
814    #[serde(default)]
815    pub avoid_as_reviewer_for: Vec<String>,
816}
817
818#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
819#[serde(rename_all = "snake_case")]
820pub enum ModelAvailability {
821    /// Reachable through the provider's normal API-key path with no extra
822    /// setup. The default for cataloged hosted/local models: by cataloging a
823    /// row we are claiming the route works out of the box.
824    #[default]
825    Serverless,
826    /// Requires the caller to provision a dedicated endpoint before requests
827    /// will succeed. The catalog row exists for selection/pricing UI, but
828    /// hosts must not auto-route to it.
829    Dedicated,
830    /// Availability is not known ahead of time. Used for routes that were
831    /// surfaced dynamically (e.g. through `/v1/models`) without a static
832    /// claim from Harn or the user.
833    Unknown,
834}
835
836impl ModelAvailability {
837    pub fn as_str(self) -> &'static str {
838        match self {
839            Self::Serverless => "serverless",
840            Self::Dedicated => "dedicated",
841            Self::Unknown => "unknown",
842        }
843    }
844
845    pub fn parse(value: &str) -> Option<Self> {
846        match value {
847            "serverless" => Some(Self::Serverless),
848            "dedicated" => Some(Self::Dedicated),
849            "unknown" => Some(Self::Unknown),
850            _ => None,
851        }
852    }
853}
854
855#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
856pub struct ResolvedModel {
857    pub id: String,
858    pub provider: String,
859    pub alias: Option<String>,
860    pub tool_format: String,
861    pub tier: String,
862    pub family: String,
863    pub lineage: String,
864}
865
866#[derive(Debug, Clone, PartialEq)]
867pub struct ComplementaryReviewerOptions {
868    pub author_model: String,
869    pub author_provider: Option<String>,
870    pub intent: ComplementaryReviewerIntent,
871    pub max_price_multiplier: Option<f64>,
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
875pub enum ComplementaryReviewerIntent {
876    Review,
877    Critique,
878    PlanReview,
879}
880
881impl ComplementaryReviewerIntent {
882    pub fn parse(value: &str) -> Option<Self> {
883        match value {
884            "review" => Some(Self::Review),
885            "critique" => Some(Self::Critique),
886            "plan_review" => Some(Self::PlanReview),
887            _ => None,
888        }
889    }
890
891    pub fn as_str(self) -> &'static str {
892        match self {
893            Self::Review => "review",
894            Self::Critique => "critique",
895            Self::PlanReview => "plan_review",
896        }
897    }
898}
899
900#[derive(Debug, Clone, Serialize, PartialEq)]
901pub struct ComplementaryReviewerSelection {
902    pub intent: String,
903    pub author: ComplementaryModelIdentity,
904    pub reviewer: ComplementaryModelIdentity,
905    pub fallback: bool,
906    pub fallback_reason: Option<String>,
907    pub reason: String,
908    pub estimated_incremental_cost: Option<ComplementaryCostEstimate>,
909}
910
911#[derive(Debug, Clone, Serialize, PartialEq)]
912pub struct ComplementaryModelIdentity {
913    pub id: String,
914    pub provider: String,
915    pub family: String,
916    pub lineage: String,
917    pub tier: String,
918    #[serde(skip_serializing_if = "Option::is_none")]
919    pub pricing: Option<ModelPricing>,
920}
921
922#[derive(Debug, Clone, Serialize, PartialEq)]
923pub struct ComplementaryCostEstimate {
924    pub input_per_mtok: f64,
925    pub output_per_mtok: f64,
926    pub total_per_mtok: f64,
927    #[serde(skip_serializing_if = "Option::is_none")]
928    pub multiplier_vs_author: Option<f64>,
929}
930
931#[derive(Debug, Clone, Deserialize)]
932pub struct InferenceRule {
933    #[serde(default)]
934    pub pattern: Option<String>,
935    #[serde(default)]
936    pub contains: Option<String>,
937    #[serde(default)]
938    pub exact: Option<String>,
939    pub provider: String,
940}
941
942#[derive(Debug, Clone, Deserialize)]
943pub struct TierRule {
944    #[serde(default)]
945    pub pattern: Option<String>,
946    #[serde(default)]
947    pub contains: Option<String>,
948    #[serde(default)]
949    pub exact: Option<String>,
950    pub tier: String,
951}
952
953#[derive(Debug, Clone, Deserialize)]
954pub struct TierDefaults {
955    #[serde(default = "default_mid")]
956    pub default: String,
957}
958
959impl Default for TierDefaults {
960    fn default() -> Self {
961        Self {
962            default: default_mid(),
963        }
964    }
965}
966
967fn default_mid() -> String {
968    "mid".to_string()
969}
970
971/// Load and cache the providers config. Called once at VM startup.
972pub fn load_config() -> &'static ProvidersConfig {
973    CONFIG.get_or_init(|| {
974        let mut config = default_config();
975        let verbose_config_logging = matches!(
976            std::env::var("HARN_VERBOSE_CONFIG").ok().as_deref(),
977            Some("1" | "true" | "TRUE" | "yes" | "YES")
978        ) || matches!(
979            std::env::var("HARN_ACP_VERBOSE").ok().as_deref(),
980            Some("1" | "true" | "TRUE" | "yes" | "YES")
981        );
982        if let Ok(path) = std::env::var("HARN_PROVIDERS_CONFIG") {
983            if let Some(overlay) = read_external_config(&path, verbose_config_logging) {
984                config.merge_from(&overlay);
985                let _ = CONFIG_PATH.set(path);
986                return config;
987            }
988        }
989        if should_load_home_config() {
990            if let Some(home) = dirs_or_home() {
991                let path = format!("{home}/.config/harn/providers.toml");
992                if let Some(overlay) = read_external_config(&path, false) {
993                    config.merge_from(&overlay);
994                    let _ = CONFIG_PATH.set(path);
995                    return config;
996                }
997            }
998        }
999        config
1000    })
1001}
1002
1003fn read_external_config(path: &str, verbose: bool) -> Option<ProvidersConfig> {
1004    match std::fs::read_to_string(path) {
1005        Ok(content) => match toml::from_str::<ProvidersConfig>(&content) {
1006            Ok(config) => {
1007                if verbose {
1008                    eprintln!(
1009                        "[llm_config] Loaded {} providers, {} aliases from {}",
1010                        config.providers.len(),
1011                        config.aliases.len(),
1012                        path
1013                    );
1014                }
1015                Some(config)
1016            }
1017            Err(error) => {
1018                eprintln!("[llm_config] TOML parse error in {path}: {error}");
1019                None
1020            }
1021        },
1022        Err(error) => {
1023            if verbose {
1024                eprintln!("[llm_config] Cannot read {path}: {error}");
1025            }
1026            None
1027        }
1028    }
1029}
1030
1031fn should_load_home_config() -> bool {
1032    // Unit tests should cover embedded defaults plus explicit overlays, not
1033    // whichever provider file happens to exist on the developer machine.
1034    !cfg!(test)
1035}
1036
1037/// Parse a provider/model catalog overlay in the same shape as
1038/// `providers.toml` or `[llm]` package-manifest sections.
1039pub fn parse_config_toml(src: &str) -> Result<ProvidersConfig, toml::de::Error> {
1040    toml::from_str::<ProvidersConfig>(src)
1041}
1042
1043/// Returns the filesystem path of the currently-loaded providers config, if
1044/// any. Returns `None` when built-in defaults are active.
1045pub fn loaded_config_path() -> Option<std::path::PathBuf> {
1046    // Force lazy init so CONFIG_PATH is populated if a file was loaded.
1047    let _ = load_config();
1048    CONFIG_PATH.get().map(std::path::PathBuf::from)
1049}
1050
1051/// Install per-run provider config overlays. The overlay uses the same shape as
1052/// `providers.toml`, but lives under `[llm]` in `harn.toml` and package
1053/// manifests. Passing `None` clears the overlay.
1054pub fn set_user_overrides(config: Option<ProvidersConfig>) {
1055    USER_OVERRIDES.with(|cell| *cell.borrow_mut() = config);
1056}
1057
1058/// Clear per-run provider config overlays.
1059pub fn clear_user_overrides() {
1060    set_user_overrides(None);
1061}
1062
1063/// Install the process-wide runtime catalog overlay used by
1064/// `provider_catalog::refresh_runtime_catalog`. Per-run user overlays still
1065/// merge last so project-local provider config can override hosted catalog
1066/// updates.
1067pub fn set_runtime_catalog_overlay(config: Option<ProvidersConfig>) {
1068    *runtime_catalog_overlay()
1069        .write()
1070        .expect("runtime catalog overlay poisoned") = config;
1071}
1072
1073pub fn clear_runtime_catalog_overlay() {
1074    set_runtime_catalog_overlay(None);
1075}
1076
1077pub(crate) fn effective_config() -> ProvidersConfig {
1078    let user_overrides = USER_OVERRIDES.with(|cell| cell.borrow().clone());
1079    effective_config_with_user_overrides(user_overrides.as_ref())
1080}
1081
1082pub(crate) fn effective_config_with_user_overrides(
1083    user_overrides: Option<&ProvidersConfig>,
1084) -> ProvidersConfig {
1085    let mut merged = load_config().clone();
1086    if let Some(overlay) = runtime_catalog_overlay()
1087        .read()
1088        .expect("runtime catalog overlay poisoned")
1089        .as_ref()
1090    {
1091        merged.merge_from(overlay);
1092    }
1093    if let Some(overlay) = user_overrides {
1094        merged.merge_from(overlay);
1095    }
1096    merged
1097}
1098
1099fn runtime_catalog_overlay() -> &'static RwLock<Option<ProvidersConfig>> {
1100    RUNTIME_CATALOG_OVERLAY.get_or_init(|| RwLock::new(None))
1101}
1102
1103/// Resolve a model alias to (model_id, provider_name).
1104pub fn resolve_model(alias: &str) -> (String, Option<String>) {
1105    let config = effective_config();
1106    if let Some(a) = config.aliases.get(alias) {
1107        return (a.id.clone(), Some(a.provider.clone()));
1108    }
1109    (normalize_model_id(alias), None)
1110}
1111
1112/// Strip host/provider selector prefixes that identify transport, not the
1113/// provider-native model id. This mirrors Burin's existing normalization so
1114/// `ollama:qwen3:30b` reaches Ollama as `qwen3:30b` instead of an invalid
1115/// model named `ollama`. Cerebras follows the same convention but uses a
1116/// slash separator (`cerebras/gpt-oss-120b`) because its own /v1/models
1117/// endpoint returns bare names that overlap OpenAI's families.
1118pub fn normalize_model_id(raw: &str) -> String {
1119    for prefix in PROVIDER_SELECTOR_PREFIXES {
1120        if let Some(stripped) = raw.strip_prefix(prefix) {
1121            return stripped.to_string();
1122        }
1123    }
1124    raw.to_string()
1125}
1126
1127const PROVIDER_SELECTOR_PREFIXES: &[&str] =
1128    &["ollama:", "local:", "huggingface:", "hf:", "cerebras/"];
1129
1130/// Resolve an alias or selector into the complete catalog identity hosts need:
1131/// provider inference, prefix-normalized model id, default tool format, and tier.
1132pub fn resolve_model_info(selector: &str) -> ResolvedModel {
1133    let config = effective_config();
1134    if let Some(alias) = config.aliases.get(selector) {
1135        let id = alias.id.clone();
1136        let provider = alias.provider.clone();
1137        let tool_format = alias
1138            .tool_format
1139            .clone()
1140            .unwrap_or_else(|| default_tool_format_with_config(&config, &id, &provider));
1141        return ResolvedModel {
1142            tier: model_tier_with_config(&config, &id),
1143            family: model_family_with_config(&config, &provider, &id),
1144            lineage: model_lineage_with_config(&config, &provider, &id),
1145            id,
1146            provider,
1147            alias: Some(selector.to_string()),
1148            tool_format,
1149        };
1150    }
1151
1152    let id = normalize_model_id(selector);
1153    let inference = infer_provider_with_config(&config, selector);
1154    let source = inference.source;
1155    let provider = inference.provider;
1156    let tool_format = default_tool_format_with_config(&config, &id, &provider);
1157    let tier = model_tier_with_config(&config, &id);
1158    let family = model_family_with_inference_source(&config, &provider, &id, source);
1159    let lineage = model_lineage_with_inference_source(&config, &provider, &id, source);
1160    ResolvedModel {
1161        id,
1162        provider,
1163        alias: None,
1164        tool_format,
1165        tier,
1166        family,
1167        lineage,
1168    }
1169}
1170
1171/// Infer provider from a model ID using inference rules.
1172pub fn infer_provider(model_id: &str) -> String {
1173    infer_provider_detail(model_id).provider
1174}
1175
1176/// Infer provider from a model ID and retain whether the configured default was used.
1177pub(crate) fn infer_provider_detail(model_id: &str) -> crate::llm::provider::ProviderInference {
1178    let config = effective_config();
1179    infer_provider_with_config(&config, model_id)
1180}
1181
1182fn infer_provider_with_config(
1183    config: &ProvidersConfig,
1184    model_id: &str,
1185) -> crate::llm::provider::ProviderInference {
1186    if model_id.starts_with("local:") || model_id.starts_with("ollama:") {
1187        return crate::llm::provider::ProviderInference::builtin("ollama");
1188    }
1189    if model_id.starts_with("huggingface:") || model_id.starts_with("hf:") {
1190        return crate::llm::provider::ProviderInference::builtin("huggingface");
1191    }
1192    // Exact catalog rows are the most authoritative declaration of where
1193    // a model is hosted: any pattern-based inference rule is necessarily
1194    // less specific than `[models."<id>"].provider = "<name>"`. Catalogs
1195    // include user overlays, so users can still re-home a model by
1196    // setting a catalog entry in their own providers.toml.
1197    let normalized_id = normalize_model_id(model_id);
1198    if let Some(model) = config
1199        .models
1200        .get(model_id)
1201        .or_else(|| config.models.get(&normalized_id))
1202    {
1203        return crate::llm::provider::ProviderInference::builtin(model.provider.clone());
1204    }
1205    for rule in &config.inference_rules {
1206        if let Some(exact) = &rule.exact {
1207            if model_id == exact {
1208                return crate::llm::provider::ProviderInference::builtin(rule.provider.clone());
1209            }
1210        }
1211        if let Some(pattern) = &rule.pattern {
1212            if glob_match(pattern, model_id) {
1213                return crate::llm::provider::ProviderInference::builtin(rule.provider.clone());
1214            }
1215        }
1216        if let Some(substr) = &rule.contains {
1217            if model_id.contains(substr.as_str()) {
1218                return crate::llm::provider::ProviderInference::builtin(rule.provider.clone());
1219            }
1220        }
1221    }
1222    crate::llm::provider::infer_provider_from_model_id(
1223        model_id,
1224        &default_provider_with_config(config),
1225    )
1226}
1227
1228pub fn default_provider() -> String {
1229    let config = effective_config();
1230    default_provider_with_config(&config)
1231}
1232
1233fn default_provider_with_config(config: &ProvidersConfig) -> String {
1234    std::env::var("HARN_DEFAULT_PROVIDER")
1235        .ok()
1236        .map(|value| value.trim().to_string())
1237        .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto"))
1238        .or_else(|| {
1239            config
1240                .default_provider
1241                .as_deref()
1242                .map(str::trim)
1243                .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto"))
1244                .map(str::to_string)
1245        })
1246        .unwrap_or_else(|| "anthropic".to_string())
1247}
1248
1249/// Get model tier ("small", "mid", "frontier").
1250pub fn model_tier(model_id: &str) -> String {
1251    let config = effective_config();
1252    model_tier_with_config(&config, model_id)
1253}
1254
1255pub(crate) fn model_tier_with_config(config: &ProvidersConfig, model_id: &str) -> String {
1256    // Per-model self-declared tier wins. This is the only path.
1257    if let Some(model) = config.models.get(model_id) {
1258        if let Some(tier) = model.tier.as_deref() {
1259            let trimmed = tier.trim();
1260            if !trimmed.is_empty() {
1261                return trimmed.to_string();
1262            }
1263        }
1264    }
1265    // Legacy pattern-rules: still consulted while we finish migrating the
1266    // long tail of models to per-row `tier = "..."`. Newly added rows
1267    // should set `tier` directly; the rule table is a fallback only.
1268    for rule in &config.tier_rules {
1269        if let Some(exact) = &rule.exact {
1270            if model_id == exact {
1271                return rule.tier.clone();
1272            }
1273        }
1274        if let Some(pattern) = &rule.pattern {
1275            if glob_match(pattern, model_id) {
1276                return rule.tier.clone();
1277            }
1278        }
1279        if let Some(substr) = &rule.contains {
1280            if model_id.contains(substr.as_str()) {
1281                return rule.tier.clone();
1282            }
1283        }
1284    }
1285    config.tier_defaults.default.clone()
1286}
1287
1288/// Return the normalized model-family token used for cross-family review.
1289pub fn model_family(provider: &str, model_id: &str) -> String {
1290    let config = effective_config();
1291    model_family_with_config(&config, provider, model_id)
1292}
1293
1294pub(crate) fn model_family_with_config(
1295    config: &ProvidersConfig,
1296    provider: &str,
1297    model_id: &str,
1298) -> String {
1299    catalog_family_token(config, model_id)
1300        .unwrap_or_else(|| derive_model_family(provider, model_id))
1301}
1302
1303fn model_family_with_inference_source(
1304    config: &ProvidersConfig,
1305    provider: &str,
1306    model_id: &str,
1307    source: crate::llm::provider::ProviderInferenceSource,
1308) -> String {
1309    if let Some(family) = catalog_family_token(config, model_id) {
1310        return family;
1311    }
1312    let id_family = derive_model_family("", model_id);
1313    if id_family != "unknown" {
1314        return id_family;
1315    }
1316    if matches!(
1317        source,
1318        crate::llm::provider::ProviderInferenceSource::DefaultFallback
1319    ) {
1320        return "unknown".to_string();
1321    }
1322    derive_model_family(provider, model_id)
1323}
1324
1325/// Return the narrower lineage token used for model-aware option packs.
1326pub fn model_lineage(provider: &str, model_id: &str) -> String {
1327    let config = effective_config();
1328    model_lineage_with_config(&config, provider, model_id)
1329}
1330
1331pub(crate) fn model_lineage_with_config(
1332    config: &ProvidersConfig,
1333    provider: &str,
1334    model_id: &str,
1335) -> String {
1336    catalog_lineage_token(config, model_id)
1337        .unwrap_or_else(|| derive_model_lineage(provider, model_id))
1338}
1339
1340fn model_lineage_with_inference_source(
1341    config: &ProvidersConfig,
1342    provider: &str,
1343    model_id: &str,
1344    source: crate::llm::provider::ProviderInferenceSource,
1345) -> String {
1346    if let Some(lineage) = catalog_lineage_token(config, model_id) {
1347        return lineage;
1348    }
1349    let id_lineage = derive_model_lineage("", model_id);
1350    if id_lineage != "unknown" {
1351        return id_lineage;
1352    }
1353    if matches!(
1354        source,
1355        crate::llm::provider::ProviderInferenceSource::DefaultFallback
1356    ) {
1357        return "unknown".to_string();
1358    }
1359    derive_model_lineage(provider, model_id)
1360}
1361
1362fn catalog_family_token(config: &ProvidersConfig, model_id: &str) -> Option<String> {
1363    config
1364        .models
1365        .get(model_id)
1366        .and_then(|model| normalized_catalog_token(model.family.as_deref()))
1367}
1368
1369fn catalog_lineage_token(config: &ProvidersConfig, model_id: &str) -> Option<String> {
1370    config
1371        .models
1372        .get(model_id)
1373        .and_then(|model| normalized_catalog_token(model.lineage.as_deref()))
1374}
1375
1376fn normalized_catalog_token(value: Option<&str>) -> Option<String> {
1377    value
1378        .map(str::trim)
1379        .filter(|value| !value.is_empty())
1380        .map(|value| value.to_ascii_lowercase().replace('_', "-"))
1381}
1382
1383fn derive_model_family(provider: &str, model_id: &str) -> String {
1384    let id = model_id.to_ascii_lowercase();
1385    if contains_any(&id, &["claude", "anthropic.claude"]) {
1386        return "anthropic-claude".to_string();
1387    }
1388    if contains_any(&id, &["gemini", "google/gemini"]) {
1389        return "google-gemini".to_string();
1390    }
1391    if contains_any(&id, &["deepseek"]) {
1392        return "deepseek".to_string();
1393    }
1394    if contains_any(&id, &["qwen"]) {
1395        return "qwen".to_string();
1396    }
1397    if contains_any(&id, &["kimi", "moonshot"]) {
1398        return "kimi".to_string();
1399    }
1400    if contains_any(&id, &["glm", "z-ai/glm", "zhipu"]) {
1401        return "glm".to_string();
1402    }
1403    if contains_any(&id, &["mistral", "mixtral", "devstral"]) {
1404        return "mistral".to_string();
1405    }
1406    if contains_any(&id, &["minimax"]) {
1407        return "minimax".to_string();
1408    }
1409    if contains_any(&id, &["llama"]) {
1410        return "llama".to_string();
1411    }
1412    if contains_any(&id, &["gemma"]) {
1413        return "gemma".to_string();
1414    }
1415    if is_openai_reasoning_model(&id) {
1416        return "openai-reasoning".to_string();
1417    }
1418    if contains_any(&id, &["gpt-oss", "openai/gpt", "gpt-"]) {
1419        return "openai-gpt".to_string();
1420    }
1421    match provider {
1422        "anthropic" | "bedrock" | "vertex-anthropic" => "anthropic-claude".to_string(),
1423        "openai" | "azure" | "azure_openai" => "openai-gpt".to_string(),
1424        "gemini" | "vertex" | "google" => "google-gemini".to_string(),
1425        "deepseek" => "deepseek".to_string(),
1426        "zai" => "glm".to_string(),
1427        "minimax" => "minimax".to_string(),
1428        other if !other.is_empty() => normalize_identifier_token(other),
1429        _ => "unknown".to_string(),
1430    }
1431}
1432
1433fn derive_model_lineage(provider: &str, model_id: &str) -> String {
1434    let id = model_id.to_ascii_lowercase();
1435    if contains_any(&id, &["haiku"]) {
1436        return "claude-haiku".to_string();
1437    }
1438    if contains_any(&id, &["opus-4-7", "opus-4-8", "opus-mythos"]) {
1439        return "claude-opus-adaptive".to_string();
1440    }
1441    if contains_any(&id, &["claude"]) {
1442        return "claude-sonnet-opus".to_string();
1443    }
1444    if contains_any(&id, &["gpt-5"]) {
1445        return "openai-gpt5".to_string();
1446    }
1447    if is_openai_reasoning_model(&id) {
1448        return "openai-reasoning".to_string();
1449    }
1450    if contains_any(&id, &["gpt-", "gpt_"]) {
1451        return "openai-legacy".to_string();
1452    }
1453    if contains_any(&id, &["gemini"]) {
1454        if contains_any(&id, &["flash"]) {
1455            return "gemini-flash".to_string();
1456        }
1457        return "gemini-pro".to_string();
1458    }
1459    if contains_any(&id, &["qwen3", "qwen/qwen3"]) {
1460        return "qwen3".to_string();
1461    }
1462    if contains_any(&id, &["gemma4", "gemma-4"]) {
1463        return "gemma4".to_string();
1464    }
1465    let family = derive_model_family(provider, model_id);
1466    if family == "unknown" {
1467        "unknown".to_string()
1468    } else {
1469        family
1470    }
1471}
1472
1473fn contains_any(haystack: &str, needles: &[&str]) -> bool {
1474    needles.iter().any(|needle| haystack.contains(needle))
1475}
1476
1477fn starts_with_any(haystack: &str, prefixes: &[&str]) -> bool {
1478    prefixes.iter().any(|prefix| haystack.starts_with(prefix))
1479}
1480
1481fn is_openai_reasoning_model(id: &str) -> bool {
1482    starts_with_any(id, &["o1", "o3", "o4"])
1483        || contains_any(
1484            id,
1485            &[
1486                "/o1", "/o3", "/o4", ":o1", ":o3", ":o4", ".o1", ".o3", ".o4",
1487            ],
1488        )
1489}
1490
1491fn normalize_identifier_token(value: &str) -> String {
1492    value
1493        .trim()
1494        .to_ascii_lowercase()
1495        .chars()
1496        .map(|ch| {
1497            if ch.is_ascii_alphanumeric() || ch == '-' {
1498                ch
1499            } else {
1500                '-'
1501            }
1502        })
1503        .collect::<String>()
1504        .split('-')
1505        .filter(|part| !part.is_empty())
1506        .collect::<Vec<_>>()
1507        .join("-")
1508}
1509
1510/// Get provider config for resolving base_url, auth, etc.
1511pub fn provider_config(name: &str) -> Option<ProviderDef> {
1512    effective_config().providers.get(name).cloned()
1513}
1514
1515pub fn provider_protocol(name: &str) -> Option<String> {
1516    provider_config(name).and_then(|def| def.protocol)
1517}
1518
1519pub fn provider_uses_acp(name: &str) -> bool {
1520    provider_protocol(name)
1521        .as_deref()
1522        .is_some_and(|protocol| protocol.eq_ignore_ascii_case("acp"))
1523}
1524
1525/// Get model-specific default parameters (temperature, etc.).
1526/// Matches glob patterns in model_defaults keys.
1527pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
1528    let config = effective_config();
1529    let mut params = BTreeMap::new();
1530    for (pattern, defaults) in &config.model_defaults {
1531        if glob_match(pattern, model_id) {
1532            for (k, v) in defaults {
1533                params.insert(k.clone(), v.clone());
1534            }
1535        }
1536    }
1537    params
1538}
1539
1540/// Get per-role LLM defaults, e.g. `[model_roles.merge]`.
1541///
1542/// Role defaults are intentionally shaped like ordinary `llm_call` options:
1543/// callers can pin `provider`/`model`, install `route_policy` or `prefer`,
1544/// and tune budget/latency knobs without creating a parallel routing stack.
1545/// Environment variables provide a lightweight operational override for
1546/// merge/fast-apply workers:
1547///
1548/// - `HARN_LLM_MERGE_PROVIDER`, `HARN_LLM_MERGE_MODEL`,
1549///   `HARN_LLM_MERGE_ROUTE_POLICY`
1550/// - `HARN_LLM_FAST_APPLY_PROVIDER`, `HARN_LLM_FAST_APPLY_MODEL`,
1551///   `HARN_LLM_FAST_APPLY_ROUTE_POLICY`
1552/// - `HARN_LLM_ROLE_<ROLE>_PROVIDER`, `_MODEL`, `_ROUTE_POLICY`
1553pub fn model_role_defaults(role: &str) -> BTreeMap<String, toml::Value> {
1554    let normalized = normalize_model_role_name(role);
1555    if normalized.is_empty() {
1556        return BTreeMap::new();
1557    }
1558    let config = effective_config();
1559    let mut params = BTreeMap::new();
1560    for key in role_lookup_keys(&normalized) {
1561        extend_model_role_defaults(&config, &key, &mut params);
1562    }
1563    apply_model_role_env_overrides(&normalized, &mut params);
1564    params
1565}
1566
1567fn extend_model_role_defaults(
1568    config: &ProvidersConfig,
1569    role: &str,
1570    params: &mut BTreeMap<String, toml::Value>,
1571) {
1572    for (configured_role, defaults) in &config.model_roles {
1573        if normalize_model_role_name(configured_role) == role {
1574            params.extend(defaults.clone());
1575        }
1576    }
1577    if let Some(defaults) = config.model_roles.get(role) {
1578        params.extend(defaults.clone());
1579    }
1580}
1581
1582fn normalize_model_role_name(role: &str) -> String {
1583    role.trim().to_ascii_lowercase().replace('-', "_")
1584}
1585
1586fn role_lookup_keys(role: &str) -> Vec<String> {
1587    if role == "merge" {
1588        vec!["fast_apply".to_string(), "merge".to_string()]
1589    } else if role == "fast_apply" {
1590        vec!["merge".to_string(), "fast_apply".to_string()]
1591    } else {
1592        vec![role.to_string()]
1593    }
1594}
1595
1596fn role_env_token(role: &str) -> String {
1597    role.chars()
1598        .map(|ch| {
1599            if ch.is_ascii_alphanumeric() {
1600                ch.to_ascii_uppercase()
1601            } else {
1602                '_'
1603            }
1604        })
1605        .collect::<String>()
1606        .split('_')
1607        .filter(|part| !part.is_empty())
1608        .collect::<Vec<_>>()
1609        .join("_")
1610}
1611
1612fn apply_model_role_env_overrides(role: &str, params: &mut BTreeMap<String, toml::Value>) {
1613    for alias in role_env_aliases(role) {
1614        apply_model_role_env_var(&format!("HARN_LLM_{alias}_PROVIDER"), "provider", params);
1615        apply_model_role_env_var(&format!("HARN_LLM_{alias}_MODEL"), "model", params);
1616        apply_model_role_env_var(
1617            &format!("HARN_LLM_{alias}_ROUTE_POLICY"),
1618            "route_policy",
1619            params,
1620        );
1621        apply_model_role_env_var(
1622            &format!("HARN_LLM_ROLE_{alias}_PROVIDER"),
1623            "provider",
1624            params,
1625        );
1626        apply_model_role_env_var(&format!("HARN_LLM_ROLE_{alias}_MODEL"), "model", params);
1627        apply_model_role_env_var(
1628            &format!("HARN_LLM_ROLE_{alias}_ROUTE_POLICY"),
1629            "route_policy",
1630            params,
1631        );
1632    }
1633}
1634
1635fn role_env_aliases(role: &str) -> Vec<String> {
1636    let token = role_env_token(role);
1637    if token.is_empty() {
1638        return Vec::new();
1639    }
1640    if token == "MERGE" {
1641        vec!["FAST_APPLY".to_string(), "MERGE".to_string()]
1642    } else if token == "FAST_APPLY" {
1643        vec!["MERGE".to_string(), "FAST_APPLY".to_string()]
1644    } else {
1645        vec![token]
1646    }
1647}
1648
1649fn apply_model_role_env_var(
1650    env_name: &str,
1651    option_name: &str,
1652    params: &mut BTreeMap<String, toml::Value>,
1653) {
1654    let Ok(value) = std::env::var(env_name) else {
1655        return;
1656    };
1657    let trimmed = value.trim();
1658    if trimmed.is_empty() {
1659        return;
1660    }
1661    params.insert(
1662        option_name.to_string(),
1663        toml::Value::String(trimmed.to_string()),
1664    );
1665}
1666
1667/// Get list of configured provider names.
1668pub fn provider_names() -> Vec<String> {
1669    effective_config().providers.keys().cloned().collect()
1670}
1671
1672/// Return every configured alias name, sorted deterministically.
1673pub fn known_model_names() -> Vec<String> {
1674    effective_config().aliases.keys().cloned().collect()
1675}
1676
1677pub fn alias_entries() -> Vec<(String, AliasDef)> {
1678    effective_config().aliases.into_iter().collect()
1679}
1680
1681pub fn alias_tool_calling_entry(alias: &str) -> Option<AliasToolCallingDef> {
1682    effective_config().alias_tool_calling.get(alias).cloned()
1683}
1684
1685/// Return every configured model-catalog entry, sorted by provider then id.
1686pub fn model_catalog_entries() -> Vec<(String, ModelDef)> {
1687    let config = effective_config();
1688    model_catalog_entries_with_config(&config)
1689}
1690
1691pub(crate) fn model_catalog_entries_with_config(
1692    config: &ProvidersConfig,
1693) -> Vec<(String, ModelDef)> {
1694    sorted_model_entries_with_config(config)
1695        .into_iter()
1696        .map(|(id, model)| {
1697            let provider = model.provider.clone();
1698            (
1699                id.clone(),
1700                with_effective_capability_tags(id, provider, model),
1701            )
1702        })
1703        .collect()
1704}
1705
1706pub(crate) fn sorted_model_entries_with_config(
1707    config: &ProvidersConfig,
1708) -> Vec<(String, ModelDef)> {
1709    let mut entries: Vec<_> = config
1710        .models
1711        .iter()
1712        .map(|(id, model)| (id.clone(), model.clone()))
1713        .collect();
1714    entries.sort_by(|(id_a, model_a), (id_b, model_b)| {
1715        model_a
1716            .provider
1717            .cmp(&model_b.provider)
1718            .then_with(|| id_a.cmp(id_b))
1719    });
1720    entries
1721}
1722
1723pub fn model_catalog_entry(model_id: &str) -> Option<ModelDef> {
1724    effective_config()
1725        .models
1726        .get(model_id)
1727        .cloned()
1728        .map(|model| {
1729            let provider = model.provider.clone();
1730            with_effective_capability_tags(model_id.to_string(), provider, model)
1731        })
1732}
1733
1734pub fn model_rate_limits(model_id: &str) -> Option<RateLimitsDef> {
1735    model_catalog_entry(model_id).and_then(|model| model.rate_limits)
1736}
1737
1738pub fn wire_model_id(model_id: &str) -> String {
1739    model_catalog_entry(model_id)
1740        .and_then(|model| model.wire_model)
1741        .unwrap_or_else(|| model_id.to_string())
1742}
1743
1744pub fn provider_rate_limits(provider: &str) -> Option<RateLimitsDef> {
1745    provider_config(provider).and_then(|provider| {
1746        provider
1747            .rate_limits
1748            .unwrap_or_default()
1749            .with_rpm_fallback(provider.rpm)
1750    })
1751}
1752
1753pub fn model_equivalence_group(model_id: &str) -> Option<String> {
1754    model_catalog_entry(model_id).and_then(|model| {
1755        model
1756            .equivalence_group
1757            .or(model.logical_model)
1758            .filter(|group| !group.trim().is_empty())
1759    })
1760}
1761
1762/// Return same-logical-model routes that can be considered for explicit
1763/// failover or cross-provider experiments. Equivalence is a catalog assertion
1764/// about compatible model weights/family, not wire-level identity.
1765pub fn equivalent_model_catalog_entries(selector: &str) -> Vec<(String, ModelDef)> {
1766    let resolved = resolve_model_info(selector);
1767    let Some(group) = model_equivalence_group(&resolved.id) else {
1768        return Vec::new();
1769    };
1770    let config = effective_config();
1771    let Some(source) = config.models.get(&resolved.id) else {
1772        return Vec::new();
1773    };
1774    let source_caps = crate::llm::capabilities::lookup(&source.provider, &resolved.id);
1775    let source_context = source
1776        .runtime_context_window
1777        .unwrap_or(source.context_window);
1778
1779    sorted_model_entries_with_config(&config)
1780        .into_iter()
1781        .filter(|(id, model)| !(id == &resolved.id && model.provider == resolved.provider))
1782        .filter(|(_, model)| !model.deprecated)
1783        .filter(|(_, model)| model.availability != ModelAvailability::Dedicated)
1784        .filter(|(_, model)| {
1785            model.equivalence_group.as_deref() == Some(group.as_str())
1786                || model.logical_model.as_deref() == Some(group.as_str())
1787        })
1788        .filter(|(id, model)| {
1789            let caps = crate::llm::capabilities::lookup(&model.provider, id);
1790            let candidate_context = model.runtime_context_window.unwrap_or(model.context_window);
1791            candidate_context >= source_context
1792                && (!source_caps.native_tools || caps.native_tools)
1793                && (!source_caps.text_tool_wire_format_supported
1794                    || caps.text_tool_wire_format_supported)
1795                && (!source_caps.reasoning_effort_supported || caps.reasoning_effort_supported)
1796                && source_caps.structured_output_mode == caps.structured_output_mode
1797        })
1798        .map(|(id, model)| {
1799            let provider = model.provider.clone();
1800            (
1801                id.clone(),
1802                with_effective_capability_tags(id, provider, model),
1803            )
1804        })
1805        .collect()
1806}
1807
1808pub fn qc_default_model(provider: &str) -> Option<String> {
1809    std::env::var("BURIN_QC_MODEL")
1810        .ok()
1811        .filter(|value| !value.trim().is_empty())
1812        .or_else(|| {
1813            effective_config()
1814                .qc_defaults
1815                .get(&provider.to_lowercase())
1816                .cloned()
1817        })
1818}
1819
1820pub fn default_model_for_provider(provider: &str) -> String {
1821    if provider_uses_acp(provider) {
1822        return "default".to_string();
1823    }
1824    match provider {
1825        "local" => std::env::var("LOCAL_LLM_MODEL")
1826            .or_else(|_| std::env::var("HARN_LLM_MODEL"))
1827            .unwrap_or_else(|_| "gemma-4-26b-a4b-it".to_string()),
1828        "mlx" => std::env::var("MLX_MODEL_ID")
1829            .unwrap_or_else(|_| "unsloth/Qwen3.6-27B-UD-MLX-4bit".to_string()),
1830        "openai" => "gpt-4o-mini".to_string(),
1831        "ollama" => "llama3.2".to_string(),
1832        "openrouter" => "anthropic/claude-sonnet-4.6".to_string(),
1833        _ => "claude-sonnet-4-6".to_string(),
1834    }
1835}
1836
1837pub fn qc_defaults() -> BTreeMap<String, String> {
1838    effective_config().qc_defaults
1839}
1840
1841pub fn model_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
1842    effective_config()
1843        .models
1844        .get(model_id)
1845        .and_then(|model| model.pricing.clone())
1846}
1847
1848/// Premium per-MTok pricing for a model's accelerated-serving ("fast mode")
1849/// tier, when the catalog declares one. Returns `None` for models with no
1850/// fast tier or a tier that omits explicit pricing — callers fall back to
1851/// standard pricing in that case.
1852pub fn model_fast_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
1853    effective_config()
1854        .models
1855        .get(model_id)
1856        .and_then(|model| model.fast_mode.as_ref())
1857        .and_then(|fast_mode| fast_mode.pricing.clone())
1858}
1859
1860pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
1861    model_pricing_per_mtok(model_id)
1862        .map(|pricing| {
1863            (
1864                pricing.input_per_mtok / 1000.0,
1865                pricing.output_per_mtok / 1000.0,
1866            )
1867        })
1868        .or_else(|| {
1869            let (input, output, _) = provider_economics(provider);
1870            match (input, output) {
1871                (Some(input), Some(output)) => Some((input, output)),
1872                _ => None,
1873            }
1874        })
1875}
1876
1877pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
1878    match auth_env {
1879        AuthEnv::None => Vec::new(),
1880        AuthEnv::Single(name) => vec![name.clone()],
1881        AuthEnv::Multiple(names) => names.clone(),
1882    }
1883}
1884
1885pub fn provider_key_available(provider: &str) -> bool {
1886    let Some(pdef) = provider_config(provider) else {
1887        return provider == "ollama";
1888    };
1889    if pdef.auth_style == "none" || matches!(pdef.auth_env, AuthEnv::None) {
1890        return true;
1891    }
1892    auth_env_names(&pdef.auth_env).into_iter().any(|env_name| {
1893        std::env::var(env_name)
1894            .ok()
1895            .is_some_and(|value| !value.trim().is_empty())
1896    })
1897}
1898
1899pub fn available_provider_names() -> Vec<String> {
1900    provider_names()
1901        .into_iter()
1902        .filter(|provider| provider_key_available(provider))
1903        .collect()
1904}
1905
1906/// Check if a provider advertises a legacy provider-level feature.
1907pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
1908    provider_config(provider)
1909        .map(|p| p.features.iter().any(|f| f == feature))
1910        .unwrap_or(false)
1911}
1912
1913/// Provider-level catalog pricing/latency. Model-specific catalog pricing
1914/// wins when available; this is the adapter-level fallback used by routing
1915/// and portal summaries when a model has no explicit catalog entry.
1916pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
1917    provider_config(provider)
1918        .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
1919        .unwrap_or((None, None, None))
1920}
1921
1922/// Resolve the default tool format for a model+provider combination.
1923/// Priority: alias `tool_format` (matched by model ID) > provider/model
1924/// capability matrix > legacy provider feature > "text".
1925pub fn default_tool_format(model: &str, provider: &str) -> String {
1926    let config = effective_config();
1927    default_tool_format_with_config(&config, model, provider)
1928}
1929
1930fn default_tool_format_with_config(
1931    config: &ProvidersConfig,
1932    model: &str,
1933    provider: &str,
1934) -> String {
1935    // Aliases match by model ID + provider, or by alias name.
1936    for (name, alias) in &config.aliases {
1937        let matches = (alias.id == model && alias.provider == provider) || name == model;
1938        if matches {
1939            if let Some(ref fmt) = alias.tool_format {
1940                return fmt.clone();
1941            }
1942        }
1943    }
1944    let capabilities = crate::llm::capabilities::lookup(provider, model);
1945    if let Some(format) = capabilities.preferred_tool_format.as_deref() {
1946        if matches!(format, "native" | "text") {
1947            return format.to_string();
1948        }
1949    }
1950    let capability_matrix_native = capabilities.native_tools;
1951    let legacy_provider_native = config
1952        .providers
1953        .get(provider)
1954        .map(|p| p.features.iter().any(|f| f == "native_tools"))
1955        .unwrap_or(false);
1956    if capability_matrix_native || legacy_provider_native {
1957        "native".to_string()
1958    } else {
1959        "text".to_string()
1960    }
1961}
1962
1963fn with_effective_capability_tags(
1964    model_id: String,
1965    provider: String,
1966    mut model: ModelDef,
1967) -> ModelDef {
1968    model.capabilities = effective_model_capability_tags(&provider, &model_id);
1969    model
1970}
1971
1972/// Legacy display tags derived from the canonical provider/model capability
1973/// matrix. The matrix is the source of truth; `models.*.capabilities` in
1974/// providers.toml is accepted only for backwards-compatible parsing.
1975pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
1976    let caps = crate::llm::capabilities::lookup(provider, model_id);
1977    capability_tags_from_capabilities(&caps)
1978}
1979
1980pub(crate) fn capability_tags_from_capabilities(
1981    caps: &crate::llm::capabilities::Capabilities,
1982) -> Vec<String> {
1983    let mut tags = Vec::new();
1984    // Today all Harn chat providers expose streaming. Keep this as a
1985    // transport baseline rather than a duplicated per-model declaration.
1986    tags.push("streaming".to_string());
1987    if caps.native_tools || caps.text_tool_wire_format_supported {
1988        tags.push("tools".to_string());
1989    }
1990    if !caps.tool_search.is_empty() {
1991        tags.push("tool_search".to_string());
1992    }
1993    if caps.vision || caps.vision_supported {
1994        tags.push("vision".to_string());
1995    }
1996    if caps.audio {
1997        tags.push("audio".to_string());
1998    }
1999    if caps.pdf {
2000        tags.push("pdf".to_string());
2001    }
2002    if caps.video {
2003        tags.push("video".to_string());
2004    }
2005    if caps.files_api_supported {
2006        tags.push("files".to_string());
2007    }
2008    if caps.prompt_caching {
2009        tags.push("prompt_caching".to_string());
2010    }
2011    if !caps.thinking_modes.is_empty() {
2012        tags.push("thinking".to_string());
2013    }
2014    if caps.interleaved_thinking_supported
2015        || caps
2016            .thinking_modes
2017            .iter()
2018            .any(|mode| mode == "adaptive" || mode == "effort")
2019    {
2020        tags.push("extended_thinking".to_string());
2021    }
2022    if caps.json_schema.is_some() {
2023        tags.push("structured_output".to_string());
2024    }
2025    tags
2026}
2027
2028/// Resolve a tier or alias into a concrete model/provider pair.
2029pub fn resolve_tier_model(
2030    target: &str,
2031    preferred_provider: Option<&str>,
2032) -> Option<(String, String)> {
2033    let config = effective_config();
2034
2035    if let Some(alias) = config.aliases.get(target) {
2036        return Some((alias.id.clone(), alias.provider.clone()));
2037    }
2038
2039    let candidate_aliases = if let Some(provider) = preferred_provider {
2040        vec![
2041            format!("{provider}/{target}"),
2042            format!("{provider}:{target}"),
2043            format!("tier/{target}"),
2044            target.to_string(),
2045        ]
2046    } else {
2047        vec![format!("tier/{target}"), target.to_string()]
2048    };
2049
2050    for alias_name in candidate_aliases {
2051        if let Some(alias) = config.aliases.get(&alias_name) {
2052            return Some((alias.id.clone(), alias.provider.clone()));
2053        }
2054    }
2055
2056    None
2057}
2058
2059/// Return all configured alias-backed model/provider pairs whose resolved
2060/// model falls into the requested capability tier. The result is de-duplicated
2061/// and sorted deterministically by provider then model id.
2062pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
2063    let config = effective_config();
2064    let mut seen = std::collections::BTreeSet::new();
2065    let mut candidates = Vec::new();
2066
2067    for alias in config.aliases.values() {
2068        let pair = (alias.id.clone(), alias.provider.clone());
2069        if seen.contains(&pair) {
2070            continue;
2071        }
2072        if model_tier(&alias.id) == target {
2073            seen.insert(pair.clone());
2074            candidates.push(pair);
2075        }
2076    }
2077
2078    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
2079        provider_a
2080            .cmp(provider_b)
2081            .then_with(|| model_a.cmp(model_b))
2082    });
2083    candidates
2084}
2085
2086/// Return all configured alias-backed model/provider pairs. Used by routing
2087/// policies that need to compare alternatives across tiers.
2088pub fn all_model_candidates() -> Vec<(String, String)> {
2089    let config = effective_config();
2090    let mut seen = std::collections::BTreeSet::new();
2091    let mut candidates = Vec::new();
2092
2093    for alias in config.aliases.values() {
2094        let pair = (alias.id.clone(), alias.provider.clone());
2095        if seen.insert(pair.clone()) {
2096            candidates.push(pair);
2097        }
2098    }
2099
2100    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
2101        provider_a
2102            .cmp(provider_b)
2103            .then_with(|| model_a.cmp(model_b))
2104    });
2105    candidates
2106}
2107
2108pub fn pick_complementary_reviewer(
2109    options: ComplementaryReviewerOptions,
2110) -> ComplementaryReviewerSelection {
2111    let config = effective_config();
2112    let mut author = resolve_model_info(&options.author_model);
2113    if let Some(provider) = options
2114        .author_provider
2115        .as_deref()
2116        .map(str::trim)
2117        .filter(|provider| !provider.is_empty())
2118    {
2119        author.provider = provider.to_string();
2120        author.family = model_family_with_config(&config, &author.provider, &author.id);
2121        author.lineage = model_lineage_with_config(&config, &author.provider, &author.id);
2122        author.tool_format = default_tool_format_with_config(&config, &author.id, &author.provider);
2123    }
2124    let author_entry = config.models.get(&author.id);
2125    let author_identity = complementary_identity(
2126        author.id.clone(),
2127        author.provider.clone(),
2128        author.family.clone(),
2129        author.lineage.clone(),
2130        author.tier.clone(),
2131        author_entry.and_then(|model| model.pricing.clone()),
2132    );
2133
2134    let fallback = |fallback_reason: String| ComplementaryReviewerSelection {
2135        intent: options.intent.as_str().to_string(),
2136        reviewer: author_identity.clone(),
2137        estimated_incremental_cost: cost_estimate(
2138            author_identity.pricing.as_ref(),
2139            author_identity.pricing.as_ref(),
2140        ),
2141        author: author_identity.clone(),
2142        fallback: true,
2143        reason: format!(
2144            "using author model {} because {fallback_reason}",
2145            author_identity.id
2146        ),
2147        fallback_reason: Some(fallback_reason),
2148    };
2149
2150    if author_identity.family == "unknown" {
2151        return fallback("author model family is unknown".to_string());
2152    }
2153
2154    let preferred_families = author_entry
2155        .map(|model| model.complementary_with.clone())
2156        .unwrap_or_default();
2157    let author_refs = reviewer_match_refs(&author_identity);
2158    let mut rejected_by_price = 0usize;
2159    let mut diff_family_seen = 0usize;
2160    let mut candidates = Vec::new();
2161
2162    for (id, model) in config.models.iter() {
2163        if id == &author_identity.id && model.provider == author_identity.provider {
2164            continue;
2165        }
2166        if model.deprecated || model.availability != ModelAvailability::Serverless {
2167            continue;
2168        }
2169        let family = model_family_with_config(&config, &model.provider, id);
2170        if family == "unknown" || family == author_identity.family {
2171            continue;
2172        }
2173        diff_family_seen += 1;
2174        let lineage = model_lineage_with_config(&config, &model.provider, id);
2175        let candidate_identity = complementary_identity(
2176            id.clone(),
2177            model.provider.clone(),
2178            family,
2179            lineage,
2180            model_tier_with_config(&config, id),
2181            model.pricing.clone(),
2182        );
2183        if model
2184            .avoid_as_reviewer_for
2185            .iter()
2186            .any(|selector| refs_contain_selector(&author_refs, selector))
2187        {
2188            continue;
2189        }
2190        if exceeds_price_cap(
2191            author_identity.pricing.as_ref(),
2192            candidate_identity.pricing.as_ref(),
2193            options.max_price_multiplier,
2194        ) {
2195            rejected_by_price += 1;
2196            continue;
2197        }
2198        let score = reviewer_score(
2199            &options,
2200            &author_identity,
2201            &candidate_identity,
2202            model,
2203            &preferred_families,
2204        );
2205        candidates.push(ReviewerCandidate {
2206            identity: candidate_identity,
2207            score,
2208        });
2209    }
2210
2211    candidates.sort_by(|left, right| {
2212        right
2213            .score
2214            .partial_cmp(&left.score)
2215            .unwrap_or(std::cmp::Ordering::Equal)
2216            .then_with(|| left.identity.provider.cmp(&right.identity.provider))
2217            .then_with(|| left.identity.id.cmp(&right.identity.id))
2218    });
2219
2220    let Some(best) = candidates.into_iter().next() else {
2221        if rejected_by_price > 0 {
2222            let cap = options.max_price_multiplier.unwrap_or_default();
2223            return fallback(format!(
2224                "no different-family reviewer satisfied max_price_multiplier {cap}"
2225            ));
2226        }
2227        if diff_family_seen == 0 {
2228            return fallback(
2229                "no active serverless different-family reviewer is cataloged".to_string(),
2230            );
2231        }
2232        return fallback("all different-family reviewer candidates were excluded".to_string());
2233    };
2234
2235    let estimate = cost_estimate(
2236        best.identity.pricing.as_ref(),
2237        author_identity.pricing.as_ref(),
2238    );
2239    ComplementaryReviewerSelection {
2240        intent: options.intent.as_str().to_string(),
2241        reason: reviewer_reason(&author_identity, &best.identity, estimate.as_ref()),
2242        estimated_incremental_cost: estimate,
2243        author: author_identity,
2244        reviewer: best.identity,
2245        fallback: false,
2246        fallback_reason: None,
2247    }
2248}
2249
2250#[derive(Debug, Clone)]
2251struct ReviewerCandidate {
2252    identity: ComplementaryModelIdentity,
2253    score: f64,
2254}
2255
2256fn complementary_identity(
2257    id: String,
2258    provider: String,
2259    family: String,
2260    lineage: String,
2261    tier: String,
2262    pricing: Option<ModelPricing>,
2263) -> ComplementaryModelIdentity {
2264    ComplementaryModelIdentity {
2265        id,
2266        provider,
2267        family,
2268        lineage,
2269        tier,
2270        pricing,
2271    }
2272}
2273
2274fn reviewer_score(
2275    options: &ComplementaryReviewerOptions,
2276    author: &ComplementaryModelIdentity,
2277    candidate: &ComplementaryModelIdentity,
2278    model: &ModelDef,
2279    preferred_families: &[String],
2280) -> f64 {
2281    let candidate_refs = reviewer_match_refs(candidate);
2282    let mut score = 0.0;
2283    if let Some(rank) = preferred_families
2284        .iter()
2285        .position(|selector| refs_contain_selector(&candidate_refs, selector))
2286    {
2287        score += 1_000.0 - rank as f64;
2288    }
2289    if candidate.provider != author.provider {
2290        score += 100.0;
2291    }
2292    score += match tier_distance(&author.tier, &candidate.tier) {
2293        0 => 80.0,
2294        1 => 45.0,
2295        2 => 15.0,
2296        _ => 0.0,
2297    };
2298    for strength in intent_strengths(options.intent) {
2299        if model.strengths.iter().any(|tag| tag == strength) {
2300            score += 8.0;
2301        }
2302    }
2303    if model.capabilities.iter().any(|tag| tag == "tools") {
2304        score += 4.0;
2305    }
2306    if let (Some(author_total), Some(candidate_total)) = (
2307        pricing_total(author.pricing.as_ref()),
2308        pricing_total(candidate.pricing.as_ref()),
2309    ) {
2310        if author_total > 0.0 {
2311            let ratio = candidate_total / author_total;
2312            if ratio <= 1.0 {
2313                score += 20.0;
2314            }
2315            score -= (ratio - 1.0).abs().min(10.0) * 8.0;
2316        }
2317    }
2318    score
2319}
2320
2321fn intent_strengths(intent: ComplementaryReviewerIntent) -> &'static [&'static str] {
2322    match intent {
2323        ComplementaryReviewerIntent::Review => &["reasoning", "coding", "tool_use"],
2324        ComplementaryReviewerIntent::Critique => &["reasoning", "long_context", "tool_use"],
2325        ComplementaryReviewerIntent::PlanReview => {
2326            &["reasoning", "coding", "agentic", "long_context", "tool_use"]
2327        }
2328    }
2329}
2330
2331fn tier_distance(left: &str, right: &str) -> u8 {
2332    let left = tier_rank(left);
2333    let right = tier_rank(right);
2334    left.abs_diff(right)
2335}
2336
2337fn tier_rank(tier: &str) -> u8 {
2338    match tier {
2339        "small" => 0,
2340        "mid" => 1,
2341        "frontier" | "reasoning" => 2,
2342        _ => 1,
2343    }
2344}
2345
2346fn exceeds_price_cap(
2347    author_pricing: Option<&ModelPricing>,
2348    candidate_pricing: Option<&ModelPricing>,
2349    max_price_multiplier: Option<f64>,
2350) -> bool {
2351    let Some(max_price_multiplier) = max_price_multiplier else {
2352        return false;
2353    };
2354    let Some(author_total) = pricing_total(author_pricing) else {
2355        return false;
2356    };
2357    let Some(candidate_total) = pricing_total(candidate_pricing) else {
2358        return true;
2359    };
2360    author_total > 0.0 && candidate_total > author_total * max_price_multiplier
2361}
2362
2363fn cost_estimate(
2364    reviewer_pricing: Option<&ModelPricing>,
2365    author_pricing: Option<&ModelPricing>,
2366) -> Option<ComplementaryCostEstimate> {
2367    let reviewer_pricing = reviewer_pricing?;
2368    let total_per_mtok = reviewer_pricing.input_per_mtok + reviewer_pricing.output_per_mtok;
2369    let multiplier_vs_author = pricing_total(author_pricing)
2370        .filter(|author_total| *author_total > 0.0)
2371        .map(|author_total| total_per_mtok / author_total);
2372    Some(ComplementaryCostEstimate {
2373        input_per_mtok: reviewer_pricing.input_per_mtok,
2374        output_per_mtok: reviewer_pricing.output_per_mtok,
2375        total_per_mtok,
2376        multiplier_vs_author,
2377    })
2378}
2379
2380fn pricing_total(pricing: Option<&ModelPricing>) -> Option<f64> {
2381    pricing.map(|pricing| pricing.input_per_mtok + pricing.output_per_mtok)
2382}
2383
2384fn reviewer_reason(
2385    author: &ComplementaryModelIdentity,
2386    reviewer: &ComplementaryModelIdentity,
2387    estimate: Option<&ComplementaryCostEstimate>,
2388) -> String {
2389    let cost = estimate
2390        .and_then(|estimate| estimate.multiplier_vs_author)
2391        .map(|multiplier| format!("{multiplier:.2}x the author model price"))
2392        .unwrap_or_else(|| "price ratio unavailable".to_string());
2393    format!(
2394        "selected {} via {} because family {} differs from author family {}, tier {} matches author tier {}, and {}",
2395        reviewer.id,
2396        reviewer.provider,
2397        reviewer.family,
2398        author.family,
2399        reviewer.tier,
2400        author.tier,
2401        cost
2402    )
2403}
2404
2405fn reviewer_match_refs(identity: &ComplementaryModelIdentity) -> BTreeSet<String> {
2406    BTreeSet::from([
2407        identity.id.to_ascii_lowercase(),
2408        identity.provider.to_ascii_lowercase(),
2409        format!("{}/{}", identity.provider, identity.id).to_ascii_lowercase(),
2410        format!("{}:{}", identity.provider, identity.id).to_ascii_lowercase(),
2411        identity.family.to_ascii_lowercase(),
2412        identity.lineage.to_ascii_lowercase(),
2413    ])
2414}
2415
2416fn refs_contain_selector(refs: &BTreeSet<String>, selector: &str) -> bool {
2417    normalized_catalog_token(Some(selector))
2418        .or_else(|| Some(selector.trim().to_ascii_lowercase()))
2419        .is_some_and(|selector| refs.contains(&selector))
2420}
2421
2422/// Simple glob matching for patterns like "claude-*", "qwen/*", "ollama:*".
2423fn glob_match(pattern: &str, input: &str) -> bool {
2424    if let Some(prefix) = pattern.strip_suffix('*') {
2425        input.starts_with(prefix)
2426    } else if let Some(suffix) = pattern.strip_prefix('*') {
2427        input.ends_with(suffix)
2428    } else if pattern.contains('*') {
2429        let parts: Vec<&str> = pattern.split('*').collect();
2430        if parts.len() == 2 {
2431            input.starts_with(parts[0]) && input.ends_with(parts[1])
2432        } else {
2433            input == pattern
2434        }
2435    } else {
2436        input == pattern
2437    }
2438}
2439
2440fn dirs_or_home() -> Option<String> {
2441    crate::user_dirs::home_dir().map(|home| home.to_string_lossy().into_owned())
2442}
2443
2444/// Resolve the effective base URL for a provider, checking the `base_url_env`
2445/// override first, then falling back to the configured `base_url`.
2446pub fn resolve_base_url(pdef: &ProviderDef) -> String {
2447    if let Some(env_name) = &pdef.base_url_env {
2448        if let Ok(val) = std::env::var(env_name) {
2449            // Strip surrounding quotes that some .env parsers leave intact.
2450            let trimmed = val.trim().trim_matches('"').trim_matches('\'');
2451            if !trimmed.is_empty() {
2452                return trimmed.to_string();
2453            }
2454        }
2455    }
2456    pdef.base_url.clone()
2457}
2458
2459/// Embedded copy of generated `llm/providers.toml`, built from
2460/// `llm/catalog_sources/**/*.toml` by `harn providers build-config`.
2461/// Edit the fragments, not this generated snapshot or this string.
2462const EMBEDDED_PROVIDERS_TOML: &str = include_str!("llm/providers.toml");
2463
2464/// Parse the embedded generated `providers.toml` into the runtime
2465/// `ProvidersConfig`.
2466///
2467/// Hosts overlay this base via `HARN_PROVIDERS_CONFIG`,
2468/// `~/.config/harn/providers.toml`, `harn.toml`, package-manifest
2469/// `[llm]` sections, and per-run `set_user_overrides(...)`. The same
2470/// Serde shape applies at every layer, so there is exactly one schema to
2471/// keep coherent — no parallel Rust-literal catalog.
2472///
2473/// We `expect` on parse failure because the file is bundled into the
2474/// binary at compile time; a malformed embedded catalog is a build-time
2475/// invariant violation that should fail every test, not silently
2476/// degrade in production.
2477fn default_config() -> ProvidersConfig {
2478    parse_config_toml(EMBEDDED_PROVIDERS_TOML)
2479        .expect("embedded providers.toml must parse — invariant checked by harn-vm tests")
2480}
2481
2482#[cfg(test)]
2483fn merge_global_config(overlay: ProvidersConfig) -> ProvidersConfig {
2484    let mut config = default_config();
2485    config.merge_from(&overlay);
2486    config
2487}
2488
2489#[cfg(test)]
2490mod tests {
2491    use super::*;
2492
2493    fn reset_overrides() {
2494        clear_user_overrides();
2495    }
2496
2497    #[test]
2498    fn test_glob_match_prefix() {
2499        assert!(glob_match("claude-*", "claude-sonnet-4-20250514"));
2500        assert!(glob_match("gpt-*", "gpt-4o"));
2501        assert!(!glob_match("claude-*", "gpt-4o"));
2502    }
2503
2504    #[test]
2505    fn test_glob_match_suffix() {
2506        assert!(glob_match("*-latest", "llama3.2-latest"));
2507        assert!(!glob_match("*-latest", "llama3.2"));
2508    }
2509
2510    #[test]
2511    fn test_glob_match_middle() {
2512        assert!(glob_match("claude-*-latest", "claude-sonnet-latest"));
2513        assert!(!glob_match("claude-*-latest", "claude-sonnet-beta"));
2514    }
2515
2516    #[test]
2517    fn test_glob_match_exact() {
2518        assert!(glob_match("gpt-4o", "gpt-4o"));
2519        assert!(!glob_match("gpt-4o", "gpt-4o-mini"));
2520    }
2521
2522    #[test]
2523    fn test_infer_provider_from_defaults() {
2524        let _guard = crate::llm::env_lock().lock().expect("env lock");
2525        let prev_default_provider = std::env::var("HARN_DEFAULT_PROVIDER").ok();
2526        unsafe {
2527            std::env::remove_var("HARN_DEFAULT_PROVIDER");
2528        }
2529
2530        assert_eq!(infer_provider("claude-sonnet-4-20250514"), "anthropic");
2531        assert_eq!(infer_provider("gpt-4o"), "openai");
2532        assert_eq!(infer_provider("o1-preview"), "openai");
2533        assert_eq!(infer_provider("o3-mini"), "openai");
2534        assert_eq!(infer_provider("o4-mini"), "openai");
2535        assert_eq!(infer_provider("gemini-2.5-pro"), "gemini");
2536        assert_eq!(infer_provider("qwen/qwen3-coder"), "openrouter");
2537        assert_eq!(infer_provider("llama3.2:latest"), "ollama");
2538        assert_eq!(infer_provider("unknown-model"), "anthropic");
2539
2540        unsafe {
2541            match prev_default_provider {
2542                Some(value) => std::env::set_var("HARN_DEFAULT_PROVIDER", value),
2543                None => std::env::remove_var("HARN_DEFAULT_PROVIDER"),
2544            }
2545        }
2546    }
2547
2548    #[test]
2549    fn test_infer_provider_prefix_rules() {
2550        assert_eq!(infer_provider("local:gemma-4-e4b-it"), "ollama");
2551        assert_eq!(infer_provider("ollama:qwen3:30b-a3b"), "ollama");
2552        // Even when the id also contains `/`, the local transport prefix wins.
2553        assert_eq!(infer_provider("local:owner/model"), "ollama");
2554        assert_eq!(infer_provider("hf:Qwen/Qwen3.6-35B-A3B"), "huggingface");
2555    }
2556
2557    #[test]
2558    fn test_openrouter_inference_requires_one_slash() {
2559        let _guard = crate::llm::env_lock().lock().expect("env lock");
2560        let prev_default_provider = std::env::var("HARN_DEFAULT_PROVIDER").ok();
2561        unsafe {
2562            std::env::remove_var("HARN_DEFAULT_PROVIDER");
2563        }
2564
2565        assert_eq!(infer_provider("org/model"), "openrouter");
2566        assert_eq!(infer_provider("org/team/model"), "anthropic");
2567
2568        unsafe {
2569            match prev_default_provider {
2570                Some(value) => std::env::set_var("HARN_DEFAULT_PROVIDER", value),
2571                None => std::env::remove_var("HARN_DEFAULT_PROVIDER"),
2572            }
2573        }
2574    }
2575
2576    #[test]
2577    fn test_cerebras_inference_beats_openrouter_slash_fallback() {
2578        let _guard = crate::llm::env_lock().lock().expect("env lock");
2579        let prev_default_provider = std::env::var("HARN_DEFAULT_PROVIDER").ok();
2580        unsafe {
2581            std::env::remove_var("HARN_DEFAULT_PROVIDER");
2582        }
2583
2584        assert_eq!(infer_provider("cerebras/gpt-oss-120b"), "cerebras");
2585        assert_eq!(infer_provider("cerebras/zai-glm-4.7"), "cerebras");
2586        assert_eq!(infer_provider("cerebras/llama-3.3-70b"), "cerebras");
2587
2588        unsafe {
2589            match prev_default_provider {
2590                Some(value) => std::env::set_var("HARN_DEFAULT_PROVIDER", value),
2591                None => std::env::remove_var("HARN_DEFAULT_PROVIDER"),
2592            }
2593        }
2594    }
2595
2596    #[test]
2597    fn test_direct_catalog_model_id_resolves_to_catalog_provider() {
2598        // Bare model IDs that the embedded catalog hosts on Cerebras must
2599        // not be misrouted by the generic `gpt-*` / single-slash inference
2600        // fallbacks. Regression for harn#2142 (model-info routed
2601        // `gpt-oss-120b` to openai, breaking Burin TUI credential checks).
2602        let _guard = crate::llm::env_lock().lock().expect("env lock");
2603        let prev_default_provider = std::env::var("HARN_DEFAULT_PROVIDER").ok();
2604        unsafe {
2605            std::env::remove_var("HARN_DEFAULT_PROVIDER");
2606        }
2607
2608        for model in ["gpt-oss-120b", "zai-glm-4.7", "llama-3.3-70b"] {
2609            assert_eq!(
2610                infer_provider(model),
2611                "cerebras",
2612                "{model} should route to its catalog provider"
2613            );
2614            let resolved = resolve_model_info(model);
2615            assert_eq!(resolved.id, model);
2616            assert_eq!(resolved.provider, "cerebras");
2617        }
2618
2619        unsafe {
2620            match prev_default_provider {
2621                Some(value) => std::env::set_var("HARN_DEFAULT_PROVIDER", value),
2622                None => std::env::remove_var("HARN_DEFAULT_PROVIDER"),
2623            }
2624        }
2625    }
2626
2627    #[test]
2628    fn test_equivalent_model_catalog_entries_use_capability_compatible_routes() {
2629        reset_overrides();
2630
2631        assert_eq!(
2632            wire_model_id("groq/openai/gpt-oss-120b"),
2633            "openai/gpt-oss-120b"
2634        );
2635        assert_eq!(wire_model_id("gpt-oss-120b"), "gpt-oss-120b");
2636
2637        let equivalents = equivalent_model_catalog_entries("gpt-oss-120b");
2638        let ids = equivalents
2639            .iter()
2640            .map(|(id, _)| id.as_str())
2641            .collect::<Vec<_>>();
2642
2643        assert!(
2644            ids.contains(&"groq/openai/gpt-oss-120b"),
2645            "Cerebras GPT-OSS should surface the Groq serving variant"
2646        );
2647        assert!(
2648            !ids.contains(&"gpt-oss-120b"),
2649            "equivalence results should not include the source row"
2650        );
2651        assert!(equivalents.iter().all(|(_, model)| {
2652            model.equivalence_group.as_deref() == Some("openai-gpt-oss-120b")
2653        }));
2654    }
2655
2656    #[test]
2657    fn test_user_catalog_overlay_re_homes_model_provider() {
2658        // Users can re-home a built-in model by overlaying a catalog row;
2659        // the exact-match catalog lookup must honor overlays as well as the
2660        // embedded TOML.
2661        reset_overrides();
2662        let mut overlay = ProvidersConfig::default();
2663        overlay.models.insert(
2664            "gpt-4o".to_string(),
2665            ModelDef {
2666                name: "GPT-4o via OpenRouter".to_string(),
2667                provider: "openrouter".to_string(),
2668                context_window: 128_000,
2669                logical_model: None,
2670                equivalence_group: None,
2671                served_variant: None,
2672                wire_model: None,
2673                api_dialect: None,
2674                rate_limits: None,
2675                architecture: None,
2676                local_memory: None,
2677                runtime_context_window: None,
2678                stream_timeout: None,
2679                capabilities: Vec::new(),
2680                pricing: None,
2681                deprecated: false,
2682                deprecation_note: None,
2683                superseded_by: None,
2684                fast_mode: None,
2685                quality_tags: Vec::new(),
2686                availability: ModelAvailability::default(),
2687                tier: None,
2688                open_weight: None,
2689                strengths: Vec::new(),
2690                benchmarks: std::collections::BTreeMap::new(),
2691                family: None,
2692                lineage: None,
2693                complementary_with: Vec::new(),
2694                avoid_as_reviewer_for: Vec::new(),
2695            },
2696        );
2697        set_user_overrides(Some(overlay));
2698
2699        assert_eq!(infer_provider("gpt-4o"), "openrouter");
2700
2701        reset_overrides();
2702    }
2703
2704    #[test]
2705    fn test_resolve_model_info_normalizes_provider_prefixes() {
2706        let local = resolve_model_info("local:gemma-4-e4b-it");
2707        assert_eq!(local.id, "gemma-4-e4b-it");
2708        assert_eq!(local.provider, "ollama");
2709
2710        let ollama = resolve_model_info("ollama:qwen3:30b-a3b");
2711        assert_eq!(ollama.id, "qwen3:30b-a3b");
2712        assert_eq!(ollama.provider, "ollama");
2713
2714        let hf = resolve_model_info("hf:Qwen/Qwen3.6-35B-A3B");
2715        assert_eq!(hf.id, "Qwen/Qwen3.6-35B-A3B");
2716        assert_eq!(hf.provider, "huggingface");
2717
2718        let cerebras = resolve_model_info("cerebras/gpt-oss-120b");
2719        assert_eq!(cerebras.id, "gpt-oss-120b");
2720        assert_eq!(cerebras.provider, "cerebras");
2721
2722        let cerebras_glm = resolve_model_info("cerebras/zai-glm-4.7");
2723        assert_eq!(cerebras_glm.id, "zai-glm-4.7");
2724        assert_eq!(cerebras_glm.provider, "cerebras");
2725    }
2726
2727    #[test]
2728    fn test_model_tier_from_defaults() {
2729        // Tier is now self-declared per model row in providers.toml.
2730        // Models that match an entry use the declared value; unknown
2731        // model ids fall through to `tier_defaults.default` ("mid").
2732        assert_eq!(model_tier("claude-sonnet-4-20250514"), "frontier");
2733        assert_eq!(model_tier("gpt-4o"), "frontier");
2734        assert_eq!(model_tier("Qwen/Qwen3.5-9B"), "small");
2735        assert_eq!(model_tier("deepseek-v4-flash"), "mid");
2736        assert_eq!(model_tier("deepseek-v4-pro"), "frontier");
2737        assert_eq!(model_tier("MiniMax-M2.7"), "frontier");
2738        assert_eq!(model_tier("glm-5.1"), "frontier");
2739        // Unknown ids resolve to the default.
2740        assert_eq!(model_tier("definitely-not-a-real-model"), "mid");
2741    }
2742
2743    #[test]
2744    fn test_model_family_preserves_underlying_hosted_lineage() {
2745        assert_eq!(
2746            model_family("openrouter", "anthropic/claude-sonnet-4-6"),
2747            "anthropic-claude"
2748        );
2749        assert_eq!(
2750            model_family("openrouter", "google/gemini-2.5-flash"),
2751            "google-gemini"
2752        );
2753        assert_eq!(
2754            model_family("openrouter", "openai/o3-mini"),
2755            "openai-reasoning"
2756        );
2757        assert_eq!(model_lineage("openrouter", "openai/gpt-5.5"), "openai-gpt5");
2758        assert_eq!(
2759            model_lineage("openrouter", "openai/o3-mini"),
2760            "openai-reasoning"
2761        );
2762        assert_eq!(
2763            model_lineage("anthropic", "claude-opus-4-8"),
2764            "claude-opus-adaptive"
2765        );
2766        assert_eq!(model_lineage("llamacpp", "qwen3.6-35b-a3b"), "qwen3");
2767    }
2768
2769    #[test]
2770    fn test_complementary_reviewer_uses_different_family() {
2771        let selection = pick_complementary_reviewer(ComplementaryReviewerOptions {
2772            author_model: "claude-sonnet-4-6".to_string(),
2773            author_provider: None,
2774            intent: ComplementaryReviewerIntent::PlanReview,
2775            max_price_multiplier: Some(3.0),
2776        });
2777
2778        assert!(!selection.fallback, "{selection:?}");
2779        assert_eq!(selection.author.family, "anthropic-claude");
2780        assert_ne!(selection.reviewer.family, selection.author.family);
2781        assert_eq!(selection.reviewer.tier, "frontier");
2782        assert!(selection.estimated_incremental_cost.is_some());
2783    }
2784
2785    #[test]
2786    fn test_complementary_reviewer_falls_back_deterministically_on_price_cap() {
2787        let selection = pick_complementary_reviewer(ComplementaryReviewerOptions {
2788            author_model: "gpt-4o-mini".to_string(),
2789            author_provider: Some("openai".to_string()),
2790            intent: ComplementaryReviewerIntent::Review,
2791            max_price_multiplier: Some(0.01),
2792        });
2793
2794        assert!(selection.fallback, "{selection:?}");
2795        assert_eq!(selection.reviewer.id, "gpt-4o-mini");
2796        assert_eq!(selection.reviewer.family, selection.author.family);
2797        assert!(selection
2798            .fallback_reason
2799            .as_deref()
2800            .is_some_and(|reason| reason.contains("max_price_multiplier")));
2801    }
2802
2803    #[test]
2804    fn test_resolve_model_unknown_alias() {
2805        let (id, provider) = resolve_model("gpt-4o");
2806        assert_eq!(id, "gpt-4o");
2807        assert!(provider.is_none());
2808    }
2809
2810    #[test]
2811    fn test_provider_names() {
2812        let names = provider_names();
2813        assert!(names.len() >= 7);
2814        assert!(names.contains(&"anthropic".to_string()));
2815        assert!(names.contains(&"together".to_string()));
2816        assert!(names.contains(&"local".to_string()));
2817        assert!(names.contains(&"mlx".to_string()));
2818        assert!(names.contains(&"openai".to_string()));
2819        assert!(names.contains(&"ollama".to_string()));
2820        assert!(names.contains(&"bedrock".to_string()));
2821        assert!(names.contains(&"azure_openai".to_string()));
2822        assert!(names.contains(&"vertex".to_string()));
2823    }
2824
2825    #[test]
2826    fn global_provider_file_is_an_overlay_on_builtin_defaults() {
2827        let mut overlay = ProvidersConfig {
2828            default_provider: Some("ollama".to_string()),
2829            ..Default::default()
2830        };
2831        overlay.aliases.insert(
2832            "quickstart".to_string(),
2833            AliasDef {
2834                id: "llama3.2".to_string(),
2835                provider: "ollama".to_string(),
2836                tool_format: None,
2837            },
2838        );
2839
2840        let merged = merge_global_config(overlay);
2841
2842        assert_eq!(merged.default_provider.as_deref(), Some("ollama"));
2843        assert!(merged.providers.contains_key("anthropic"));
2844        assert!(merged.providers.contains_key("ollama"));
2845        assert_eq!(merged.aliases["quickstart"].id, "llama3.2");
2846    }
2847
2848    #[test]
2849    fn partial_provider_overlay_preserves_builtin_provider_metadata() {
2850        let overlay = parse_config_toml(
2851            r#"
2852            [providers.ollama]
2853            base_url = "http://localhost:11435"
2854            extra_headers = { "x-local" = "1" }
2855            "#,
2856        )
2857        .expect("provider overlay parses");
2858
2859        let merged = merge_global_config(overlay);
2860        let ollama = merged
2861            .providers
2862            .get("ollama")
2863            .expect("ollama remains configured");
2864
2865        assert_eq!(ollama.base_url, "http://localhost:11435");
2866        assert_eq!(ollama.auth_style, "none");
2867        assert_eq!(ollama.chat_endpoint, "/api/chat");
2868        assert_eq!(ollama.completion_endpoint.as_deref(), Some("/api/generate"));
2869        assert_eq!(ollama.cost_per_1k_in, Some(0.0));
2870        assert_eq!(ollama.cost_per_1k_out, Some(0.0));
2871        assert_eq!(
2872            ollama
2873                .healthcheck
2874                .as_ref()
2875                .and_then(|healthcheck| healthcheck.path.as_deref()),
2876            Some("/api/tags")
2877        );
2878        assert_eq!(
2879            ollama.extra_headers.get("x-local").map(String::as_str),
2880            Some("1")
2881        );
2882    }
2883
2884    #[test]
2885    fn partial_provider_overlay_can_explicitly_replace_default_auth_style() {
2886        let overlay = parse_config_toml(
2887            r#"
2888            [providers.ollama]
2889            auth_style = "bearer"
2890            auth_env = "OLLAMA_API_KEY"
2891            "#,
2892        )
2893        .expect("provider overlay parses");
2894
2895        let merged = merge_global_config(overlay);
2896        let ollama = merged
2897            .providers
2898            .get("ollama")
2899            .expect("ollama remains configured");
2900
2901        assert_eq!(ollama.auth_style, "bearer");
2902        assert_eq!(auth_env_names(&ollama.auth_env), vec!["OLLAMA_API_KEY"]);
2903        assert_eq!(ollama.chat_endpoint, "/api/chat");
2904    }
2905
2906    #[test]
2907    fn test_resolve_tier_model_default_aliases() {
2908        // Exercise the alias-resolution machinery, not the specific catalog
2909        // value: the model under each tier alias evolves as the embedded
2910        // providers.toml is updated. The invariants worth pinning are the
2911        // provider routing + catalog-registration of the resolved model.
2912        let (model, provider) = resolve_tier_model("frontier", None)
2913            .expect("frontier alias must resolve from the embedded catalog");
2914        assert_eq!(provider, "anthropic");
2915        assert!(
2916            model_catalog_entry(&model)
2917                .is_some_and(|entry| entry.provider == "anthropic" && !entry.deprecated),
2918            "frontier alias must point at a registered, non-deprecated anthropic model (got {model})"
2919        );
2920
2921        let (model, provider) = resolve_tier_model("small", None)
2922            .expect("small alias must resolve from the embedded catalog");
2923        assert!(
2924            [
2925                "openrouter",
2926                "huggingface",
2927                "local",
2928                "llamacpp",
2929                "mlx",
2930                "ollama"
2931            ]
2932            .contains(&provider.as_str()),
2933            "small tier should resolve to an open-weight provider (got {provider} / {model})"
2934        );
2935    }
2936
2937    #[test]
2938    fn test_resolve_tier_model_prefers_provider_scoped_aliases() {
2939        // tier/<provider> takes precedence over generic tier when the
2940        // caller scopes by provider. Don't pin the specific model — the
2941        // catalog evolves.
2942        let (model, provider) = resolve_tier_model("mid", Some("openai"))
2943            .expect("mid tier scoped to openai must resolve");
2944        assert_eq!(provider, "openai");
2945        assert!(
2946            model_catalog_entry(&model).is_some(),
2947            "mid/openai alias must point at a registered model (got {model})"
2948        );
2949    }
2950
2951    #[test]
2952    fn test_provider_config_anthropic() {
2953        let pdef = provider_config("anthropic").unwrap();
2954        assert_eq!(pdef.auth_style, "header");
2955        assert_eq!(pdef.auth_header.as_deref(), Some("x-api-key"));
2956    }
2957
2958    #[test]
2959    fn test_provider_config_mlx() {
2960        let pdef = provider_config("mlx").unwrap();
2961        assert_eq!(pdef.base_url, "http://127.0.0.1:8002");
2962        assert_eq!(pdef.base_url_env.as_deref(), Some("MLX_BASE_URL"));
2963        assert_eq!(
2964            pdef.healthcheck.unwrap().path.as_deref(),
2965            Some("/v1/models")
2966        );
2967
2968        let (model, provider) = resolve_model("mlx-qwen36-27b");
2969        assert_eq!(model, "unsloth/Qwen3.6-27B-UD-MLX-4bit");
2970        assert_eq!(provider.as_deref(), Some("mlx"));
2971    }
2972
2973    #[test]
2974    fn test_enterprise_provider_defaults_and_inference() {
2975        let bedrock = provider_config("bedrock").unwrap();
2976        assert_eq!(bedrock.auth_style, "aws_sigv4");
2977        assert_eq!(bedrock.base_url_env.as_deref(), Some("BEDROCK_BASE_URL"));
2978        assert_eq!(
2979            infer_provider("anthropic.claude-3-5-sonnet-20240620-v1:0"),
2980            "bedrock"
2981        );
2982        assert_eq!(infer_provider("meta.llama3-70b-instruct-v1:0"), "bedrock");
2983
2984        let azure = provider_config("azure_openai").unwrap();
2985        assert_eq!(azure.base_url_env.as_deref(), Some("AZURE_OPENAI_ENDPOINT"));
2986        assert_eq!(
2987            auth_env_names(&azure.auth_env),
2988            vec![
2989                "AZURE_OPENAI_API_KEY".to_string(),
2990                "AZURE_OPENAI_AD_TOKEN".to_string(),
2991                "AZURE_OPENAI_BEARER_TOKEN".to_string(),
2992            ]
2993        );
2994
2995        let vertex = provider_config("vertex").unwrap();
2996        assert_eq!(vertex.base_url, "https://aiplatform.googleapis.com/v1");
2997        assert_eq!(infer_provider("gemini-1.5-pro-002"), "gemini");
2998    }
2999
3000    #[test]
3001    fn test_default_provider_env_override_for_unknown_model() {
3002        let _guard = crate::llm::env_lock().lock().expect("env lock");
3003        let prev_default_provider = std::env::var("HARN_DEFAULT_PROVIDER").ok();
3004        unsafe {
3005            std::env::set_var("HARN_DEFAULT_PROVIDER", "openai");
3006        }
3007
3008        let inference = infer_provider_detail("unknown-model");
3009
3010        unsafe {
3011            match prev_default_provider {
3012                Some(value) => std::env::set_var("HARN_DEFAULT_PROVIDER", value),
3013                None => std::env::remove_var("HARN_DEFAULT_PROVIDER"),
3014            }
3015        }
3016
3017        assert_eq!(inference.provider, "openai");
3018        assert_eq!(
3019            inference.source,
3020            crate::llm::provider::ProviderInferenceSource::DefaultFallback
3021        );
3022    }
3023
3024    #[test]
3025    fn test_unknown_model_family_ignores_default_provider_fallback() {
3026        let _guard = crate::llm::env_lock().lock().expect("env lock");
3027        let prev_default_provider = std::env::var("HARN_DEFAULT_PROVIDER").ok();
3028        unsafe {
3029            std::env::set_var("HARN_DEFAULT_PROVIDER", "ollama");
3030        }
3031
3032        let unknown = resolve_model_info("mystery-model-xyz");
3033        let known_family = resolve_model_info("deepseek-mystery-model");
3034
3035        unsafe {
3036            match prev_default_provider {
3037                Some(value) => std::env::set_var("HARN_DEFAULT_PROVIDER", value),
3038                None => std::env::remove_var("HARN_DEFAULT_PROVIDER"),
3039            }
3040        }
3041
3042        assert_eq!(unknown.provider, "ollama");
3043        assert_eq!(unknown.family, "unknown");
3044        assert_eq!(unknown.lineage, "unknown");
3045        assert_eq!(known_family.family, "deepseek");
3046        assert_eq!(known_family.lineage, "deepseek");
3047    }
3048
3049    #[test]
3050    fn test_resolve_base_url_no_env() {
3051        let pdef = ProviderDef {
3052            base_url: "https://example.com".to_string(),
3053            ..Default::default()
3054        };
3055        assert_eq!(resolve_base_url(&pdef), "https://example.com");
3056    }
3057
3058    #[test]
3059    fn test_default_config_roundtrip() {
3060        let config = default_config();
3061        assert!(!config.providers.is_empty());
3062        assert!(!config.inference_rules.is_empty());
3063        // Tier is now declared on each model row; tier_rules is allowed
3064        // to be empty (the rule table is a legacy fallback only).
3065        assert_eq!(config.tier_defaults.default, "mid");
3066        // At least the new open-weight frontiers should have explicit tiers.
3067        let frontiers = config
3068            .models
3069            .iter()
3070            .filter(|(_, m)| m.tier.as_deref() == Some("frontier"))
3071            .count();
3072        assert!(
3073            frontiers >= 4,
3074            "expected at least 4 frontier-tagged models, got {frontiers}"
3075        );
3076    }
3077
3078    #[test]
3079    fn test_local_ollama_catalog_metadata() {
3080        reset_overrides();
3081
3082        let devstral =
3083            model_catalog_entry("devstral-small-2:24b").expect("devstral-small-2 catalog entry");
3084        assert_eq!(devstral.context_window, 262_144);
3085        assert!(!devstral.capabilities.iter().any(|cap| cap == "vision"));
3086
3087        let gemma4 = model_catalog_entry("gemma4:26b").expect("gemma4 catalog entry");
3088        assert_eq!(gemma4.context_window, 262_144);
3089        assert!(gemma4.capabilities.iter().any(|cap| cap == "vision"));
3090    }
3091
3092    #[test]
3093    fn test_external_config_overlays_default_catalog() {
3094        let mut config = default_config();
3095        let mut overlay = ProvidersConfig {
3096            default_provider: Some("ollama".to_string()),
3097            ..Default::default()
3098        };
3099        overlay.providers.insert(
3100            "custom".to_string(),
3101            ProviderDef {
3102                base_url: "https://llm.example.test/v1".to_string(),
3103                chat_endpoint: "/chat/completions".to_string(),
3104                ..Default::default()
3105            },
3106        );
3107
3108        config.merge_from(&overlay);
3109
3110        assert_eq!(config.default_provider.as_deref(), Some("ollama"));
3111        assert!(config.providers.contains_key("custom"));
3112        assert!(config.providers.contains_key("anthropic"));
3113        assert!(config.providers.contains_key("ollama"));
3114    }
3115
3116    #[test]
3117    fn test_model_params_empty() {
3118        let params = model_params("claude-sonnet-4-20250514");
3119        assert!(params.is_empty());
3120    }
3121
3122    #[test]
3123    fn test_user_overrides_add_provider_and_alias() {
3124        reset_overrides();
3125        let mut overlay = ProvidersConfig::default();
3126        overlay.providers.insert(
3127            "acme".to_string(),
3128            ProviderDef {
3129                base_url: "https://llm.acme.test/v1".to_string(),
3130                chat_endpoint: "/chat/completions".to_string(),
3131                ..Default::default()
3132            },
3133        );
3134        overlay.aliases.insert(
3135            "acme-fast".to_string(),
3136            AliasDef {
3137                id: "acme/model-fast".to_string(),
3138                provider: "acme".to_string(),
3139                tool_format: Some("native".to_string()),
3140            },
3141        );
3142        set_user_overrides(Some(overlay));
3143
3144        let (model, provider) = resolve_model("acme-fast");
3145        assert_eq!(model, "acme/model-fast");
3146        assert_eq!(provider.as_deref(), Some("acme"));
3147        assert!(provider_names().contains(&"acme".to_string()));
3148        assert_eq!(
3149            provider_config("acme").map(|provider| provider.base_url),
3150            Some("https://llm.acme.test/v1".to_string())
3151        );
3152
3153        reset_overrides();
3154    }
3155
3156    #[test]
3157    fn test_default_tool_format_uses_capability_matrix() {
3158        reset_overrides();
3159
3160        assert_eq!(
3161            default_tool_format("qwen3.6-35b-a3b-ud-q4-k-xl", "llamacpp"),
3162            "native"
3163        );
3164        assert_eq!(
3165            default_tool_format("devstral-small-2:24b", "ollama"),
3166            "text"
3167        );
3168        assert_eq!(
3169            default_tool_format("ollama-devstral-small-2-native", "ollama"),
3170            "native"
3171        );
3172        // vLLM/SGLang-served Gemma 4 exposes OpenAI-compatible function calling,
3173        // so the local route declares native tools (matching every hosted gemma-4
3174        // sibling) rather than degrading to the text tool format.
3175        assert_eq!(default_tool_format("gemma-4-26b-a4b-it", "local"), "native");
3176        assert_eq!(
3177            default_tool_format("deepseek/deepseek-v3.2", "openrouter"),
3178            "text"
3179        );
3180        assert_eq!(
3181            default_tool_format("qwen/qwen3-coder-flash", "openrouter"),
3182            "text"
3183        );
3184    }
3185
3186    #[test]
3187    fn test_user_overrides_add_model_catalog_pricing_and_qc_defaults() {
3188        reset_overrides();
3189        let mut overlay = ProvidersConfig::default();
3190        overlay.models.insert(
3191            "acme/model-fast".to_string(),
3192            ModelDef {
3193                name: "Acme Fast".to_string(),
3194                provider: "acme".to_string(),
3195                context_window: 65_536,
3196                logical_model: None,
3197                equivalence_group: None,
3198                served_variant: None,
3199                wire_model: None,
3200                api_dialect: None,
3201                rate_limits: None,
3202                architecture: None,
3203                local_memory: None,
3204                runtime_context_window: None,
3205                stream_timeout: Some(42.0),
3206                capabilities: vec!["tools".to_string(), "streaming".to_string()],
3207                pricing: Some(ModelPricing {
3208                    input_per_mtok: 1.25,
3209                    output_per_mtok: 2.5,
3210                    cache_read_per_mtok: Some(0.25),
3211                    cache_write_per_mtok: None,
3212                }),
3213                deprecated: false,
3214                deprecation_note: None,
3215                superseded_by: None,
3216                fast_mode: None,
3217                quality_tags: Vec::new(),
3218                availability: ModelAvailability::default(),
3219                tier: None,
3220                open_weight: None,
3221                strengths: Vec::new(),
3222                benchmarks: std::collections::BTreeMap::new(),
3223                family: None,
3224                lineage: None,
3225                complementary_with: Vec::new(),
3226                avoid_as_reviewer_for: Vec::new(),
3227            },
3228        );
3229        overlay
3230            .qc_defaults
3231            .insert("acme".to_string(), "acme/model-cheap".to_string());
3232        set_user_overrides(Some(overlay));
3233
3234        let entry = model_catalog_entry("acme/model-fast").expect("catalog entry");
3235        assert_eq!(entry.context_window, 65_536);
3236        assert_eq!(
3237            entry.capabilities,
3238            vec!["streaming".to_string(), "tools".to_string()]
3239        );
3240        assert_eq!(
3241            entry.pricing.as_ref().map(|pricing| pricing.input_per_mtok),
3242            Some(1.25)
3243        );
3244        assert_eq!(
3245            pricing_per_1k_for("acme", "acme/model-fast"),
3246            Some((0.00125, 0.0025))
3247        );
3248        assert_eq!(
3249            qc_default_model("acme").as_deref(),
3250            Some("acme/model-cheap")
3251        );
3252
3253        reset_overrides();
3254    }
3255
3256    #[test]
3257    fn test_user_overrides_prepend_inference_rules() {
3258        reset_overrides();
3259        let mut overlay = ProvidersConfig::default();
3260        overlay.inference_rules.push(InferenceRule {
3261            pattern: Some("internal-*".to_string()),
3262            contains: None,
3263            exact: None,
3264            provider: "openai".to_string(),
3265        });
3266        set_user_overrides(Some(overlay));
3267
3268        assert_eq!(infer_provider("internal-foo"), "openai");
3269
3270        reset_overrides();
3271    }
3272
3273    // ── Embedded providers.toml invariants ───────────────────────────────────
3274    // These tests pin properties of the *system* — TOML parses, every
3275    // alias resolves, every deprecated model has a note — without
3276    // pinning specific catalog values. They survive future catalog
3277    // churn and surface real schema breakage.
3278
3279    #[test]
3280    fn embedded_providers_toml_parses_and_is_not_trivially_empty() {
3281        let config = default_config();
3282        assert!(
3283            config.providers.len() >= 10,
3284            "expected >=10 providers in embedded catalog, got {}",
3285            config.providers.len()
3286        );
3287        assert!(
3288            config.models.len() >= 20,
3289            "expected >=20 models in embedded catalog, got {}",
3290            config.models.len()
3291        );
3292        assert!(
3293            config.aliases.len() >= 15,
3294            "expected >=15 aliases in embedded catalog, got {}",
3295            config.aliases.len()
3296        );
3297        assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
3298    }
3299
3300    #[test]
3301    fn embedded_catalog_every_deprecated_model_has_a_note() {
3302        let config = default_config();
3303        let offenders: Vec<&str> = config
3304            .models
3305            .iter()
3306            .filter(|(_, model)| {
3307                model.deprecated
3308                    && model
3309                        .deprecation_note
3310                        .as_deref()
3311                        .unwrap_or("")
3312                        .trim()
3313                        .is_empty()
3314            })
3315            .map(|(id, _)| id.as_str())
3316            .collect();
3317        assert!(
3318            offenders.is_empty(),
3319            "deprecated models missing a deprecation_note: {offenders:?}"
3320        );
3321    }
3322
3323    #[test]
3324    fn embedded_cerebras_catalog_separates_public_and_dedicated_routes() {
3325        let config = default_config();
3326        for id in ["gpt-oss-120b", "zai-glm-4.7"] {
3327            let model = config.models.get(id).expect("current public Cerebras row");
3328            assert_eq!(model.provider, "cerebras");
3329            assert_eq!(model.availability, ModelAvailability::Serverless);
3330            assert!(!model.deprecated);
3331        }
3332
3333        let llama = config
3334            .models
3335            .get("llama-3.3-70b")
3336            .expect("legacy Cerebras row");
3337        assert_eq!(llama.provider, "cerebras");
3338        assert_eq!(llama.availability, ModelAvailability::Dedicated);
3339        assert!(llama.deprecated);
3340    }
3341
3342    #[test]
3343    fn embedded_catalog_every_model_targets_a_registered_provider() {
3344        let config = default_config();
3345        let known: std::collections::BTreeSet<&str> =
3346            config.providers.keys().map(String::as_str).collect();
3347        let orphans: Vec<(&str, &str)> = config
3348            .models
3349            .iter()
3350            .filter(|(_, model)| !known.contains(model.provider.as_str()))
3351            .map(|(id, model)| (id.as_str(), model.provider.as_str()))
3352            .collect();
3353        assert!(
3354            orphans.is_empty(),
3355            "models reference unknown providers: {orphans:?}"
3356        );
3357    }
3358
3359    #[test]
3360    fn embedded_catalog_every_alias_targets_a_registered_provider() {
3361        let config = default_config();
3362        let known: std::collections::BTreeSet<&str> =
3363            config.providers.keys().map(String::as_str).collect();
3364        let orphans: Vec<(&str, &str)> = config
3365            .aliases
3366            .iter()
3367            .filter(|(_, alias)| !known.contains(alias.provider.as_str()))
3368            .map(|(name, alias)| (name.as_str(), alias.provider.as_str()))
3369            .collect();
3370        assert!(
3371            orphans.is_empty(),
3372            "aliases reference unknown providers: {orphans:?}"
3373        );
3374    }
3375
3376    #[test]
3377    fn embedded_catalog_every_qc_default_targets_a_known_model() {
3378        let config = default_config();
3379        let orphans: Vec<(&str, &str)> = config
3380            .qc_defaults
3381            .iter()
3382            .filter(|(_, model_id)| !config.models.contains_key(model_id.as_str()))
3383            .map(|(provider, model_id)| (provider.as_str(), model_id.as_str()))
3384            .collect();
3385        assert!(
3386            orphans.is_empty(),
3387            "qc_defaults reference unknown models: {orphans:?}"
3388        );
3389    }
3390
3391    #[test]
3392    fn embedded_catalog_pricing_rates_are_non_negative() {
3393        let config = default_config();
3394        for (id, model) in &config.models {
3395            let Some(pricing) = &model.pricing else {
3396                continue;
3397            };
3398            assert!(
3399                pricing.input_per_mtok >= 0.0 && pricing.output_per_mtok >= 0.0,
3400                "{id}: negative pricing — in={} out={}",
3401                pricing.input_per_mtok,
3402                pricing.output_per_mtok
3403            );
3404            if let Some(rate) = pricing.cache_read_per_mtok {
3405                assert!(rate >= 0.0, "{id}: negative cache_read rate {rate}");
3406            }
3407            if let Some(rate) = pricing.cache_write_per_mtok {
3408                assert!(rate >= 0.0, "{id}: negative cache_write rate {rate}");
3409            }
3410        }
3411    }
3412
3413    #[test]
3414    fn model_availability_parses_known_strings() {
3415        assert_eq!(
3416            ModelAvailability::parse("serverless"),
3417            Some(ModelAvailability::Serverless)
3418        );
3419        assert_eq!(
3420            ModelAvailability::parse("dedicated"),
3421            Some(ModelAvailability::Dedicated)
3422        );
3423        assert_eq!(
3424            ModelAvailability::parse("unknown"),
3425            Some(ModelAvailability::Unknown)
3426        );
3427        assert_eq!(ModelAvailability::parse("provisioned"), None);
3428        for value in [
3429            ModelAvailability::Serverless,
3430            ModelAvailability::Dedicated,
3431            ModelAvailability::Unknown,
3432        ] {
3433            assert_eq!(ModelAvailability::parse(value.as_str()), Some(value));
3434        }
3435    }
3436
3437    #[test]
3438    fn embedded_catalog_marks_together_dedicated_route_as_dedicated() {
3439        let config = default_config();
3440        let model = config
3441            .models
3442            .get("Qwen/Qwen3-Coder-Next-FP8")
3443            .expect("Together Qwen3 Coder Next FP8 is cataloged");
3444        assert_eq!(model.provider, "together");
3445        assert_eq!(model.availability, ModelAvailability::Dedicated);
3446    }
3447
3448    #[test]
3449    fn embedded_catalog_dedicated_models_are_not_targeted_by_tier_aliases() {
3450        // A dedicated-only model behind a tier alias would silently fail
3451        // every serverless caller; the catalog must keep those routes
3452        // separated.
3453        let config = default_config();
3454        let dedicated: std::collections::BTreeSet<(&str, &str)> = config
3455            .models
3456            .iter()
3457            .filter(|(_, model)| model.availability == ModelAvailability::Dedicated)
3458            .map(|(id, model)| (model.provider.as_str(), id.as_str()))
3459            .collect();
3460        for (name, alias) in &config.aliases {
3461            if matches!(
3462                name.as_str(),
3463                "frontier"
3464                    | "mid"
3465                    | "small"
3466                    | "tier/frontier"
3467                    | "tier/mid"
3468                    | "tier/small"
3469                    | "sonnet"
3470                    | "opus"
3471                    | "haiku"
3472            ) {
3473                assert!(
3474                    !dedicated.contains(&(alias.provider.as_str(), alias.id.as_str())),
3475                    "tier alias `{name}` targets dedicated-only route `{}/{}`",
3476                    alias.provider,
3477                    alias.id,
3478                );
3479            }
3480        }
3481    }
3482
3483    #[test]
3484    fn embedded_catalog_tier_aliases_resolve_to_active_models() {
3485        // The three canonical tier aliases (frontier / mid / small) MUST
3486        // resolve to non-deprecated catalog entries; a default that
3487        // routes the loop into a sunsetted model is a release blocker.
3488        for alias in ["frontier", "mid", "small"] {
3489            let (model, _provider) = resolve_tier_model(alias, None)
3490                .unwrap_or_else(|| panic!("tier alias `{alias}` must resolve"));
3491            let entry = model_catalog_entry(&model).unwrap_or_else(|| {
3492                panic!("tier alias `{alias}` -> `{model}` must be a registered catalog entry")
3493            });
3494            assert!(
3495                !entry.deprecated,
3496                "tier alias `{alias}` resolves to deprecated model `{model}` ({:?})",
3497                entry.deprecation_note
3498            );
3499        }
3500    }
3501
3502    #[test]
3503    fn opus_alias_tracks_claude_opus_4_8_with_fast_mode() {
3504        // The `opus` alias must follow the newest Opus release, and that
3505        // release advertises its (off-by-default) fast-mode tier.
3506        let (model, provider) = resolve_model("opus");
3507        assert_eq!(model, "claude-opus-4-8");
3508        assert_eq!(provider.as_deref(), Some("anthropic"));
3509
3510        let opus48 = model_catalog_entry("claude-opus-4-8").expect("opus 4.8 catalog entry");
3511        assert!(!opus48.deprecated, "newest Opus must not be deprecated");
3512        let fast = opus48.fast_mode.expect("opus 4.8 advertises fast mode");
3513        assert_eq!(fast.param, "speed");
3514        assert_eq!(fast.value, "fast");
3515        assert_eq!(fast.status.as_deref(), Some("research_preview"));
3516        let fast_pricing = fast.pricing.expect("fast mode carries premium pricing");
3517        let standard = opus48.pricing.expect("opus 4.8 standard pricing");
3518        assert!(
3519            fast_pricing.input_per_mtok > standard.input_per_mtok,
3520            "fast mode must be premium-priced relative to standard"
3521        );
3522    }
3523
3524    #[test]
3525    fn superseded_opus_models_point_at_claude_opus_4_8() {
3526        // Earlier Opus rows are deprecated and carry a structured
3527        // `superseded_by` pointer to the current flagship.
3528        for model in ["claude-opus-4-7", "claude-opus-4-6"] {
3529            let entry =
3530                model_catalog_entry(model).unwrap_or_else(|| panic!("{model} catalog entry"));
3531            assert!(entry.deprecated, "{model} should be deprecated");
3532            assert_eq!(
3533                entry.superseded_by.as_deref(),
3534                Some("claude-opus-4-8"),
3535                "{model} should be superseded by claude-opus-4-8"
3536            );
3537        }
3538    }
3539
3540    #[test]
3541    fn gpt_5_5_fast_mode_rides_service_tier() {
3542        // Fast mode is provider-agnostic: OpenAI exposes it through the
3543        // `service_tier` knob rather than Anthropic's `speed`.
3544        let entry = model_catalog_entry("gpt-5.5").expect("gpt-5.5 catalog entry");
3545        let fast = entry.fast_mode.expect("gpt-5.5 advertises a fast tier");
3546        assert_eq!(fast.param, "service_tier");
3547        assert_eq!(fast.status.as_deref(), Some("ga"));
3548    }
3549}