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