Skip to main content

theway_core/agent/
model_request.rs

1//! Provider-independent request data assembled immediately before model dispatch.
2
3use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use theway_llm_provider::{Message, ThinkingBudgets, ThinkingLevel, Tool};
7
8/// Generation controls that every built-in provider adapter can receive through
9/// [`theway_llm_provider::SimpleStreamOptions`]. Transport, authentication, retry,
10/// and provider-specific fields are intentionally excluded from extension patches.
11#[derive(Clone, Debug, Default, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase", deny_unknown_fields)]
13pub struct NormalizedGenerationOptions {
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub temperature: Option<f32>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub max_tokens: Option<u32>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub reasoning: Option<ThinkingLevel>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub thinking_budgets: Option<ThinkingBudgets>,
22}
23
24/// Request-local, provider-independent draft exposed to `before_model_request`.
25///
26/// `executable_tool_names` are stable references into the immutable registry
27/// snapshot captured while constructing this draft. The runtime resolves them
28/// back to executable implementations only after the replacement is validated.
29#[derive(Clone, Debug, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct NormalizedModelRequestDraft {
32    pub provider: String,
33    pub model: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub system_instructions: Option<String>,
36    #[serde(default)]
37    pub messages: Vec<Message>,
38    #[serde(default)]
39    pub visible_tools: Vec<Tool>,
40    #[serde(default)]
41    pub executable_tool_names: Vec<String>,
42    #[serde(default)]
43    pub generation_options: NormalizedGenerationOptions,
44}
45
46impl NormalizedModelRequestDraft {
47    /// Validate an extension replacement against the immutable base snapshot.
48    /// The caller keeps the base draft when validation fails, making the patch
49    /// atomic and request-local.
50    pub fn validate_replacement(&self, base: &Self, model_max_tokens: u32) -> Result<(), String> {
51        if self.provider != base.provider || self.model != base.model {
52            return Err("normalized request provider/model identity is immutable".into());
53        }
54        if self.provider.trim().is_empty() || self.model.trim().is_empty() {
55            return Err("normalized request provider/model identity cannot be empty".into());
56        }
57
58        let base_names = base
59            .executable_tool_names
60            .iter()
61            .map(String::as_str)
62            .collect::<HashSet<_>>();
63        let mut visible_names = HashSet::new();
64        for tool in &self.visible_tools {
65            if tool.name.trim().is_empty() || !visible_names.insert(tool.name.as_str()) {
66                return Err("visible tool names must be non-empty and unique".into());
67            }
68            if !tool.parameters.is_object() && !tool.parameters.is_boolean() {
69                return Err(format!(
70                    "visible tool '{}' parameters must be a JSON Schema object or boolean",
71                    tool.name
72                ));
73            }
74            if !base_names.contains(tool.name.as_str()) {
75                return Err(format!(
76                    "visible tool '{}' has no executable reference in the base request",
77                    tool.name
78                ));
79            }
80        }
81
82        let mut executable_names = HashSet::new();
83        for name in &self.executable_tool_names {
84            if !executable_names.insert(name.as_str()) || !base_names.contains(name.as_str()) {
85                return Err(
86                    "executable tool references must be unique members of the base request".into(),
87                );
88            }
89        }
90        if executable_names != visible_names {
91            return Err(
92                "visible tool definitions and executable tool references must name the same catalog"
93                    .into(),
94            );
95        }
96
97        if self
98            .generation_options
99            .temperature
100            .is_some_and(|temperature| !temperature.is_finite())
101        {
102            return Err("generation temperature must be finite".into());
103        }
104        if let Some(max_tokens) = self.generation_options.max_tokens {
105            if max_tokens == 0 || (model_max_tokens > 0 && max_tokens > model_max_tokens) {
106                return Err("generation maxTokens must be within the selected model limit".into());
107            }
108        }
109        if self
110            .generation_options
111            .thinking_budgets
112            .as_ref()
113            .is_some_and(|budgets| {
114                [budgets.minimal, budgets.low, budgets.medium, budgets.high]
115                    .into_iter()
116                    .flatten()
117                    .any(|budget| budget == 0)
118            })
119        {
120            return Err("thinking budgets must be greater than zero".into());
121        }
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod coverage_gap {
128    use super::*;
129    use theway_llm_provider::Tool;
130
131    fn tool(name: &str, parameters: serde_json::Value) -> Tool {
132        Tool {
133            name: name.to_string(),
134            description: String::new(),
135            parameters,
136        }
137    }
138
139    fn draft(provider: &str, model: &str, tools: &[&str]) -> NormalizedModelRequestDraft {
140        NormalizedModelRequestDraft {
141            provider: provider.to_string(),
142            model: model.to_string(),
143            system_instructions: None,
144            messages: Vec::new(),
145            visible_tools: tools
146                .iter()
147                .map(|name| tool(name, serde_json::json!({"type": "object"})))
148                .collect(),
149            executable_tool_names: tools.iter().map(|name| name.to_string()).collect(),
150            generation_options: NormalizedGenerationOptions::default(),
151        }
152    }
153
154    #[test]
155    fn validates_identical_draft_and_model_identity() {
156        let base = draft("p", "m", &["t"]);
157        assert!(
158            draft("p", "m", &["t"])
159                .validate_replacement(&base, 100)
160                .is_ok()
161        );
162
163        let model_changed = draft("p", "other", &["t"])
164            .validate_replacement(&base, 100)
165            .unwrap_err();
166        assert!(model_changed.contains("identity is immutable"));
167
168        let empty_provider = draft("", "m", &["t"])
169            .validate_replacement(&draft("", "m", &["t"]), 100)
170            .unwrap_err();
171        assert!(empty_provider.contains("cannot be empty"));
172
173        let empty_model = draft("p", "", &["t"])
174            .validate_replacement(&draft("p", "", &["t"]), 100)
175            .unwrap_err();
176        assert!(empty_model.contains("cannot be empty"));
177    }
178
179    #[test]
180    fn validates_visible_tool_names_and_parameters() {
181        let base = draft("p", "m", &["t"]);
182
183        let mut empty_name = draft("p", "m", &["t"]);
184        empty_name.visible_tools = vec![tool("", serde_json::json!({"type": "object"}))];
185        assert!(
186            empty_name
187                .validate_replacement(&base, 100)
188                .unwrap_err()
189                .contains("non-empty and unique")
190        );
191
192        let mut duplicate = draft("p", "m", &["t", "t"]);
193        duplicate.visible_tools = vec![
194            tool("t", serde_json::json!({"type": "object"})),
195            tool("t", serde_json::json!({"type": "object"})),
196        ];
197        assert!(
198            duplicate
199                .validate_replacement(&base, 100)
200                .unwrap_err()
201                .contains("non-empty and unique")
202        );
203
204        let mut boolean_params = draft("p", "m", &["t"]);
205        boolean_params.visible_tools = vec![tool("t", serde_json::Value::Bool(true))];
206        assert!(boolean_params.validate_replacement(&base, 100).is_ok());
207
208        let mut missing_ref = draft("p", "m", &["t"]);
209        missing_ref.visible_tools = vec![tool("other", serde_json::json!({"type": "object"}))];
210        missing_ref.executable_tool_names = vec!["other".to_string()];
211        assert!(
212            missing_ref
213                .validate_replacement(&base, 100)
214                .unwrap_err()
215                .contains("no executable reference")
216        );
217    }
218
219    #[test]
220    fn validates_executable_tool_names_and_set_equality() {
221        let base = draft("p", "m", &["t"]);
222
223        let mut duplicate = draft("p", "m", &["t"]);
224        duplicate.executable_tool_names = vec!["t".to_string(), "t".to_string()];
225        assert!(
226            duplicate
227                .validate_replacement(&base, 100)
228                .unwrap_err()
229                .contains("unique members")
230        );
231
232        let mut missing = draft("p", "m", &["t"]);
233        missing.executable_tool_names = vec!["other".to_string()];
234        assert!(
235            missing
236                .validate_replacement(&base, 100)
237                .unwrap_err()
238                .contains("unique members")
239        );
240
241        let mut mismatch = draft("p", "m", &["t"]);
242        mismatch.executable_tool_names = Vec::new();
243        assert!(
244            mismatch
245                .validate_replacement(&base, 100)
246                .unwrap_err()
247                .contains("same catalog")
248        );
249    }
250
251    #[test]
252    fn validates_generation_options_limits() {
253        let base = draft("p", "m", &[]);
254
255        let mut non_finite = draft("p", "m", &[]);
256        non_finite.generation_options.temperature = Some(f32::NAN);
257        assert!(
258            non_finite
259                .validate_replacement(&base, 100)
260                .unwrap_err()
261                .contains("temperature must be finite")
262        );
263
264        let mut zero_max = draft("p", "m", &[]);
265        zero_max.generation_options.max_tokens = Some(0);
266        assert!(
267            zero_max
268                .validate_replacement(&base, 100)
269                .unwrap_err()
270                .contains("maxTokens")
271        );
272
273        let mut over_max = draft("p", "m", &[]);
274        over_max.generation_options.max_tokens = Some(200);
275        assert!(
276            over_max
277                .validate_replacement(&base, 100)
278                .unwrap_err()
279                .contains("maxTokens")
280        );
281
282        let mut no_limit = draft("p", "m", &[]);
283        no_limit.generation_options.max_tokens = Some(200);
284        assert!(no_limit.validate_replacement(&base, 0).is_ok());
285
286        let mut zero_budget = draft("p", "m", &[]);
287        zero_budget.generation_options.thinking_budgets = Some(ThinkingBudgets {
288            minimal: Some(0),
289            low: Some(1),
290            medium: Some(1),
291            high: Some(1),
292        });
293        assert!(
294            zero_budget
295                .validate_replacement(&base, 100)
296                .unwrap_err()
297                .contains("thinking budgets")
298        );
299    }
300}