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