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