Skip to main content

cherenkov/server/
request.rs

1//! Completion options, chat rendering, tokenization and context policy.
2
3use 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    /// Contract for tool-call output parsing; `Some` while tools are enabled.
25    pub(super) tool_contract: Option<Arc<ToolCallOutputContract>>,
26}
27
28/// Resolved session input; the original HTTP JSON remains unchanged.
29pub(super) struct SessionInput {
30    pub messages: Vec<Value>,
31    pub sampling: Sampling,
32    pub context: usize,
33}
34
35/// Tokenization and policy checks finish before the response starts.
36pub(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            // MTP state depends on the following token. At the reusable chat
83            // prefix, keep that token inside the known-stable rendered text.
84            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    // Chat parses tool controls below; completions never take them.
126    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}
249/// Shape a request's tools for the checkpoint template; reject types the
250/// Qwen4 frontend cannot honor.
251fn 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        // The template emits `tool | tojson`; keep the shape the Qwen tool
318        // contract expects: a function wrapper whose non-guaranteed `strict`
319        // flag is normalized to false.
320        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
344/// Resolve `tool_choice`; `false` suppresses the tools block entirely.
345fn 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
399/// Tolerate legacy OpenAI function controls the Qwen4 frontend ignores.
400fn 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;