Skip to main content

harn_vm/llm/capabilities/
admission.rs

1use super::overrides::current_user_overrides;
2use super::rule::declared_portable_option_support;
3
4/// Portable generation options whose availability is declared by the
5/// provider capability registry.
6///
7/// This enum is the shared vocabulary for runtime admission, routing-step
8/// overrides, and static preflight. Provider-specific escape hatches remain
9/// below `provider_options.<provider>` and never enter this portable lane.
10#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
11pub enum PortableOption {
12    Temperature,
13    TopP,
14    TopK,
15    Seed,
16    FrequencyPenalty,
17    PresencePenalty,
18    Stop,
19    Cache,
20    PromptCacheTtl,
21}
22
23impl PortableOption {
24    /// Options whose non-`nil` presence is enough to prove caller intent.
25    /// Cache needs a true value and prompt-cache TTL needs its selected value,
26    /// so their callers use the dedicated admission paths below.
27    pub const PRESENCE_DRIVEN: [Self; 7] = [
28        Self::Temperature,
29        Self::TopP,
30        Self::TopK,
31        Self::Seed,
32        Self::FrequencyPenalty,
33        Self::PresencePenalty,
34        Self::Stop,
35    ];
36
37    pub const ALL: [Self; 9] = [
38        Self::Temperature,
39        Self::TopP,
40        Self::TopK,
41        Self::Seed,
42        Self::FrequencyPenalty,
43        Self::PresencePenalty,
44        Self::Stop,
45        Self::Cache,
46        Self::PromptCacheTtl,
47    ];
48
49    pub const fn name(self) -> &'static str {
50        match self {
51            Self::Temperature => "temperature",
52            Self::TopP => "top_p",
53            Self::TopK => "top_k",
54            Self::Seed => "seed",
55            Self::FrequencyPenalty => "frequency_penalty",
56            Self::PresencePenalty => "presence_penalty",
57            Self::Stop => "stop",
58            Self::Cache => "cache",
59            Self::PromptCacheTtl => "prompt_cache_ttl",
60        }
61    }
62
63    pub fn from_name(name: &str) -> Option<Self> {
64        Self::ALL.into_iter().find(|option| option.name() == name)
65    }
66}
67
68/// A caller requested a portable option that the resolved route cannot
69/// represent. The route and option stay structured so every projection can
70/// render its own diagnostic without duplicating capability policy.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct CapabilityAdmissionError {
73    pub provider: String,
74    pub model: String,
75    pub option: PortableOption,
76    pub requested_value: Option<String>,
77    pub supported_values: Vec<String>,
78}
79
80impl std::fmt::Display for CapabilityAdmissionError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(
83            f,
84            "option `{}`{} is not supported by `{}` (provider `{}`).",
85            self.option.name(),
86            self.requested_value
87                .as_deref()
88                .map(|value| format!(" value `{value}`"))
89                .unwrap_or_default(),
90            self.model,
91            self.provider,
92        )?;
93        if !self.supported_values.is_empty() {
94            write!(
95                f,
96                " Supported values: {}.",
97                self.supported_values.join(", ")
98            )?;
99        }
100        write!(
101            f,
102            " Remove it, choose a compatible route, or move a provider-native control below `provider_options.{}`. See `harn provider catalog matrix` for compatibility.",
103            self.provider
104        )
105    }
106}
107
108/// Admit one caller-selected portable option against the canonical capability
109/// registry. Generation fields remain open-world because adapters can project
110/// them unchanged. Cache controls require an authored capability because Harn
111/// must choose a provider-specific lowering.
112pub fn admit_portable_option(
113    provider: &str,
114    model: &str,
115    option: PortableOption,
116) -> Result<(), CapabilityAdmissionError> {
117    debug_assert_ne!(option, PortableOption::PromptCacheTtl);
118    let user = current_user_overrides();
119    let builtin = super::lookup::builtin();
120    let (supported, _) =
121        declared_portable_option_support(user.as_ref(), builtin, provider, model, option);
122    let requires_authored_support = option == PortableOption::Cache;
123    if supported == Some(true) || (supported.is_none() && !requires_authored_support) {
124        return Ok(());
125    }
126    Err(CapabilityAdmissionError {
127        provider: provider.to_string(),
128        model: model.to_string(),
129        option,
130        requested_value: None,
131        supported_values: Vec::new(),
132    })
133}
134
135/// Admit one explicit prompt-cache TTL. A route that declares prompt caching
136/// but no selectable TTL values cannot represent caller-selected TTL intent.
137/// A route with no cache facts is rejected because Harn has no sound TTL
138/// lowering to project for it.
139pub fn admit_prompt_cache_ttl(
140    provider: &str,
141    model: &str,
142    ttl: &str,
143) -> Result<(), CapabilityAdmissionError> {
144    let user = current_user_overrides();
145    let builtin = super::lookup::builtin();
146    let (cache_supported, supported_values) = declared_portable_option_support(
147        user.as_ref(),
148        builtin,
149        provider,
150        model,
151        PortableOption::PromptCacheTtl,
152    );
153    match cache_supported {
154        Some(true)
155            if supported_values
156                .as_ref()
157                .is_some_and(|values| values.iter().any(|value| value == ttl)) =>
158        {
159            return Ok(())
160        }
161        Some(true) | Some(false) | None => {}
162    }
163    Err(CapabilityAdmissionError {
164        provider: provider.to_string(),
165        model: model.to_string(),
166        option: PortableOption::PromptCacheTtl,
167        requested_value: Some(ttl.to_string()),
168        supported_values: supported_values.unwrap_or_default(),
169    })
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::llm::capabilities::{clear_user_overrides, set_user_overrides_toml};
176
177    #[test]
178    fn rejects_declared_gap_and_keeps_unknown_routes_open_world() {
179        let rejected = admit_portable_option("moonshot", "kimi-k3", PortableOption::Temperature)
180            .expect_err("Kimi K3 rejects caller-selected temperature");
181        assert_eq!(rejected.option, PortableOption::Temperature);
182        assert!(rejected.to_string().contains("provider_options.moonshot"));
183
184        assert!(
185            admit_portable_option("my-proxy", "custom-model", PortableOption::Temperature,).is_ok()
186        );
187    }
188
189    #[test]
190    fn cache_and_ttl_admission_require_authored_lowering() {
191        set_user_overrides_toml(
192            r#"
193[[provider.test-provider]]
194model_match = "no-cache"
195prompt_caching = false
196
197[[provider.test-provider]]
198model_match = "cache-with-ttl"
199prompt_caching = true
200prompt_cache_ttls = ["5m", "1h"]
201"#,
202        )
203        .unwrap();
204
205        let cache = admit_portable_option("test-provider", "no-cache", PortableOption::Cache)
206            .expect_err("the synthetic route declares prompt caching unsupported");
207        assert_eq!(cache.option, PortableOption::Cache);
208
209        admit_prompt_cache_ttl("test-provider", "cache-with-ttl", "1h")
210            .expect("the synthetic route supports the one-hour TTL");
211        let unsupported = admit_prompt_cache_ttl("test-provider", "cache-with-ttl", "2h")
212            .expect_err("the synthetic route rejects an unlisted TTL");
213        assert_eq!(unsupported.requested_value.as_deref(), Some("2h"));
214        assert_eq!(unsupported.supported_values, ["5m", "1h"]);
215
216        let unknown = admit_prompt_cache_ttl("my-proxy", "custom-model", "1h")
217            .expect_err("unknown custom routes have no sound TTL lowering");
218        assert_eq!(unknown.option, PortableOption::PromptCacheTtl);
219        clear_user_overrides();
220    }
221}