1use super::{ApiKind, MODEL, tool_call::ToolCallOutputContract};
4use crate::{
5 config::Defaults,
6 options::Options,
7 prompt::{ChatTemplate, Prompt, valid_function_name},
8 runner,
9 sampling::Sampling,
10 tok::ChatTokenizer,
11};
12use anyhow::{Context, Result, ensure};
13use serde_json::{Map, Value, json};
14use std::sync::Arc;
15
16pub(super) struct Request {
17 prompt: Prompt,
18 pub(super) max_tokens: usize,
19 pub(super) stream: bool,
20 pub(super) include_usage: bool,
21 no_eos: bool,
22 sampling: Sampling,
23 context: Option<usize>,
24 pub(super) tool_contract: Option<Arc<ToolCallOutputContract>>,
26}
27
28pub(super) struct SessionInput {
30 pub messages: Vec<Value>,
31 pub sampling: Sampling,
32 pub context: usize,
33}
34
35pub(super) struct PreparedRequest {
37 pub(super) request: Request,
38 pub(super) ids: Vec<u32>,
39 pub(super) boundaries: Vec<usize>,
40 pub(super) options: Options,
41}
42
43impl Request {
44 pub(super) fn prepare(
45 self,
46 tok: &ChatTokenizer,
47 options: &Options,
48 max_output_tokens: usize,
49 ) -> Result<PreparedRequest> {
50 ensure!(
51 self.max_tokens <= max_output_tokens,
52 "max_tokens exceeds server output policy"
53 );
54
55 let ids = tok.encode(&self.prompt.text)?;
56 let mut options = options.clone();
57 options.no_eos = self.no_eos;
58 options.sampling = self.sampling.clone();
59
60 if let Some(context) = self.context {
61 ensure!(
62 context > 0 && context <= options.max_ctx,
63 "context_tokens exceeds server capacity"
64 );
65
66 options.max_ctx = context;
67 }
68
69 runner::check_budget(
70 ids.len(),
71 self.max_tokens,
72 options.effective_drafts(),
73 options.max_ctx,
74 )?;
75
76 let mut boundaries = Vec::new();
77
78 for (index, end) in self.prompt.boundaries.into_iter().enumerate() {
79 let prefix = tok.encode(&self.prompt.text[..end])?;
80 let mut matched = ids.iter().zip(&prefix).take_while(|(a, b)| a == b).count();
81
82 if index == 0 && options.effective_drafts() > 0 {
85 matched = matched.saturating_sub(1);
86 }
87
88 boundaries.push(matched);
89 }
90
91 Ok(PreparedRequest {
92 request: self,
93 ids,
94 boundaries,
95 options,
96 })
97 }
98}
99
100pub(super) fn parse_request(
101 v: &Value,
102 kind: ApiKind,
103 defaults: &Defaults,
104 session: Option<&SessionInput>,
105 template: Option<&ChatTemplate>,
106) -> Result<Request> {
107 ensure!(v.is_object(), "request must be a JSON object");
108 ensure!(
109 v["model"].is_null() || v["model"].as_str() == Some(MODEL),
110 "model must be cherenkov"
111 );
112 ensure!(
113 v["n"].is_null() || v["n"].as_u64() == Some(1),
114 "n must be 1"
115 );
116
117 let (sampling, context) = match session {
118 Some(input) => (input.sampling.clone(), Some(input.context)),
119 None => (
120 Sampling::from_request(v, &defaults.sampling)?,
121 context_tokens(v)?,
122 ),
123 };
124
125 let tool_keys: &[&str] = match kind {
127 ApiKind::Chat => &[],
128 ApiKind::Completion => &["tools", "tool_choice", "functions", "function_call"],
129 };
130
131 for key in [
132 "stop",
133 "logprobs",
134 "top_logprobs",
135 "logit_bias",
136 "suffix",
137 "reasoning_effort",
138 "chat_template_kwargs",
139 "previous_response_id",
140 "conversation",
141 "min_p",
142 "repetition_penalty",
143 ] {
144 ensure!(v[key].is_null(), "{key} is not supported");
145 }
146
147 for key in tool_keys {
148 ensure!(v[key].is_null(), "{key} is not supported");
149 }
150
151 ensure!(
152 v["response_format"].is_null() || v["response_format"] == json!({"type":"text"}),
153 "only text response_format is supported"
154 );
155
156 let max_tokens = v
157 .get("max_completion_tokens")
158 .filter(|v| !v.is_null())
159 .or_else(|| v.get("max_tokens").filter(|v| !v.is_null()))
160 .map(|n| {
161 n.as_u64()
162 .and_then(|n| usize::try_from(n).ok())
163 .filter(|n| *n > 0)
164 .context("max_tokens must be a positive integer")
165 })
166 .transpose()?
167 .unwrap_or(defaults.max_tokens);
168 let stream = v
169 .get("stream")
170 .map(|v| v.as_bool().context("stream must be boolean"))
171 .transpose()?
172 .unwrap_or(defaults.stream);
173 let include_usage = match v.get("stream_options").filter(|v| !v.is_null()) {
174 Some(o) => {
175 ensure!(o.is_object(), "stream_options must be an object");
176
177 o.get("include_usage")
178 .map(|v| v.as_bool().context("include_usage must be boolean"))
179 .transpose()?
180 .unwrap_or(defaults.include_usage)
181 }
182 None => defaults.include_usage,
183 };
184 let mut tools = Vec::new();
185 let mut tool_contract = None;
186
187 if kind == ApiKind::Chat {
188 let parsed = parse_tools(v)?;
189 let enabled = parse_tool_choice(v)?;
190
191 parse_parallel_tool_calls(v, enabled && !parsed.is_empty())?;
192 parse_legacy_function_controls(v)?;
193
194 if enabled {
195 tools = parsed;
196 tool_contract = (!tools.is_empty()).then(|| ToolCallOutputContract::from_tools(&tools));
197 }
198 }
199
200 let prompt = render_prompt(v, kind, session, template, &tools)?;
201
202 Ok(Request {
203 prompt,
204 max_tokens,
205 stream,
206 include_usage,
207 no_eos: defaults.no_eos,
208 sampling,
209 context,
210 tool_contract,
211 })
212}
213
214fn render_prompt(
215 v: &Value,
216 kind: ApiKind,
217 session: Option<&SessionInput>,
218 template: Option<&ChatTemplate>,
219 tools: &[Value],
220) -> Result<Prompt> {
221 if kind == ApiKind::Completion {
222 let text = v["prompt"].as_str().context("prompt must be a string")?;
223
224 return Ok(Prompt::raw(text.to_owned()));
225 }
226
227 let messages = match session {
228 Some(input) => input.messages.as_slice(),
229 None => v["messages"]
230 .as_array()
231 .context("messages must be an array")?,
232 };
233
234 template
235 .context("checkpoint has no chat template")?
236 .chat(messages, Some(tools))
237}
238
239fn context_tokens(v: &Value) -> Result<Option<usize>> {
240 v.get("context_tokens")
241 .filter(|v| !v.is_null())
242 .map(|v| {
243 v.as_u64()
244 .and_then(|n| usize::try_from(n).ok())
245 .context("context_tokens must be a positive integer")
246 })
247 .transpose()
248}
249fn parse_tools(v: &Value) -> Result<Vec<Value>> {
252 let mut tools = Vec::new();
253
254 if v["tools"].is_null() {
255 return Ok(tools);
256 }
257
258 for entry in v["tools"].as_array().context("tools must be an array")? {
259 let ty = entry["type"]
260 .as_str()
261 .context("tools entries must contain a string type")?;
262
263 ensure!(
264 ty == "function",
265 "tool type '{ty}' requires a non-function output contract, which is not supported; use function tools"
266 );
267
268 let function = entry.get("function").filter(|value| !value.is_null());
269 let function = function
270 .and_then(Value::as_object)
271 .context("function tools must contain a function object")?;
272 let name = function
273 .get("name")
274 .and_then(Value::as_str)
275 .context("function name must be a string")?;
276
277 ensure!(
278 valid_function_name(name),
279 "function name must match [A-Za-z0-9_-]{{1,64}}"
280 );
281
282 let description = function
283 .get("description")
284 .filter(|value| !value.is_null())
285 .map(|value| {
286 value
287 .as_str()
288 .context("function description must be a string")
289 })
290 .transpose()?;
291
292 let parameters = function.get("parameters").filter(|value| !value.is_null());
293 let parameters = match parameters {
294 Some(parameters) => {
295 parameters
296 .as_object()
297 .context("function parameters must be a JSON object")?;
298
299 parameters.clone()
300 }
301 None => json!({"type": "object", "properties": {}}),
302 };
303
304 if let Some(strict) = function.get("strict").filter(|value| !value.is_null()) {
305 ensure!(strict.is_boolean(), "function strict must be a boolean");
306
307 let strict_value = strict
308 .as_bool()
309 .context("function strict must be a boolean")?;
310
311 ensure!(
312 !strict_value,
313 "strict=true requires generated arguments to satisfy the declared schema, which cannot be guaranteed; omit strict or use false"
314 );
315 }
316
317 let mut shaped_function = Map::new();
321
322 shaped_function.insert("name".to_owned(), Value::String(name.to_owned()));
323 shaped_function.insert("parameters".to_owned(), parameters);
324 shaped_function.insert("strict".to_owned(), Value::Bool(false));
325
326 if let Some(description) = description {
327 shaped_function.insert(
328 "description".to_owned(),
329 Value::String(description.to_owned()),
330 );
331 }
332
333 let mut shaped = Map::new();
334
335 shaped.insert("type".to_owned(), Value::String("function".to_owned()));
336 shaped.insert("function".to_owned(), Value::Object(shaped_function));
337
338 tools.push(Value::Object(shaped));
339 }
340
341 Ok(tools)
342}
343
344fn parse_tool_choice(v: &Value) -> Result<bool> {
346 let Some(choice) = v.get("tool_choice").filter(|value| !value.is_null()) else {
347 return Ok(true);
348 };
349
350 if let Some(ty) = choice.as_str() {
351 return match ty {
352 "auto" => Ok(true),
353 "none" => Ok(false),
354 "required" => anyhow::bail!(
355 "tool_choice='required' requires at least one tool call, which cannot be guaranteed; use 'auto' or 'none'"
356 ),
357 other => anyhow::bail!(
358 "tool_choice must be 'auto', 'none', or a function choice, not '{other}'"
359 ),
360 };
361 }
362
363 let object = choice
364 .as_object()
365 .context("tool_choice must be a string or object")?;
366 let ty = object["type"]
367 .as_str()
368 .context("tool_choice objects must contain a string type")?;
369
370 if ty == "function" {
371 anyhow::bail!(
372 "tool_choice for a specific function forces that function to be called, which cannot be guaranteed; use 'auto' or 'none'"
373 );
374 }
375
376 anyhow::bail!("unsupported tool_choice type '{ty}'")
377}
378
379fn parse_parallel_tool_calls(v: &Value, tools_enabled: bool) -> Result<()> {
380 let Some(value) = v
381 .get("parallel_tool_calls")
382 .filter(|value| !value.is_null())
383 else {
384 return Ok(());
385 };
386
387 let value = value
388 .as_bool()
389 .context("parallel_tool_calls must be a boolean")?;
390
391 ensure!(
392 value || !tools_enabled,
393 "parallel_tool_calls=false requires the model to emit at most one tool call, which cannot be guaranteed while tools are enabled"
394 );
395
396 Ok(())
397}
398
399fn parse_legacy_function_controls(v: &Value) -> Result<()> {
401 let Some(functions) = v.get("functions").filter(|value| !value.is_null()) else {
402 return legacy_function_call(v);
403 };
404
405 let entries = functions.as_array().context("functions must be an array")?;
406
407 ensure!(
408 entries.is_empty(),
409 "non-empty legacy functions require the single-function-call response contract, which is not supported; use tools instead"
410 );
411
412 legacy_function_call(v)
413}
414
415fn legacy_function_call(v: &Value) -> Result<()> {
416 let Some(choice) = v.get("function_call").filter(|value| !value.is_null()) else {
417 return Ok(());
418 };
419
420 if let Some(value) = choice.as_str() {
421 ensure!(
422 value == "none" || value == "auto",
423 "function_call must be 'none', 'auto', or a named function choice"
424 );
425
426 return Ok(());
427 }
428
429 ensure!(
430 choice.is_object(),
431 "function_call must be 'none', 'auto', or an object"
432 );
433 anyhow::bail!(
434 "a named legacy function_call forces that function to be called, which cannot be guaranteed; use tool_choice instead"
435 );
436}
437#[cfg(test)]
438#[path = "../../tests/unit/server/request.rs"]
439mod tests;