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/// Premium per-MTok pricing for a model's accelerated-serving ("fast mode")
494/// tier, when the catalog declares one. Returns `None` for models with no
495/// fast tier or a tier that omits explicit pricing — callers fall back to
496/// standard pricing in that case.
497pub fn model_fast_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
498    effective_config()
499        .models
500        .get(model_id)
501        .and_then(|model| model.fast_mode.as_ref())
502        .and_then(|fast_mode| fast_mode.pricing.clone())
503}
504
505pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
506    model_pricing_per_mtok(model_id)
507        .map(|pricing| {
508            (
509                pricing.input_per_mtok / 1000.0,
510                pricing.output_per_mtok / 1000.0,
511            )
512        })
513        .or_else(|| {
514            let (input, output, _) = provider_economics(provider);
515            match (input, output) {
516                (Some(input), Some(output)) => Some((input, output)),
517                _ => None,
518            }
519        })
520}
521
522pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
523    match auth_env {
524        AuthEnv::None => Vec::new(),
525        AuthEnv::Single(name) => vec![name.clone()],
526        AuthEnv::Multiple(names) => names.clone(),
527    }
528}
529
530pub fn provider_key_available(provider: &str) -> bool {
531    let Some(pdef) = provider_config(provider) else {
532        return provider == "ollama";
533    };
534    if pdef.auth_style == "none" || matches!(pdef.auth_env, AuthEnv::None) {
535        return true;
536    }
537    auth_env_names(&pdef.auth_env).into_iter().any(|env_name| {
538        std::env::var(env_name)
539            .ok()
540            .is_some_and(|value| !value.trim().is_empty())
541    })
542}
543
544pub fn available_provider_names() -> Vec<String> {
545    provider_names()
546        .into_iter()
547        .filter(|provider| provider_key_available(provider))
548        .collect()
549}
550
551/// Check if a provider advertises a legacy provider-level feature.
552pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
553    provider_config(provider)
554        .map(|p| p.features.iter().any(|f| f == feature))
555        .unwrap_or(false)
556}
557
558/// Provider-level catalog pricing/latency. Model-specific catalog pricing
559/// wins when available; this is the adapter-level fallback used by routing
560/// and portal summaries when a model has no explicit catalog entry.
561pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
562    provider_config(provider)
563        .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
564        .unwrap_or((None, None, None))
565}
566
567/// The tool-call channel a `tool_format` string addresses.
568///
569/// `native` is the provider JSON tool-calling channel; `text` (the canonical
570/// tagged/heredoc grammar) and `json` (fenced-JSON) are both TEXT-channel
571/// formats — they ride in the assistant's visible content and parse with a
572/// text parser. This is the single source of truth for "is this format a
573/// text-channel format?" so the parity gates, native-tools resolution, and
574/// tool-result message role all agree.
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub enum ToolFormatChannel {
577    /// Provider native JSON tool calling.
578    Native,
579    /// A text-channel grammar carried in assistant content (`text` or `json`).
580    Text,
581}
582
583/// Classify a `tool_format` string into its channel, or `None` for an unknown
584/// value (a typo, or a not-yet-wired format). Callers use this to reject
585/// unknown formats loudly instead of silently defaulting.
586///
587/// EXHAUSTIVE-MATCH GUARD: this `match` is the canonical place tool_format is
588/// switched. Adding a new format requires a branch here, so a half-wired
589/// format fails to compile rather than silently reading as text.
590pub fn tool_format_channel(format: &str) -> Option<ToolFormatChannel> {
591    match format {
592        "native" => Some(ToolFormatChannel::Native),
593        "text" | "json" => Some(ToolFormatChannel::Text),
594        _ => None,
595    }
596}
597
598/// True when `format` is a tool_format Harn understands (`native`, `text`, or
599/// `json`). Used to gate the capability-matrix `preferred_tool_format` so a
600/// pinned format is honored, while an unknown value falls through to the
601/// native/text heuristic.
602pub fn is_known_tool_format(format: &str) -> bool {
603    tool_format_channel(format).is_some()
604}
605
606/// Resolve the default tool format for a model+provider combination.
607/// Priority: alias `tool_format` (matched by model ID) > provider/model
608/// capability matrix > legacy provider feature > "json" (the global
609/// text-channel default; heredoc "text" is opt-in via a pin or explicit
610/// request).
611pub fn default_tool_format(model: &str, provider: &str) -> String {
612    let config = effective_config();
613    default_tool_format_with_config(&config, model, provider)
614}
615
616pub(crate) fn default_tool_format_with_config(
617    config: &ProvidersConfig,
618    model: &str,
619    provider: &str,
620) -> String {
621    // Aliases match by model ID + provider, or by alias name.
622    for (name, alias) in &config.aliases {
623        let matches = (alias.id == model && alias.provider == provider) || name == model;
624        if matches {
625            if let Some(ref fmt) = alias.tool_format {
626                return fmt.clone();
627            }
628        }
629    }
630    let capabilities = crate::llm::capabilities::lookup(provider, model);
631    if let Some(format) = capabilities.preferred_tool_format.as_deref() {
632        // A capability row may pin any known tool_format, including `text`
633        // (heredoc) — the reverse safety valve a regressing model uses to pin
634        // OFF the global json default. `json` is also honored when a row sets
635        // it. The exhaustive match below is the EXHAUSTIVE-MATCH GUARD: a new
636        // tool_format that isn't classified here fails loudly rather than
637        // silently falling through to the native/json heuristic.
638        if is_known_tool_format(format) {
639            return format.to_string();
640        }
641    }
642    let capability_matrix_native = capabilities.native_tools;
643    let legacy_provider_native = config
644        .providers
645        .get(provider)
646        .map(|p| p.features.iter().any(|f| f == "native_tools"))
647        .unwrap_or(false);
648    if capability_matrix_native || legacy_provider_native {
649        "native".to_string()
650    } else {
651        // GLOBAL DEFAULT: a text-channel model with no pinned format resolves
652        // to fenced-json (`json`), not heredoc (`text`). The win is STRUCTURAL
653        // — a JSON string can't carry a raw newline, so a `<<EOF` content
654        // delimiter never collides with the call wrapper (heredoc's known
655        // production defect: models leak `<<EOF` into file content → the
656        // `line 0: <<` thrash). Fenced-json swept a clean 1.0/1.0/1.0
657        // (compliance/parse-determinism/expressiveness) across every model
658        // measured, and the structural guarantee generalizes to unmeasured
659        // models. Heredoc (`text`) stays selectable explicitly and via a
660        // per-model `preferred_tool_format = "text"` pin (the reverse valve).
661        "json".to_string()
662    }
663}
664
665fn with_effective_capability_tags(
666    model_id: String,
667    provider: String,
668    mut model: ModelDef,
669) -> ModelDef {
670    model.capabilities = effective_model_capability_tags(&provider, &model_id);
671    model
672}
673
674/// Legacy display tags derived from the canonical provider/model capability
675/// matrix. The matrix is the source of truth; `models.*.capabilities` in
676/// providers.toml is accepted only for backwards-compatible parsing.
677pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
678    let caps = crate::llm::capabilities::lookup(provider, model_id);
679    let mut tags = capability_tags_from_capabilities(&caps);
680    if effective_batch_api_supported(provider, &caps) && !tags.iter().any(|tag| tag == "batch") {
681        tags.push("batch".to_string());
682    }
683    tags
684}
685
686pub fn effective_batch_api_supported(
687    provider: &str,
688    caps: &crate::llm::capabilities::Capabilities,
689) -> bool {
690    caps.batch_api || provider_has_feature(provider, "batch")
691}
692
693pub(crate) fn capability_tags_from_capabilities(
694    caps: &crate::llm::capabilities::Capabilities,
695) -> Vec<String> {
696    let mut tags = Vec::new();
697    // Today all Harn chat providers expose streaming. Keep this as a
698    // transport baseline rather than a duplicated per-model declaration.
699    tags.push("streaming".to_string());
700    if caps.native_tools || caps.text_tool_wire_format_supported {
701        tags.push("tools".to_string());
702    }
703    if !caps.tool_search.is_empty() {
704        tags.push("tool_search".to_string());
705    }
706    if caps.vision || caps.vision_supported {
707        tags.push("vision".to_string());
708    }
709    if caps.audio {
710        tags.push("audio".to_string());
711    }
712    if caps.pdf {
713        tags.push("pdf".to_string());
714    }
715    if caps.video {
716        tags.push("video".to_string());
717    }
718    if caps.files_api_supported {
719        tags.push("files".to_string());
720    }
721    if caps.batch_api {
722        tags.push("batch".to_string());
723    }
724    if caps.prompt_caching {
725        tags.push("prompt_caching".to_string());
726    }
727    if !caps.thinking_modes.is_empty() {
728        tags.push("thinking".to_string());
729    }
730    if caps.interleaved_thinking_supported
731        || caps
732            .thinking_modes
733            .iter()
734            .any(|mode| mode == "adaptive" || mode == "effort")
735    {
736        tags.push("extended_thinking".to_string());
737    }
738    if caps.structured_output.is_some() || caps.json_schema.is_some() {
739        tags.push("structured_output".to_string());
740    }
741    tags
742}
743
744/// Resolve a tier or alias into a concrete model/provider pair.
745pub fn resolve_tier_model(
746    target: &str,
747    preferred_provider: Option<&str>,
748) -> Option<(String, String)> {
749    let config = effective_config();
750
751    let candidate_aliases = if let Some(provider) = preferred_provider {
752        vec![
753            format!("{provider}/{target}"),
754            format!("{provider}:{target}"),
755            format!("tier/{target}"),
756            target.to_string(),
757        ]
758    } else {
759        vec![format!("tier/{target}"), target.to_string()]
760    };
761
762    for alias_name in candidate_aliases {
763        if let Some(alias) = config.aliases.get(&alias_name) {
764            return Some((alias.id.clone(), alias.provider.clone()));
765        }
766    }
767
768    None
769}
770
771/// Return all configured alias-backed model/provider pairs whose resolved
772/// model falls into the requested capability tier. The result is de-duplicated
773/// and sorted deterministically by provider then model id.
774pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
775    let config = effective_config();
776    let mut seen = std::collections::BTreeSet::new();
777    let mut candidates = Vec::new();
778
779    for alias in config.aliases.values() {
780        let pair = (alias.id.clone(), alias.provider.clone());
781        if seen.contains(&pair) {
782            continue;
783        }
784        if model_tier(&alias.id) == target {
785            seen.insert(pair.clone());
786            candidates.push(pair);
787        }
788    }
789
790    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
791        provider_a
792            .cmp(provider_b)
793            .then_with(|| model_a.cmp(model_b))
794    });
795    candidates
796}
797
798/// Return all configured alias-backed model/provider pairs. Used by routing
799/// policies that need to compare alternatives across tiers.
800pub fn all_model_candidates() -> Vec<(String, String)> {
801    let config = effective_config();
802    let mut seen = std::collections::BTreeSet::new();
803    let mut candidates = Vec::new();
804
805    for alias in config.aliases.values() {
806        let pair = (alias.id.clone(), alias.provider.clone());
807        if seen.insert(pair.clone()) {
808            candidates.push(pair);
809        }
810    }
811
812    candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
813        provider_a
814            .cmp(provider_b)
815            .then_with(|| model_a.cmp(model_b))
816    });
817    candidates
818}