harn_vm/llm/capabilities/
admission.rs1use super::overrides::current_user_overrides;
2use super::rule::declared_portable_option_support;
3
4#[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 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#[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
108pub 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
135pub 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}