1use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use theway_llm_provider::{Message, ThinkingBudgets, ThinkingLevel, Tool};
7
8#[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#[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 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}