Skip to main content

llm_api/
policy.rs

1//! Pure invocation policy shared by deployments. No discovery or provider routing.
2use crate::ModelConstraints;
3use serde::{Deserialize, Serialize};
4
5/// Confirmed model capabilities. Missing information never establishes support.
6#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
7pub struct ModelCapabilities {
8    pub vision: bool,
9    pub tool_calling: bool,
10    pub structured_output: bool,
11}
12
13impl ModelConstraints {
14    /// Checks declarations only, including requirements on requests with no images/tools.
15    pub fn validate(&self, capabilities: &ModelCapabilities) -> Result<(), &'static str> {
16        for (required, supported, name) in [
17            (self.vision, capabilities.vision, "vision"),
18            (self.tool_calling, capabilities.tool_calling, "tool_calling"),
19            (
20                self.structured_output,
21                capabilities.structured_output,
22                "structured_output",
23            ),
24        ] {
25            if required && !supported {
26                return Err(name);
27            }
28        }
29        Ok(())
30    }
31}
32
33/// Deployment-owned generation settings, never supplied through Agent constraints.
34/// None leaves the setting unspecified. These are desired preferences: adapters may omit
35/// unsupported settings. Syntax validation does not establish model/provider support.
36#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct GenerationParameters {
39    pub reasoning_effort: Option<String>,
40    pub temperature: Option<f64>,
41}
42
43impl GenerationParameters {
44    pub fn validate(&self) -> Result<(), &'static str> {
45        if let Some(effort) = self.reasoning_effort.as_deref() {
46            if !matches!(
47                effort,
48                "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"
49            ) {
50                return Err("invalid reasoning_effort");
51            }
52        }
53        if self
54            .temperature
55            .is_some_and(|value| !value.is_finite() || value < 0.0)
56        {
57            return Err("temperature must be finite and nonnegative");
58        }
59        Ok(())
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    #[test]
67    fn declarations_are_requirements_not_prohibitions() {
68        let supported = ModelCapabilities {
69            vision: true,
70            tool_calling: true,
71            structured_output: true,
72        };
73        assert!(ModelConstraints::default().validate(&supported).is_ok());
74        assert!(ModelConstraints::default()
75            .validate(&ModelCapabilities::default())
76            .is_ok());
77        for constraints in [
78            ModelConstraints {
79                vision: true,
80                ..Default::default()
81            },
82            ModelConstraints {
83                tool_calling: true,
84                ..Default::default()
85            },
86            ModelConstraints {
87                structured_output: true,
88                ..Default::default()
89            },
90        ] {
91            assert!(constraints.validate(&supported).is_ok());
92            assert!(constraints.validate(&ModelCapabilities::default()).is_err());
93        }
94    }
95    #[test]
96    fn retired_generation_control_is_not_silently_ignored() {
97        assert!(serde_json::from_value::<ModelConstraints>(serde_json::json!({
98            "vision": false, "tool_calling": false, "structured_output": false, "reasoning": "high"
99        })).is_err());
100    }
101    #[test]
102    fn validates_explicit_generation_settings() {
103        for effort in ["none", "low", "high", "max", "ultra"] {
104            assert!(GenerationParameters {
105                reasoning_effort: Some(effort.into()),
106                temperature: None
107            }
108            .validate()
109            .is_ok());
110        }
111        assert!(GenerationParameters {
112            reasoning_effort: Some("unknown".into()),
113            temperature: None
114        }
115        .validate()
116        .is_err());
117        for temperature in [-1.0, f64::NAN, f64::INFINITY] {
118            assert!(GenerationParameters {
119                temperature: Some(temperature),
120                ..Default::default()
121            }
122            .validate()
123            .is_err());
124        }
125    }
126}