use super::overrides::current_user_overrides;
use super::rule::resolved_rule_and_defaults;
fn declared_portable_option_support(
user: Option<&super::model::CapabilitiesFile>,
builtin: &super::model::CapabilitiesFile,
provider: &str,
model: &str,
option: PortableOption,
) -> (Option<bool>, Option<Vec<String>>) {
let (rule, defaults) = resolved_rule_and_defaults(user, builtin, provider, model);
let rule = rule.as_ref();
let supported = match option {
PortableOption::Temperature => rule
.and_then(|rule| rule.temperature_supported)
.or(defaults.temperature_supported),
PortableOption::TopP => rule
.and_then(|rule| rule.top_p_supported)
.or(defaults.top_p_supported),
PortableOption::TopK => rule
.and_then(|rule| rule.top_k_supported)
.or(defaults.top_k_supported),
PortableOption::Seed => rule
.and_then(|rule| rule.seed_supported)
.or(defaults.seed_supported),
PortableOption::FrequencyPenalty => rule
.and_then(|rule| rule.frequency_penalty_supported)
.or(defaults.frequency_penalty_supported),
PortableOption::PresencePenalty => rule
.and_then(|rule| rule.presence_penalty_supported)
.or(defaults.presence_penalty_supported),
PortableOption::Stop => rule
.and_then(|rule| rule.stop_supported)
.or(defaults.stop_supported),
PortableOption::Logprobs
| PortableOption::LogitBias
| PortableOption::MinP
| PortableOption::RepetitionPenalty
| PortableOption::Prediction
| PortableOption::Verbosity
| PortableOption::Mirostat => Some(
rule.and_then(|rule| rule.advanced_generation_options.as_ref())
.or(defaults.advanced_generation_options.as_ref())
.is_some_and(|options| options.contains(&option)),
),
PortableOption::ParallelToolCalls => rule
.and_then(|rule| rule.supports_parallel_tool_calls)
.or(defaults.supports_parallel_tool_calls),
PortableOption::Cache | PortableOption::PromptCacheTtl => {
rule.and_then(|rule| rule.prompt_caching)
}
};
let values = (option == PortableOption::PromptCacheTtl).then(|| {
rule.and_then(|rule| rule.prompt_cache_ttls.clone())
.or_else(|| defaults.prompt_cache_ttls.clone())
.unwrap_or_default()
});
(supported, values)
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PortableOption {
Temperature,
TopP,
TopK,
Seed,
FrequencyPenalty,
PresencePenalty,
Stop,
Logprobs,
LogitBias,
MinP,
RepetitionPenalty,
Prediction,
Verbosity,
Mirostat,
ParallelToolCalls,
Cache,
PromptCacheTtl,
}
impl PortableOption {
pub const PRESENCE_DRIVEN: [Self; 7] = [
Self::Temperature,
Self::TopP,
Self::TopK,
Self::Seed,
Self::FrequencyPenalty,
Self::PresencePenalty,
Self::Stop,
];
pub const ALL: [Self; 17] = [
Self::Temperature,
Self::TopP,
Self::TopK,
Self::Seed,
Self::FrequencyPenalty,
Self::PresencePenalty,
Self::Stop,
Self::Logprobs,
Self::LogitBias,
Self::MinP,
Self::RepetitionPenalty,
Self::Prediction,
Self::Verbosity,
Self::Mirostat,
Self::ParallelToolCalls,
Self::Cache,
Self::PromptCacheTtl,
];
pub const fn name(self) -> &'static str {
match self {
Self::Temperature => "temperature",
Self::TopP => "top_p",
Self::TopK => "top_k",
Self::Seed => "seed",
Self::FrequencyPenalty => "frequency_penalty",
Self::PresencePenalty => "presence_penalty",
Self::Stop => "stop",
Self::Logprobs => "logprobs",
Self::LogitBias => "logit_bias",
Self::MinP => "min_p",
Self::RepetitionPenalty => "repetition_penalty",
Self::Prediction => "prediction",
Self::Verbosity => "verbosity",
Self::Mirostat => "mirostat",
Self::ParallelToolCalls => "parallel_tool_calls",
Self::Cache => "cache",
Self::PromptCacheTtl => "prompt_cache_ttl",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|option| option.name() == name)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CapabilityAdmissionError {
pub provider: String,
pub model: String,
pub option: PortableOption,
pub reasoning_enabled: bool,
pub requested_value: Option<String>,
pub supported_values: Vec<String>,
}
impl std::fmt::Display for CapabilityAdmissionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"option `{}`{} is not supported{} by `{}` (provider `{}`).",
self.option.name(),
self.requested_value
.as_deref()
.map(|value| format!(" value `{value}`"))
.unwrap_or_default(),
if self.reasoning_enabled {
" while reasoning is enabled"
} else {
""
},
self.model,
self.provider,
)?;
if !self.supported_values.is_empty() {
write!(
f,
" Supported values: {}.",
self.supported_values.join(", ")
)?;
}
write!(
f,
" Remove it, choose a compatible route, or move a provider-native control below `provider_options.{}`. See `harn provider catalog matrix` for compatibility.",
self.provider
)
}
}
pub fn admit_portable_option(
provider: &str,
model: &str,
option: PortableOption,
) -> Result<(), CapabilityAdmissionError> {
admit_portable_option_for_thinking(
provider,
model,
&crate::llm::api::ThinkingConfig::Disabled,
option,
)
}
pub(crate) fn admit_portable_option_for_thinking(
provider: &str,
model: &str,
thinking: &crate::llm::api::ThinkingConfig,
option: PortableOption,
) -> Result<(), CapabilityAdmissionError> {
debug_assert_ne!(option, PortableOption::PromptCacheTtl);
let user = current_user_overrides();
let builtin = super::lookup::builtin();
let (supported, _) =
declared_portable_option_support(user.as_ref(), builtin, provider, model, option);
let rejected_while_reasoning = thinking.is_enabled()
&& super::lookup::lookup(provider, model)
.reasoning_excluded_portable_options
.contains(&option);
let requires_authored_support = matches!(
option,
PortableOption::Cache
| PortableOption::Logprobs
| PortableOption::LogitBias
| PortableOption::MinP
| PortableOption::RepetitionPenalty
| PortableOption::Prediction
| PortableOption::Verbosity
| PortableOption::Mirostat
| PortableOption::ParallelToolCalls
);
if !rejected_while_reasoning
&& (supported == Some(true) || (supported.is_none() && !requires_authored_support))
{
return Ok(());
}
Err(CapabilityAdmissionError {
provider: provider.to_string(),
model: model.to_string(),
option,
reasoning_enabled: rejected_while_reasoning,
requested_value: None,
supported_values: Vec::new(),
})
}
pub fn admit_prompt_cache_ttl(
provider: &str,
model: &str,
ttl: &str,
) -> Result<(), CapabilityAdmissionError> {
let user = current_user_overrides();
let builtin = super::lookup::builtin();
let (cache_supported, supported_values) = declared_portable_option_support(
user.as_ref(),
builtin,
provider,
model,
PortableOption::PromptCacheTtl,
);
match cache_supported {
Some(true)
if supported_values
.as_ref()
.is_some_and(|values| values.iter().any(|value| value == ttl)) =>
{
return Ok(())
}
Some(true) | Some(false) | None => {}
}
Err(CapabilityAdmissionError {
provider: provider.to_string(),
model: model.to_string(),
option: PortableOption::PromptCacheTtl,
reasoning_enabled: false,
requested_value: Some(ttl.to_string()),
supported_values: supported_values.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::capabilities::{clear_user_overrides, set_user_overrides_toml};
#[test]
fn rejects_declared_gap_and_keeps_unknown_routes_open_world() {
let rejected = admit_portable_option("moonshot", "kimi-k3", PortableOption::Temperature)
.expect_err("Kimi K3 rejects caller-selected temperature");
assert_eq!(rejected.option, PortableOption::Temperature);
assert!(rejected.to_string().contains("provider_options.moonshot"));
assert!(
admit_portable_option("my-proxy", "custom-model", PortableOption::Temperature,).is_ok()
);
}
#[test]
fn gemini_interactions_routes_reject_unrepresentable_penalties() {
for model in [
"gemini-3.6-flash",
"gemini-3.7-pro",
"gemini-3.5-flash-lite",
"models/gemini-3.5-flash-lite",
] {
for option in [
PortableOption::FrequencyPenalty,
PortableOption::PresencePenalty,
] {
let error = admit_portable_option("gemini", model, option)
.expect_err("Interactions has no penalty wire field");
assert_eq!(error.option, option, "unexpected admission for {model}");
}
}
}
#[test]
fn cache_and_ttl_admission_require_authored_lowering() {
set_user_overrides_toml(
r#"
[[provider.test-provider]]
model_match = "no-cache"
prompt_caching = false
[[provider.test-provider]]
model_match = "cache-with-ttl"
prompt_caching = true
prompt_cache_ttls = ["5m", "1h"]
"#,
)
.unwrap();
let cache = admit_portable_option("test-provider", "no-cache", PortableOption::Cache)
.expect_err("the synthetic route declares prompt caching unsupported");
assert_eq!(cache.option, PortableOption::Cache);
admit_prompt_cache_ttl("test-provider", "cache-with-ttl", "1h")
.expect("the synthetic route supports the one-hour TTL");
let unsupported = admit_prompt_cache_ttl("test-provider", "cache-with-ttl", "2h")
.expect_err("the synthetic route rejects an unlisted TTL");
assert_eq!(unsupported.requested_value.as_deref(), Some("2h"));
assert_eq!(unsupported.supported_values, ["5m", "1h"]);
let unknown = admit_prompt_cache_ttl("my-proxy", "custom-model", "1h")
.expect_err("unknown custom routes have no sound TTL lowering");
assert_eq!(unknown.option, PortableOption::PromptCacheTtl);
clear_user_overrides();
}
#[test]
fn advanced_generation_controls_require_an_authored_wire_lowering() {
for option in [PortableOption::Logprobs, PortableOption::LogitBias] {
admit_portable_option("openai", "gpt-4o", option)
.expect("OpenAI Chat has an authored lowering");
}
admit_portable_option("ollama", "qwen3", PortableOption::Mirostat)
.expect("Ollama has a native Mirostat lowering");
admit_portable_option(
"anthropic",
"claude-sonnet-4-20250514",
PortableOption::ParallelToolCalls,
)
.expect("Anthropic has a typed disable_parallel_tool_use lowering");
for (provider, model, option) in [
("gemini", "gemini-3.6-flash", PortableOption::Logprobs),
("groq", "llama-3.3-70b-versatile", PortableOption::Logprobs),
(
"anthropic",
"claude-sonnet-4-20250514",
PortableOption::LogitBias,
),
("gemini", "gemini-2.5-flash", PortableOption::LogitBias),
(
"gemini",
"gemini-2.5-flash",
PortableOption::ParallelToolCalls,
),
("my-proxy", "custom-model", PortableOption::Mirostat),
] {
admit_portable_option(provider, model, option)
.expect_err("an unowned wire projection must be rejected");
}
}
}