Skip to main content

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;