Skip to main content

harn_vm/llm_config/
catalog.rs

1//! Catalog query surface: provider/model lookups, per-role and per-model
2//! parameter defaults, pricing, capability tags, tool-format resolution, and
3//! tier candidate enumeration.
4use std::collections::BTreeMap;
5
6use super::*;
7
8use harn_glob::match_name as glob_match;
9
10const LOGICAL_MODEL_DEFAULT_PREFIX: &str = "logical:";
11const MODEL_DEFAULT_UNSET_KEY: &str = "_unset";
12
13/// True for `[model_defaults."logical:<model>"]` selectors, which name one
14/// exact publisher-level logical model rather than globbing serving routes.
15/// The two selector kinds resolve through different lookups, so every site
16/// that must tell them apart asks here instead of re-testing the prefix.
17pub(crate) fn is_logical_model_selector(selector: &str) -> bool {
18    selector.starts_with(LOGICAL_MODEL_DEFAULT_PREFIX)
19}
20
21/// Get provider config for resolving base_url, auth, etc.
22pub fn provider_config(name: &str) -> Option<ProviderDef> {
23    let mut provider = effective_config().providers.get(name).cloned()?;
24    if let Some(base_url) = runtime_provider_endpoint(name) {
25        // The endpoint was host-verified for this execution. Clear catalog
26        // selectors only on this clone so every transport path resolves the
27        // same endpoint without making runtime state serializable or public.
28        provider.base_url = base_url;
29        provider.base_url_env = None;
30        provider.region_env = None;
31    }
32    Some(provider)
33}
34
35pub fn provider_protocol(name: &str) -> Option<String> {
36    provider_config(name).and_then(|def| def.protocol)
37}
38
39pub fn provider_uses_acp(name: &str) -> bool {
40    provider_protocol(name)
41        .as_deref()
42        .is_some_and(|protocol| protocol.eq_ignore_ascii_case("acp"))
43}
44
45/// Get model-specific default parameters (temperature, etc.).
46/// Matches glob patterns in model_defaults keys.
47pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
48    let config = effective_config();
49    matching_model_params(&config, model_id)
50}
51
52fn matching_model_params(
53    config: &ProvidersConfig,
54    model_id: &str,
55) -> BTreeMap<String, toml::Value> {
56    let mut params = BTreeMap::new();
57    apply_matching_model_params(config, model_id, &mut params);
58    params
59}
60
61fn apply_matching_model_params(
62    config: &ProvidersConfig,
63    model_id: &str,
64    params: &mut BTreeMap<String, toml::Value>,
65) {
66    for (pattern, defaults) in &config.model_defaults {
67        if !is_logical_model_selector(pattern) && glob_match(pattern, model_id) {
68            apply_model_param_layer(params, defaults);
69        }
70    }
71}
72
73fn apply_model_param_layer(
74    params: &mut BTreeMap<String, toml::Value>,
75    defaults: &BTreeMap<String, toml::Value>,
76) {
77    for (key, value) in defaults {
78        if key != MODEL_DEFAULT_UNSET_KEY {
79            params.insert(key.clone(), value.clone());
80        }
81    }
82    if let Some(keys) = defaults
83        .get(MODEL_DEFAULT_UNSET_KEY)
84        .and_then(toml::Value::as_array)
85    {
86        for key in keys.iter().filter_map(toml::Value::as_str) {
87            params.remove(key);
88        }
89    }
90}
91
92/// Get generation defaults for one concrete serving route.
93///
94/// Publisher/model defaults use an exact `logical:<logical_model>` selector.
95/// Route-id patterns then override them, preserving the existing precedence
96/// where a provider-qualified pattern wins over a bare wire-model pattern.
97pub fn model_params_for_route(provider: &str, model_id: &str) -> BTreeMap<String, toml::Value> {
98    let config = effective_config();
99    model_params_for_route_with_config(&config, provider, model_id)
100}
101
102/// Return the Harn-validated generation defaults that are safe to persist in
103/// an execution receipt.
104///
105/// Route-specific overlays remain intentionally free-form so operators can
106/// configure provider-specific request parameters. Those fields must still
107/// influence inference, but they are not part of Harn's stable, secret-free
108/// receipt contract. Keep this filter beside Harn's generation validator so hosts do
109/// not duplicate the generation-default schema.
110pub fn generation_defaults_for_route(
111    provider: &str,
112    model_id: &str,
113) -> BTreeMap<String, toml::Value> {
114    model_params_for_route(provider, model_id)
115        .into_iter()
116        .filter(|(key, value)| is_valid_generation_default(key, value))
117        .collect()
118}
119
120pub(crate) fn model_params_for_route_with_config(
121    config: &ProvidersConfig,
122    provider: &str,
123    model_id: &str,
124) -> BTreeMap<String, toml::Value> {
125    let normalized_id = normalize_model_id(model_id);
126    let route = config
127        .models
128        .get_key_value(model_id)
129        .filter(|(_, model)| model.provider == provider)
130        .or_else(|| {
131            config
132                .models
133                .get_key_value(&normalized_id)
134                .filter(|(_, model)| model.provider == provider)
135        })
136        .or_else(|| {
137            config.models.iter().find(|(_, model)| {
138                model.provider == provider
139                    && model
140                        .wire_model
141                        .as_deref()
142                        .is_some_and(|wire| wire == model_id || wire == normalized_id.as_str())
143            })
144        });
145
146    let mut params = route
147        .and_then(|(_, model)| model.logical_model.as_deref())
148        .and_then(|logical_model| {
149            config
150                .model_defaults
151                .get(&format!("{LOGICAL_MODEL_DEFAULT_PREFIX}{logical_model}"))
152        })
153        .map(|defaults| {
154            let mut params = BTreeMap::new();
155            apply_model_param_layer(&mut params, defaults);
156            params
157        })
158        .unwrap_or_default();
159
160    let mut identities = vec![model_id.to_string()];
161    if normalized_id != model_id {
162        identities.push(normalized_id);
163    }
164    if let Some((catalog_id, model)) = route {
165        for identity in [Some(catalog_id.as_str()), model.wire_model.as_deref()]
166            .into_iter()
167            .flatten()
168        {
169            if !identities.iter().any(|known| known == identity) {
170                identities.push(identity.to_string());
171            }
172        }
173    }
174    for identity in &identities {
175        apply_matching_model_params(config, identity, &mut params);
176    }
177    let provider_prefix = format!("{provider}/");
178    for identity in identities {
179        if !identity.starts_with(&provider_prefix) {
180            apply_matching_model_params(
181                config,
182                &format!("{provider_prefix}{identity}"),
183                &mut params,
184            );
185        }
186    }
187    params
188}
189
190/// Validate logical-model defaults against catalog identities and route caps.
191/// Route-specific patterns remain intentionally free-form for operator
192/// overrides; `logical:` selectors are publisher-level contracts and must be
193/// exact, typed, and representable by every route that inherits them.
194pub fn model_default_issues(config: &ProvidersConfig) -> Vec<String> {
195    let mut issues = Vec::new();
196    for (selector, defaults) in &config.model_defaults {
197        if let Some(unset) = defaults.get(MODEL_DEFAULT_UNSET_KEY) {
198            let valid = !is_logical_model_selector(selector)
199                && unset.as_array().is_some_and(|keys| {
200                    !keys.is_empty()
201                        && keys.iter().all(|key| {
202                            key.as_str()
203                                .is_some_and(is_supported_generation_default_key)
204                        })
205                });
206            if !valid {
207                issues.push(format!(
208                    "model_defaults.{selector}.{MODEL_DEFAULT_UNSET_KEY} must be a non-empty list of supported route-default keys"
209                ));
210            }
211        }
212        let Some(logical_model) = selector.strip_prefix(LOGICAL_MODEL_DEFAULT_PREFIX) else {
213            continue;
214        };
215        if logical_model.is_empty()
216            || logical_model.contains('*')
217            || logical_model.contains('?')
218            || logical_model.contains('[')
219        {
220            issues.push(format!(
221                "model_defaults.{selector} must name one exact logical model"
222            ));
223            continue;
224        }
225        let routes: Vec<_> = config
226            .models
227            .iter()
228            .filter(|(_, model)| model.logical_model.as_deref() == Some(logical_model))
229            .collect();
230        if routes.is_empty() {
231            issues.push(format!(
232                "model_defaults.{selector} references an unknown logical model"
233            ));
234            continue;
235        }
236
237        for (key, value) in defaults {
238            if key == MODEL_DEFAULT_UNSET_KEY {
239                continue;
240            }
241            if !is_valid_generation_default(key, value) {
242                issues.push(format!(
243                    "model_defaults.{selector}.{key} is not a supported generation default"
244                ));
245                continue;
246            }
247
248            for (model_id, model) in &routes {
249                let caps = crate::llm::capabilities::lookup_with_user_overrides(
250                    &model.provider,
251                    model_id,
252                    None,
253                );
254                let supported = match key.as_str() {
255                    "temperature" => caps.temperature_supported,
256                    "top_p" => caps.top_p_supported,
257                    "top_k" => caps.top_k_supported,
258                    "frequency_penalty" => caps.frequency_penalty_supported,
259                    "presence_penalty" => caps.presence_penalty_supported,
260                    "reasoning_effort" => {
261                        let effort = value.as_str().expect("validated effort string");
262                        caps.reasoning_effort_supported
263                            && caps.thinking_modes.iter().any(|mode| mode == "effort")
264                            && (caps.reasoning_effort_levels.is_empty()
265                                || caps
266                                    .reasoning_effort_levels
267                                    .iter()
268                                    .any(|level| level == effort))
269                    }
270                    "max_tokens" => true,
271                    _ => false,
272                };
273                let effective =
274                    model_params_for_route_with_config(config, &model.provider, model_id);
275                if !supported && effective.contains_key(key) {
276                    issues.push(format!(
277                        "model_defaults.{selector}.{key} cannot be represented by route {}:{}",
278                        model.provider, model_id
279                    ));
280                }
281            }
282        }
283    }
284    issues
285}
286
287fn is_supported_generation_default_key(key: &str) -> bool {
288    matches!(
289        key,
290        "temperature"
291            | "top_p"
292            | "top_k"
293            | "frequency_penalty"
294            | "presence_penalty"
295            | "max_tokens"
296            | "reasoning_effort"
297    )
298}
299
300fn is_valid_generation_default(key: &str, value: &toml::Value) -> bool {
301    match key {
302        "temperature" => value
303            .as_float()
304            .is_some_and(|value| value.is_finite() && (0.0..=2.0).contains(&value)),
305        "frequency_penalty" | "presence_penalty" => value
306            .as_float()
307            .is_some_and(|value| value.is_finite() && (-2.0..=2.0).contains(&value)),
308        "top_p" => value
309            .as_float()
310            .is_some_and(|value| value.is_finite() && (0.0..=1.0).contains(&value)),
311        "top_k" => value.as_integer().is_some_and(|value| value >= 0),
312        "max_tokens" => value.as_integer().is_some_and(|value| value > 0),
313        "reasoning_effort" => value.as_str().is_some_and(|value| {
314            matches!(
315                value,
316                "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
317            )
318        }),
319        _ => false,
320    }
321}
322
323/// Get per-role LLM defaults, e.g. `[model_roles.merge]`.
324///
325/// Role defaults are intentionally shaped like ordinary `llm_call` options:
326/// callers can pin `provider`/`model`, install `route_policy` or `prefer`,
327/// and tune budget/latency knobs without creating a parallel routing stack.
328/// Environment variables provide a lightweight operational override for
329/// merge/fast-apply workers:
330///
331/// - `HARN_LLM_MERGE_PROVIDER`, `HARN_LLM_MERGE_MODEL`,
332///   `HARN_LLM_MERGE_ROUTE_POLICY`
333/// - `HARN_LLM_FAST_APPLY_PROVIDER`, `HARN_LLM_FAST_APPLY_MODEL`,
334///   `HARN_LLM_FAST_APPLY_ROUTE_POLICY`
335/// - `HARN_LLM_ROLE_<ROLE>_PROVIDER`, `_MODEL`, `_ROUTE_POLICY`
336pub fn model_role_defaults(role: &str) -> BTreeMap<String, toml::Value> {
337    let normalized = normalize_model_role_name(role);
338    if normalized.is_empty() {
339        return BTreeMap::new();
340    }
341    let config = effective_config();
342    let mut params = BTreeMap::new();
343    for key in role_lookup_keys(&normalized) {
344        extend_model_role_defaults(&config, &key, &mut params);
345    }
346    apply_model_role_env_overrides(&normalized, &mut params);
347    params
348}
349
350fn extend_model_role_defaults(
351    config: &ProvidersConfig,
352    role: &str,
353    params: &mut BTreeMap<String, toml::Value>,
354) {
355    for (configured_role, defaults) in &config.model_roles {
356        if normalize_model_role_name(configured_role) == role {
357            params.extend(defaults.clone());
358        }
359    }
360    if let Some(defaults) = config.model_roles.get(role) {
361        params.extend(defaults.clone());
362    }
363}
364
365fn normalize_model_role_name(role: &str) -> String {
366    role.trim().to_ascii_lowercase().replace('-', "_")
367}
368
369fn role_lookup_keys(role: &str) -> Vec<String> {
370    if role == "merge" {
371        vec!["fast_apply".to_string(), "merge".to_string()]
372    } else if role == "fast_apply" {
373        vec!["merge".to_string(), "fast_apply".to_string()]
374    } else {
375        vec![role.to_string()]
376    }
377}
378
379fn role_env_token(role: &str) -> String {
380    role.chars()
381        .map(|ch| {
382            if ch.is_ascii_alphanumeric() {
383                ch.to_ascii_uppercase()
384            } else {
385                '_'
386            }
387        })
388        .collect::<String>()
389        .split('_')
390        .filter(|part| !part.is_empty())
391        .collect::<Vec<_>>()
392        .join("_")
393}
394
395fn apply_model_role_env_overrides(role: &str, params: &mut BTreeMap<String, toml::Value>) {
396    for alias in role_env_aliases(role) {
397        apply_model_role_env_var(&format!("HARN_LLM_{alias}_PROVIDER"), "provider", params);
398        apply_model_role_env_var(&format!("HARN_LLM_{alias}_MODEL"), "model", params);
399        apply_model_role_env_var(
400            &format!("HARN_LLM_{alias}_ROUTE_POLICY"),
401            "route_policy",
402            params,
403        );
404        apply_model_role_env_var(
405            &format!("HARN_LLM_ROLE_{alias}_PROVIDER"),
406            "provider",
407            params,
408        );
409        apply_model_role_env_var(&format!("HARN_LLM_ROLE_{alias}_MODEL"), "model", params);
410        apply_model_role_env_var(
411            &format!("HARN_LLM_ROLE_{alias}_ROUTE_POLICY"),
412            "route_policy",
413            params,
414        );
415    }
416}
417
418fn role_env_aliases(role: &str) -> Vec<String> {
419    let token = role_env_token(role);
420    if token.is_empty() {
421        return Vec::new();
422    }
423    if token == "MERGE" {
424        vec!["FAST_APPLY".to_string(), "MERGE".to_string()]
425    } else if token == "FAST_APPLY" {
426        vec!["MERGE".to_string(), "FAST_APPLY".to_string()]
427    } else {
428        vec![token]
429    }
430}
431
432fn apply_model_role_env_var(
433    env_name: &str,
434    option_name: &str,
435    params: &mut BTreeMap<String, toml::Value>,
436) {
437    let Ok(Some(value)) = crate::stdlib::process::session_env_var(env_name) else {
438        return;
439    };
440    let trimmed = value.trim();
441    if trimmed.is_empty() {
442        return;
443    }
444    params.insert(
445        option_name.to_string(),
446        toml::Value::String(trimmed.to_string()),
447    );
448}
449
450/// Get list of configured provider names.
451pub fn provider_names() -> Vec<String> {
452    effective_config().providers.keys().cloned().collect()
453}
454
455/// Return every configured alias name, sorted deterministically.
456pub fn known_model_names() -> Vec<String> {
457    effective_config().aliases.keys().cloned().collect()
458}
459
460pub fn alias_entries() -> Vec<(String, AliasDef)> {
461    effective_config()
462        .aliases
463        .iter()
464        .map(|(name, alias)| (name.clone(), alias.clone()))
465        .collect()
466}
467
468pub fn alias_tool_calling_entry(alias: &str) -> Option<AliasToolCallingDef> {
469    effective_config().alias_tool_calling.get(alias).cloned()
470}
471
472/// Return every configured model-catalog entry, sorted by provider then id.
473pub fn model_catalog_entries() -> Vec<(String, ModelDef)> {
474    let config = effective_config();
475    model_catalog_entries_with_config(&config)
476}
477
478pub(crate) fn model_catalog_entries_with_config(
479    config: &ProvidersConfig,
480) -> Vec<(String, ModelDef)> {
481    sorted_model_entries_with_config(config)
482        .into_iter()
483        .map(|(id, model)| {
484            let provider = model.provider.clone();
485            (
486                id.clone(),
487                with_effective_capability_tags(id, provider, model),
488            )
489        })
490        .collect()
491}
492
493pub(crate) fn sorted_model_entries_with_config(
494    config: &ProvidersConfig,
495) -> Vec<(String, ModelDef)> {
496    let mut entries: Vec<_> = config
497        .models
498        .iter()
499        .map(|(id, model)| (id.clone(), model.clone()))
500        .collect();
501    entries.sort_by(|(id_a, model_a), (id_b, model_b)| {
502        model_a
503            .provider
504            .cmp(&model_b.provider)
505            .then_with(|| id_a.cmp(id_b))
506    });
507    entries
508}
509
510pub fn model_catalog_entry(model_id: &str) -> Option<ModelDef> {
511    effective_config()
512        .models
513        .get(model_id)
514        .cloned()
515        .map(|model| {
516            let provider = model.provider.clone();
517            with_effective_capability_tags(model_id.to_string(), provider, model)
518        })
519}
520
521/// Return the collision-free catalog id for one concrete provider route.
522///
523/// Runtime transports use `wire_model`, while pricing and other catalog
524/// metadata are keyed by the authored catalog id. Resolve either identity
525/// without allowing an identically named model from another provider to win.
526pub fn model_catalog_id_for_route(provider: &str, model_id: &str) -> Option<String> {
527    let config = effective_config();
528    let normalized_id = normalize_model_id(model_id);
529    config
530        .models
531        .get_key_value(model_id)
532        .filter(|(_, model)| model.provider == provider)
533        .or_else(|| {
534            config
535                .models
536                .get_key_value(&normalized_id)
537                .filter(|(_, model)| model.provider == provider)
538        })
539        .or_else(|| {
540            config.models.iter().find(|(_, model)| {
541                model.provider == provider
542                    && model
543                        .wire_model
544                        .as_deref()
545                        .is_some_and(|wire| wire == model_id || wire == normalized_id.as_str())
546            })
547        })
548        .map(|(id, _)| id.clone())
549}
550
551pub fn model_rate_limits(model_id: &str) -> Option<RateLimitsDef> {
552    model_catalog_entry(model_id).and_then(|model| model.rate_limits)
553}
554
555/// Resolve a named model ladder declared under `[model_ladders.<name>]`.
556/// Returns `None` when no ladder with that name exists in the effective
557/// (base + overlay) catalog.
558pub fn model_ladder(name: &str) -> Option<ModelLadderDef> {
559    effective_config().model_ladders.get(name).cloned()
560}
561
562/// Sorted names of every declared model ladder — used to build a helpful
563/// "did you mean" error when a `ladder:` option names an unknown ladder.
564pub fn model_ladder_names() -> Vec<String> {
565    effective_config().model_ladders.keys().cloned().collect()
566}
567
568pub fn wire_model_id(model_id: &str) -> String {
569    model_catalog_entry(model_id)
570        .and_then(|model| model.wire_model)
571        .unwrap_or_else(|| model_id.to_string())
572}
573
574/// Resolve the model identity used by the capability matrix for one concrete
575/// provider route without deriving capability tags (which would recurse back
576/// into capability lookup). Collision-free catalog ids may differ from the
577/// upstream creator/model slug that provider-family rules match.
578pub(crate) fn capability_model_id(provider: &str, model_id: &str) -> String {
579    if !provider_has_feature(provider, "wire_model_capabilities") {
580        return model_id.to_string();
581    }
582    effective_config()
583        .models
584        .get(model_id)
585        .filter(|model| model.provider == provider)
586        .and_then(|model| model.wire_model.clone())
587        .unwrap_or_else(|| model_id.to_string())
588}
589
590pub fn provider_rate_limits(provider: &str) -> Option<RateLimitsDef> {
591    provider_config(provider).and_then(|provider| {
592        provider
593            .rate_limits
594            .unwrap_or_default()
595            .with_rpm_fallback(provider.rpm)
596    })
597}
598
599pub fn model_equivalence_group(model_id: &str) -> Option<String> {
600    model_catalog_entry(model_id).and_then(|model| {
601        model
602            .equivalence_group
603            .or(model.logical_model)
604            .filter(|group| !group.trim().is_empty())
605    })
606}
607
608#[derive(Clone, Debug, Default, PartialEq, Eq)]
609pub struct EquivalentModelRequirements {
610    pub context_tokens: Option<u64>,
611    pub responses_api: bool,
612    pub native_tools: bool,
613    pub text_tool_wire_format: bool,
614    pub provider_tool_types: Vec<String>,
615    pub vision: bool,
616    pub url_images: bool,
617    pub audio: bool,
618    pub pdf: bool,
619    pub video: bool,
620    pub files_api: bool,
621    pub thinking: bool,
622    pub reasoning_effort: bool,
623    pub structured_output: bool,
624    pub structured_output_mode: Option<String>,
625}
626
627impl EquivalentModelRequirements {
628    fn from_source_context(
629        context_tokens: u64,
630        caps: &crate::llm::capabilities::Capabilities,
631    ) -> Self {
632        Self {
633            context_tokens: Some(context_tokens),
634            responses_api: caps.responses_api,
635            native_tools: caps.native_tools,
636            text_tool_wire_format: caps.text_tool_wire_format_supported,
637            provider_tool_types: equivalent_provider_tool_types_for_capabilities(caps),
638            vision: caps.vision_supported,
639            url_images: caps.image_url_input_supported,
640            audio: caps.audio,
641            pdf: caps.pdf,
642            video: caps.video,
643            files_api: caps.files_api_supported,
644            thinking: !caps.thinking_modes.is_empty(),
645            reasoning_effort: caps.reasoning_effort_supported,
646            structured_output: caps.structured_output.is_some(),
647            structured_output_mode: Some(caps.structured_output_mode.clone()),
648        }
649    }
650}
651
652fn equivalent_provider_tool_types_for_capabilities(
653    caps: &crate::llm::capabilities::Capabilities,
654) -> Vec<String> {
655    let mut kinds = caps.hosted_tools.clone();
656    if caps.computer_use_style.is_some() {
657        kinds.push("computer_use".to_string());
658    }
659    kinds.sort();
660    kinds.dedup();
661    kinds
662}
663
664fn provider_tool_type_matches(
665    caps: &crate::llm::capabilities::Capabilities,
666    required: &str,
667) -> bool {
668    if required == "computer_use" && caps.computer_use_style.is_some() {
669        return true;
670    }
671    caps.hosted_tools
672        .iter()
673        .any(|kind| kind == required || (required == "computer_use" && kind == "computer"))
674}
675
676/// Return same-logical-model routes that can be considered for explicit
677/// failover or cross-provider experiments. Equivalence is a catalog assertion
678/// about compatible model weights/family, not wire-level identity.
679pub fn equivalent_model_catalog_entries_for_requirements(
680    selector: &str,
681    requirements: EquivalentModelRequirements,
682) -> Vec<(String, ModelDef)> {
683    let resolved = resolve_model_info(selector);
684    let Some(group) = model_equivalence_group(&resolved.id) else {
685        return Vec::new();
686    };
687    let config = effective_config();
688    let Some(source) = config.models.get(&resolved.id) else {
689        return Vec::new();
690    };
691    let source_context = source
692        .runtime_context_window
693        .unwrap_or(source.context_window);
694    let minimum_context = requirements.context_tokens.unwrap_or(source_context);
695
696    sorted_model_entries_with_config(&config)
697        .into_iter()
698        .filter(|(id, model)| !(id == &resolved.id && model.provider == resolved.provider))
699        .filter(|(_, model)| !model.deprecated)
700        .filter(|(_, model)| model.availability != ModelAvailability::Dedicated)
701        .filter(|(_, model)| {
702            model.equivalence_group.as_deref() == Some(group.as_str())
703                || model.logical_model.as_deref() == Some(group.as_str())
704        })
705        .filter(|(id, model)| {
706            let caps = crate::llm::capabilities::lookup(&model.provider, id);
707            let candidate_context = model.runtime_context_window.unwrap_or(model.context_window);
708            let context_matches = candidate_context >= minimum_context;
709            let responses_api_match = !requirements.responses_api || caps.responses_api;
710            let native_tools_match = !requirements.native_tools || caps.native_tools;
711            let text_tool_format_match =
712                !requirements.text_tool_wire_format || caps.text_tool_wire_format_supported;
713            let provider_tools_match = requirements
714                .provider_tool_types
715                .iter()
716                .all(|required| provider_tool_type_matches(&caps, required));
717            let vision_match = !requirements.vision || caps.vision_supported;
718            let url_images_match = !requirements.url_images
719                || crate::llm::provider::provider_supports_image_urls(&model.provider, id);
720            let audio_match = !requirements.audio || caps.audio;
721            let pdf_match = !requirements.pdf || caps.pdf;
722            let video_match = !requirements.video || caps.video;
723            let files_api_match = !requirements.files_api || caps.files_api_supported;
724            let thinking_match = !requirements.thinking || !caps.thinking_modes.is_empty();
725            let reasoning_effort_match =
726                !requirements.reasoning_effort || caps.reasoning_effort_supported;
727            let structured_output_match =
728                !requirements.structured_output || caps.structured_output.is_some();
729            let structured_output_mode_match = requirements
730                .structured_output_mode
731                .as_ref()
732                .is_none_or(|mode| mode == &caps.structured_output_mode);
733            context_matches
734                && responses_api_match
735                && native_tools_match
736                && text_tool_format_match
737                && provider_tools_match
738                && vision_match
739                && url_images_match
740                && audio_match
741                && pdf_match
742                && video_match
743                && files_api_match
744                && thinking_match
745                && reasoning_effort_match
746                && structured_output_match
747                && structured_output_mode_match
748        })
749        .map(|(id, model)| {
750            let provider = model.provider.clone();
751            (
752                id.clone(),
753                with_effective_capability_tags(id, provider, model),
754            )
755        })
756        .collect()
757}
758
759/// Request-shaped equivalent routes: constrain the context window but only
760/// require capabilities the current call actually resolved to use.
761pub fn equivalent_model_catalog_entries_for_context(
762    selector: &str,
763    required_context_tokens: Option<u64>,
764) -> Vec<(String, ModelDef)> {
765    equivalent_model_catalog_entries_for_requirements(
766        selector,
767        EquivalentModelRequirements {
768            context_tokens: required_context_tokens,
769            ..EquivalentModelRequirements::default()
770        },
771    )
772}
773
774pub fn equivalent_model_catalog_entries(selector: &str) -> Vec<(String, ModelDef)> {
775    let resolved = resolve_model_info(selector);
776    let config = effective_config();
777    let Some(source) = config.models.get(&resolved.id) else {
778        return Vec::new();
779    };
780    let source_caps = crate::llm::capabilities::lookup(&source.provider, &resolved.id);
781    let source_context = source
782        .runtime_context_window
783        .unwrap_or(source.context_window);
784    equivalent_model_catalog_entries_for_requirements(
785        selector,
786        EquivalentModelRequirements::from_source_context(source_context, &source_caps),
787    )
788}
789
790pub fn qc_default_model(provider: &str) -> Option<String> {
791    crate::stdlib::process::session_env_var("BURIN_QC_MODEL")
792        .ok()
793        .flatten()
794        .filter(|value| !value.trim().is_empty())
795        .or_else(|| {
796            effective_config()
797                .qc_defaults
798                .get(&provider.to_lowercase())
799                .cloned()
800        })
801}
802
803pub fn default_model_for_provider(provider: &str) -> String {
804    if provider_uses_acp(provider) {
805        return "default".to_string();
806    }
807    match provider {
808        "local" => crate::stdlib::process::session_env_value("LOCAL_LLM_MODEL")
809            .or_else(|| crate::stdlib::process::session_env_value("HARN_LLM_MODEL"))
810            .unwrap_or_else(|| "gemma-4-26b-a4b-it".to_string()),
811        "mlx" => crate::stdlib::process::session_env_var("MLX_MODEL_ID")
812            .ok()
813            .flatten()
814            .unwrap_or_else(|| "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit".to_string()),
815        "openai" => "gpt-4o-mini".to_string(),
816        "ollama" => "llama3.2".to_string(),
817        "openrouter" => "anthropic/claude-sonnet-4.6".to_string(),
818        _ => "claude-sonnet-4-6".to_string(),
819    }
820}
821
822pub fn qc_defaults() -> BTreeMap<String, String> {
823    effective_config().qc_defaults.clone()
824}
825
826pub fn model_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
827    effective_config()
828        .models
829        .get(model_id)
830        .and_then(|model| model.pricing.clone())
831}
832
833pub fn model_pricing_per_mtok_for_route(provider: &str, model_id: &str) -> Option<ModelPricing> {
834    let catalog_id = model_catalog_id_for_route(provider, model_id)?;
835    model_pricing_per_mtok(&catalog_id)
836}
837
838/// Per-MTok whole-request pricing selected for the provider-reported input
839/// usage. Models without input-token bands retain their base rates.
840pub fn model_pricing_for_input_tokens(model_id: &str, input_tokens: i64) -> Option<ModelPricing> {
841    model_pricing_per_mtok(model_id).map(|pricing| pricing.for_input_tokens(input_tokens))
842}
843
844pub fn model_pricing_for_route_input_tokens(
845    provider: &str,
846    model_id: &str,
847    input_tokens: i64,
848) -> Option<ModelPricing> {
849    model_pricing_per_mtok_for_route(provider, model_id)
850        .map(|pricing| pricing.for_input_tokens(input_tokens))
851}
852
853/// Per-MTok pricing for a named serving tier, when the catalog declares one.
854/// Returns `None` for models with no matching tier or a tier that omits
855/// explicit pricing — callers fall back to standard pricing in that case.
856pub fn model_serving_tier_pricing_per_mtok(model_id: &str, tier_id: &str) -> Option<ModelPricing> {
857    effective_config()
858        .models
859        .get(model_id)
860        .and_then(|model| model.serving_tiers.iter().find(|tier| tier.id == tier_id))
861        .and_then(|tier| tier.pricing.clone())
862}
863
864pub fn model_serving_tier_pricing_per_mtok_for_route(
865    provider: &str,
866    model_id: &str,
867    tier_id: &str,
868) -> Option<ModelPricing> {
869    let catalog_id = model_catalog_id_for_route(provider, model_id)?;
870    model_serving_tier_pricing_per_mtok(&catalog_id, tier_id)
871}
872
873pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
874    model_pricing_per_mtok_for_route(provider, model_id)
875        .map(|pricing| {
876            (
877                pricing.input_per_mtok / 1000.0,
878                pricing.output_per_mtok / 1000.0,
879            )
880        })
881        .or_else(|| {
882            let (input, output, _) = provider_economics(provider);
883            match (input, output) {
884                (Some(input), Some(output)) => Some((input, output)),
885                _ => None,
886            }
887        })
888}
889
890pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
891    match auth_env {
892        AuthEnv::None => Vec::new(),
893        AuthEnv::Single(name) => vec![name.clone()],
894        AuthEnv::Multiple(names) => names.clone(),
895    }
896}
897
898/// Check if a provider advertises a legacy provider-level feature.
899pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
900    provider_config(provider)
901        .map(|p| p.features.iter().any(|f| f == feature))
902        .unwrap_or(false)
903}
904
905/// Provider-level catalog pricing/latency. Model-specific catalog pricing
906/// wins when available; this is the adapter-level fallback used by routing
907/// and portal summaries when a model has no explicit catalog entry.
908pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
909    provider_config(provider)
910        .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
911        .unwrap_or((None, None, None))
912}
913
914/// The tool-call channel a `tool_format` string addresses.
915///
916/// `native` is the provider JSON tool-calling channel; `text` (the canonical
917/// tagged/heredoc grammar) and `json` (fenced-JSON) are both TEXT-channel
918/// formats — they ride in the assistant's visible content and parse with a
919/// text parser. This is the single source of truth for "is this format a
920/// text-channel format?" so the parity gates, native-tools resolution, and
921/// tool-result message role all agree.
922#[derive(Debug, Clone, Copy, PartialEq, Eq)]
923pub enum ToolFormatChannel {
924    /// Provider native JSON tool calling.
925    Native,
926    /// A text-channel grammar carried in assistant content (`text` or `json`).
927    Text,
928}
929
930/// Classify a `tool_format` string into its channel, or `None` for an unknown
931/// value (a typo, or a not-yet-wired format). Callers use this to reject
932/// unknown formats loudly instead of silently defaulting.
933///
934/// EXHAUSTIVE-MATCH GUARD: this `match` is the canonical place tool_format is
935/// switched. Adding a new format requires a branch here, so a half-wired
936/// format fails to compile rather than silently reading as text.
937pub fn tool_format_channel(format: &str) -> Option<ToolFormatChannel> {
938    match format {
939        "native" => Some(ToolFormatChannel::Native),
940        // `adaptive` was removed with the dialect cutover (#5700). Keep it out
941        // of the known set so catalog pins cannot silently select a deleted
942        // union parser; `parse_tool_calls` also fails closed if it is forced.
943        "text" | "json" => Some(ToolFormatChannel::Text),
944        _ => None,
945    }
946}
947
948/// True when `format` is a tool_format Harn understands (`native`, `text`, or
949/// `json`). Used to gate the capability-matrix `preferred_tool_format` so a
950/// pinned format is honored, while an unknown value falls through to the
951/// native/text heuristic.
952pub fn is_known_tool_format(format: &str) -> bool {
953    tool_format_channel(format).is_some()
954}
955
956/// Resolve the default tool format for a model+provider combination.
957/// Priority: alias `tool_format` (matched by model ID) > pinned empirical
958/// fitness > provider/model capability matrix > legacy provider feature >
959/// "json" (the global text-channel default; heredoc "text" is opt-in via a
960/// pin or explicit request).
961pub fn default_tool_format(model: &str, provider: &str) -> String {
962    let config = effective_config();
963    default_tool_format_with_config(&config, model, provider)
964}
965
966pub(crate) fn default_tool_format_with_config(
967    config: &ProvidersConfig,
968    model: &str,
969    provider: &str,
970) -> String {
971    default_tool_format_with_config_and_fitness(
972        config,
973        model,
974        provider,
975        crate::llm::tool_scorecard::pinned_tool_format(provider, model),
976    )
977}
978
979pub(crate) fn default_tool_format_with_config_and_fitness(
980    config: &ProvidersConfig,
981    model: &str,
982    provider: &str,
983    measured_format: Option<String>,
984) -> String {
985    // Aliases match by model ID + provider, or by alias name.
986    for (name, alias) in &config.aliases {
987        let matches = (alias.id == model && alias.provider == provider) || name == model;
988        if matches {
989            if let Some(ref fmt) = alias.tool_format {
990                return fmt.clone();
991            }
992        }
993    }
994    if let Some(format) =
995        measured_format.filter(|format| matches!(format.as_str(), "native" | "json" | "text"))
996    {
997        return format;
998    }
999    let capabilities = crate::llm::capabilities::lookup(provider, model);
1000    if let Some(format) = capabilities.preferred_tool_format.as_deref() {
1001        // A capability row may pin any known tool_format, including `text`
1002        // (heredoc) — the reverse safety valve a regressing model uses to pin
1003        // OFF the global json default. `json` is also honored when a row sets
1004        // it. The exhaustive match below is the EXHAUSTIVE-MATCH GUARD: a new
1005        // tool_format that isn't classified here fails loudly rather than
1006        // silently falling through to the native/json heuristic.
1007        if is_known_tool_format(format) {
1008            return format.to_string();
1009        }
1010    }
1011    let capability_matrix_native = capabilities.native_tools;
1012    let legacy_provider_native = config
1013        .providers
1014        .get(provider)
1015        .map(|p| p.features.iter().any(|f| f == "native_tools"))
1016        .unwrap_or(false);
1017    if capability_matrix_native || legacy_provider_native {
1018        "native".to_string()
1019    } else {
1020        // GLOBAL DEFAULT: a text-channel model with no pinned format resolves
1021        // to fenced-json (`json`), not heredoc (`text`). The win is STRUCTURAL
1022        // — a JSON string can't carry a raw newline, so a `<<EOF` content
1023        // delimiter never collides with the call wrapper (heredoc's known
1024        // production defect: models leak `<<EOF` into file content → the
1025        // `line 0: <<` thrash). Fenced-json swept a clean 1.0/1.0/1.0
1026        // (compliance/parse-determinism/expressiveness) across every model
1027        // measured, and the structural guarantee generalizes to unmeasured
1028        // models. Heredoc (`text`) stays selectable explicitly and via a
1029        // per-model `preferred_tool_format = "text"` pin (the reverse valve).
1030        "json".to_string()
1031    }
1032}
1033
1034fn with_effective_capability_tags(
1035    model_id: String,
1036    provider: String,
1037    mut model: ModelDef,
1038) -> ModelDef {
1039    model.capabilities = effective_model_capability_tags(&provider, &model_id);
1040    model
1041}
1042
1043/// Legacy display tags derived from the canonical provider/model capability
1044/// matrix. The matrix is the source of truth; `models.*.capabilities` in
1045/// providers.toml is accepted only for backwards-compatible parsing.
1046pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
1047    let caps = crate::llm::capabilities::lookup(provider, model_id);
1048    let mut tags = capability_tags_from_capabilities(&caps);
1049    if effective_batch_api_supported(provider, &caps) && !tags.iter().any(|tag| tag == "batch") {
1050        tags.push("batch".to_string());
1051    }
1052    tags
1053}
1054
1055pub fn effective_batch_api_supported(
1056    provider: &str,
1057    caps: &crate::llm::capabilities::Capabilities,
1058) -> bool {
1059    caps.batch_api || provider_has_feature(provider, "batch")
1060}
1061
1062pub(crate) fn capability_tags_from_capabilities(
1063    caps: &crate::llm::capabilities::Capabilities,
1064) -> Vec<String> {
1065    let mut tags = Vec::new();
1066    // Today all Harn chat providers expose streaming. Keep this as a
1067    // transport baseline rather than a duplicated per-model declaration.
1068    tags.push("streaming".to_string());
1069    if caps.native_tools || caps.text_tool_wire_format_supported {
1070        tags.push("tools".to_string());
1071    }
1072    if !caps.tool_search.is_empty() {
1073        tags.push("tool_search".to_string());
1074    }
1075    if caps.vision || caps.vision_supported {
1076        tags.push("vision".to_string());
1077    }
1078    if caps.audio {
1079        tags.push("audio".to_string());
1080    }
1081    if caps.pdf {
1082        tags.push("pdf".to_string());
1083    }
1084    if caps.video {
1085        tags.push("video".to_string());
1086    }
1087    if caps.files_api_supported {
1088        tags.push("files".to_string());
1089    }
1090    if caps.batch_api {
1091        tags.push("batch".to_string());
1092    }
1093    if caps.prompt_caching {
1094        tags.push("prompt_caching".to_string());
1095    }
1096    if !caps.thinking_modes.is_empty() {
1097        tags.push("thinking".to_string());
1098    }
1099    if caps.interleaved_thinking_supported
1100        || caps
1101            .thinking_modes
1102            .iter()
1103            .any(|mode| mode == "adaptive" || mode == "effort")
1104    {
1105        tags.push("extended_thinking".to_string());
1106    }
1107    if caps.structured_output.is_some() || caps.json_schema.is_some() {
1108        tags.push("structured_output".to_string());
1109    }
1110    tags
1111}
1112
1113/// Resolve a tier or alias into a concrete model/provider pair.
1114pub fn resolve_tier_model(
1115    target: &str,
1116    preferred_provider: Option<&str>,
1117) -> Option<(String, String)> {
1118    let config = effective_config();
1119
1120    let candidate_aliases = if let Some(provider) = preferred_provider {
1121        vec![
1122            format!("{provider}/{target}"),
1123            format!("{provider}:{target}"),
1124            format!("tier/{target}"),
1125            target.to_string(),
1126        ]
1127    } else {
1128        vec![format!("tier/{target}"), target.to_string()]
1129    };
1130
1131    for alias_name in candidate_aliases {
1132        if let Some(alias) = config.aliases.get(&alias_name) {
1133            return Some((alias.id.clone(), alias.provider.clone()));
1134        }
1135    }
1136
1137    None
1138}
1139
1140/// Return all configured alias-backed model/provider pairs whose resolved
1141/// model falls into the requested capability tier. The result is de-duplicated
1142/// and sorted deterministically by provider then model id.
1143pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
1144    let config = effective_config();
1145    let mut seen = std::collections::BTreeSet::new();
1146    let mut candidates = Vec::new();
1147
1148    for alias in config.aliases.values() {
1149        let pair = (alias.id.clone(), alias.provider.clone());
1150        if seen.contains(&pair) {
1151            continue;
1152        }
1153        if model_tier(&alias.id) == target {
1154            seen.insert(pair.clone());
1155            candidates.push(pair);
1156        }
1157    }
1158
1159    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
1160        provider_a
1161            .cmp(provider_b)
1162            .then_with(|| model_a.cmp(model_b))
1163    });
1164    candidates
1165}
1166
1167/// Return all configured alias-backed model/provider pairs. Used by routing
1168/// policies that need to compare alternatives across tiers.
1169pub fn all_model_candidates() -> Vec<(String, String)> {
1170    let config = effective_config();
1171    let mut seen = std::collections::BTreeSet::new();
1172    let mut candidates = Vec::new();
1173
1174    for alias in config.aliases.values() {
1175        let pair = (alias.id.clone(), alias.provider.clone());
1176        if seen.insert(pair.clone()) {
1177            candidates.push(pair);
1178        }
1179    }
1180
1181    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
1182        provider_a
1183            .cmp(provider_b)
1184            .then_with(|| model_a.cmp(model_b))
1185    });
1186    candidates
1187}