harness_gateway_client/wire/request.rs
1//! The chat-completions request body: the one JSON shape every transport
2//! sends for a round, built from the wire types and the frozen options.
3
4use promptforge::model::{CompletionOptions, Message, ToolSchema};
5use serde_json::Value;
6
7/// Builds the JSON body of a streaming chat-completions request.
8///
9/// The body names the model given by `options.model()` and carries
10/// `messages` as given. Every request streams, so `stream` is always true.
11/// The body also sets `stream_options.include_usage`. That asks the backend
12/// to send token usage in a final chunk with an empty `choices` list, so
13/// token counts still arrive when the reply streams as server-sent events.
14///
15/// When `tools` is `Some` and holds at least one schema, the body sends each
16/// schema in its `tools` array as an `OpenAI` function tool. Each entry is an
17/// object with `type` set to `"function"` and a `function` object that holds
18/// the tool's `name`, `description`, and `parameters`. The body then also
19/// sets `tool_choice` to `"auto"`. When `tools` is `None` or an empty slice,
20/// the body omits both fields and the request is a plain chat completion.
21///
22/// The optional settings in `options` extend the body only when set.
23/// `temperature` and `max_tokens` become fields of the same name. `thinking`
24/// becomes `chat_template_kwargs.enable_thinking`.
25///
26/// A transport that performs a `Chat` effect must build its request body
27/// here. It passes the effect's messages, tools, and options, sends the
28/// result as the JSON body of its chat-completions request, and later hands
29/// the same value to
30/// [`read_completion_stream`](crate::read_completion_stream). One shared
31/// builder keeps every transport sending the same request for one effect.
32#[must_use]
33pub fn build_request_body(
34 messages: &[Message],
35 tools: Option<&[ToolSchema]>,
36 options: &CompletionOptions,
37) -> Value {
38 let mut body = serde_json::json!({
39 "model": options.model(),
40 "messages": messages,
41 "stream": true,
42 "stream_options": { "include_usage": true },
43 });
44 if let Some(tools) = tools.filter(|tools| !tools.is_empty()) {
45 let wrapped: Vec<Value> = tools
46 .iter()
47 .map(|tool| {
48 serde_json::json!({
49 "type": "function",
50 "function": {
51 "name": tool.name(),
52 "description": tool.description(),
53 "parameters": tool.parameters(),
54 },
55 })
56 })
57 .collect();
58 body["tools"] = Value::Array(wrapped);
59 body["tool_choice"] = Value::String("auto".into());
60 }
61 if let Some(temperature) = options.temperature() {
62 body["temperature"] = serde_json::json!(temperature.get());
63 }
64 if let Some(max_tokens) = options.max_tokens() {
65 body["max_tokens"] = serde_json::json!(max_tokens.get());
66 }
67 if let Some(thinking) = options.thinking() {
68 body["chat_template_kwargs"] = serde_json::json!({
69 "enable_thinking": thinking,
70 });
71 }
72 body
73}
74
75#[cfg(test)]
76#[path = "request-tests.rs"]
77mod tests;