Skip to main content

harn_vm/llm_config/
resolution.rs

1//! Selector resolution: turn an alias or provider/model selector into the
2//! complete `ResolvedModel` identity (provider, normalized id, tool format,
3//! tier, family, lineage).
4use std::collections::BTreeMap;
5
6use serde::Serialize;
7
8use super::*;
9
10#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
11pub struct ResolvedModel {
12    pub id: String,
13    pub provider: String,
14    pub alias: Option<String>,
15    pub tool_format: String,
16    pub tier: String,
17    pub family: String,
18    pub lineage: String,
19}
20
21/// Stable, secret-free model-route facts suitable for durable receipts.
22///
23/// The execution path may carry arbitrary route-overlay parameters. This
24/// contract exposes only Harn's validated generation-default schema so
25/// replay, eval, and audit consumers do not serialize private operator fields.
26#[derive(Debug, Clone, PartialEq)]
27pub struct ModelExecutionContract {
28    pub selector: String,
29    pub resolved: ResolvedModel,
30    pub wire_model: String,
31    pub generation_defaults: BTreeMap<String, toml::Value>,
32}
33
34/// Resolve a model alias to (model_id, provider_name).
35pub fn resolve_model(alias: &str) -> (String, Option<String>) {
36    let config = effective_config();
37    if let Some(a) = config.aliases.get(alias) {
38        return (a.id.clone(), Some(a.provider.clone()));
39    }
40    (normalize_model_id_with_config(alias, &config), None)
41}
42
43/// Strip host/provider selector prefixes that identify transport, not the
44/// provider-native model id. This mirrors the host's existing normalization so
45/// `ollama:qwen3:30b` reaches Ollama as `qwen3:30b` instead of an invalid
46/// model named `ollama`. Cerebras follows the same convention but uses a
47/// slash separator (`cerebras/gpt-oss-120b`) because its own /v1/models
48/// endpoint returns bare names that overlap OpenAI's families.
49pub fn normalize_model_id(raw: &str) -> String {
50    normalize_model_id_with_config(raw, &effective_config())
51}
52
53fn normalize_model_id_with_config(raw: &str, config: &ProvidersConfig) -> String {
54    for prefix in PROVIDER_SELECTOR_PREFIXES {
55        if let Some(stripped) = raw.strip_prefix(prefix) {
56            return stripped.to_string();
57        }
58    }
59    if let Some((provider, model)) = raw.split_once(':') {
60        if !model.is_empty() && (provider == "mock" || config.providers.contains_key(provider)) {
61            return model.to_string();
62        }
63    }
64    raw.to_string()
65}
66
67const PROVIDER_SELECTOR_PREFIXES: &[&str] =
68    &["ollama:", "local:", "huggingface:", "hf:", "cerebras/"];
69
70/// Resolve an alias or selector into the complete catalog identity hosts need:
71/// provider inference, prefix-normalized model id, default tool format, and tier.
72pub fn resolve_model_info(selector: &str) -> ResolvedModel {
73    let config = effective_config();
74    if let Some(alias) = config.aliases.get(selector) {
75        let id = alias.id.clone();
76        let provider = alias.provider.clone();
77        let requested = alias
78            .tool_format
79            .clone()
80            .unwrap_or_else(|| default_tool_format_with_config(&config, &id, &provider));
81        let tool_format = guard_tool_format(&provider, &id, &requested, Some(selector));
82        return ResolvedModel {
83            tier: model_tier_with_config(&config, &id),
84            family: model_family_with_config(&config, &provider, &id),
85            lineage: model_lineage_with_config(&config, &provider, &id),
86            id,
87            provider,
88            alias: Some(selector.to_string()),
89            tool_format,
90        };
91    }
92
93    let id = normalize_model_id_with_config(selector, &config);
94    let inference = infer_provider_with_config(&config, selector);
95    let source = inference.source;
96    let provider = inference.provider;
97    let requested = default_tool_format_with_config(&config, &id, &provider);
98    let tool_format = guard_tool_format(&provider, &id, &requested, None);
99    let tier = model_tier_with_config(&config, &id);
100    let family = model_family_with_inference_source(&config, &provider, &id, source);
101    let lineage = model_lineage_with_inference_source(&config, &provider, &id, source);
102    ResolvedModel {
103        id,
104        provider,
105        alias: None,
106        tool_format,
107        tier,
108        family,
109        lineage,
110    }
111}
112
113/// Resolve a model selector into the stable, secret-free execution facts that
114/// hosts may persist and fingerprint.
115pub fn model_execution_contract(selector: &str) -> ModelExecutionContract {
116    let resolved = resolve_model_info(selector);
117    let wire_model = wire_model_id(&resolved.id);
118    let generation_defaults = generation_defaults_for_route(&resolved.provider, &resolved.id);
119    ModelExecutionContract {
120        selector: selector.to_string(),
121        resolved,
122        wire_model,
123        generation_defaults,
124    }
125}
126
127/// Run the requested `tool_format` through the capability registry's
128/// dialect-validity gate, returning the safe format to actually use. When the
129/// registry auto-corrects a known-broken combo (e.g. a `native` pin on a
130/// `native_unreliable` route that silently drops to unparsed DSML text), the
131/// correction is logged once at resolution time so a harness developer sees
132/// *why* their pinned format was not honored — never a silent vanishing.
133fn guard_tool_format(provider: &str, model: &str, requested: &str, alias: Option<&str>) -> String {
134    let decision = crate::llm::capabilities::validate_tool_format(provider, model, requested);
135    if let Some(reason) = &decision.correction {
136        tracing::warn!(
137            target: "harn::llm::tool_format",
138            alias = alias.unwrap_or(""),
139            "{reason}"
140        );
141    }
142    decision.effective
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{normalize_model_id, resolve_model_info};
148
149    #[test]
150    fn registered_provider_selector_normalizes_to_native_model_id() {
151        let model = resolve_model_info("openai:o3");
152        assert_eq!(model.provider, "openai");
153        assert_eq!(model.id, "o3");
154        assert_eq!(normalize_model_id("mock:o3"), "o3");
155        assert_eq!(
156            normalize_model_id("ollama:qwen3.2:latest"),
157            "qwen3.2:latest",
158            "only the first selector colon is transport syntax"
159        );
160    }
161
162    #[test]
163    fn grok_code_aliases_resolve_through_live_resolver() {
164        for selector in ["grok-code", "grok-code-fast", "grok-code-fast-1"] {
165            let model = resolve_model_info(selector);
166            assert_eq!(
167                (
168                    model.id.as_str(),
169                    model.provider.as_str(),
170                    model.alias.as_deref(),
171                    model.tool_format.as_str(),
172                ),
173                ("grok-build-0.1", "xai", Some(selector), "native"),
174                "selector: {selector}",
175            );
176        }
177    }
178
179    #[test]
180    fn huggingface_qwen3_coder_aliases_resolve_through_live_resolver() {
181        for selector in ["huggingface-qwen3-coder", "hf-qwen3-coder"] {
182            let model = resolve_model_info(selector);
183            assert_eq!(
184                (
185                    model.id.as_str(),
186                    model.provider.as_str(),
187                    model.alias.as_deref(),
188                    model.tool_format.as_str(),
189                    model.tier.as_str(),
190                ),
191                (
192                    "Qwen/Qwen3-Coder-480B-A35B-Instruct",
193                    "huggingface",
194                    Some(selector),
195                    "native",
196                    "frontier",
197                ),
198                "selector: {selector}",
199            );
200        }
201    }
202}