use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::shared::api::contract::{ApiImage, ApiMessage, ApiRole, ChatRequest, FinishReason};
#[derive(Debug, Serialize)]
pub struct RespRequest {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub input: Vec<Value>,
pub stream: bool,
pub store: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub include: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<RespReasoning>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<RespText>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<RespTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<&'static str>,
}
#[derive(Debug, Serialize)]
pub struct RespReasoning {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<&'static str>,
}
#[derive(Debug, Serialize)]
pub struct RespText {
pub verbosity: &'static str,
}
#[derive(Debug, Serialize)]
pub struct RespTool {
#[serde(rename = "type")]
pub kind: &'static str,
pub name: String,
pub description: String,
pub parameters: Value,
pub strict: bool,
}
pub fn build_request(req: &ChatRequest, model: &str, stream: bool) -> RespRequest {
let s = &req.sampling;
let force_off = s.reasoning_budget == Some(0);
let want_summary = !force_off && s.thinking == Some(true);
let effort = if force_off {
Some("none")
} else {
s.reasoning_effort.map(|e| e.as_wire())
};
let reasoning = (want_summary || effort.is_some()).then_some(RespReasoning {
effort,
summary: want_summary.then_some("detailed"),
});
let include = if reasoning.is_some() {
vec!["reasoning.encrypted_content"]
} else {
Vec::new()
};
let tools = if req.tools.is_empty() {
None
} else {
Some(
req.tools
.iter()
.map(|t| RespTool {
kind: "function",
name: t.name.clone(),
description: t.description.clone(),
parameters: t.parameters.clone(),
strict: false,
})
.collect(),
)
};
let tool_choice = tools.as_ref().map(|_| "auto");
RespRequest {
model: model.to_string(),
instructions: req.system.clone(),
input: build_input(req),
stream,
store: false,
include,
max_output_tokens: s.max_tokens,
reasoning,
text: s.verbosity.map(|v| RespText {
verbosity: v.as_wire(),
}),
tools,
tool_choice,
}
}
fn build_input(req: &ChatRequest) -> Vec<Value> {
let mut items = Vec::new();
for m in &req.messages {
match m.role {
ApiRole::System => continue,
ApiRole::User => items.push(json!({
"type": "message", "role": "user", "content": user_content(m),
})),
ApiRole::Assistant => push_assistant_items(m, &mut items),
ApiRole::Tool => items.push(json!({
"type": "function_call_output",
"call_id": m.tool_call_id.clone().unwrap_or_default(),
"output": tool_output(m),
})),
}
}
items
}
fn user_content(m: &ApiMessage) -> Value {
if m.images.is_empty() {
return json!(m.content);
}
let mut parts = image_parts(&m.images);
if !m.content.is_empty() {
parts.push(json!({ "type": "input_text", "text": m.content }));
}
json!(parts)
}
fn tool_output(m: &ApiMessage) -> Value {
if m.images.is_empty() {
return json!(m.content);
}
let mut parts = Vec::with_capacity(m.images.len() * 2 + 1);
if !m.content.is_empty() {
parts.push(json!({ "type": "input_text", "text": m.content }));
}
parts.extend(image_parts(&m.images));
json!(parts)
}
fn image_parts(images: &[ApiImage]) -> Vec<Value> {
let mut parts = Vec::with_capacity(images.len() * 2);
for image in images {
if let Some(label) = &image.label {
parts.push(json!({ "type": "input_text", "text": label }));
}
parts.push(json!({
"type": "input_image",
"image_url": format!("data:{};base64,{}", image.mime, image.data),
}));
}
parts
}
fn push_assistant_items(m: &ApiMessage, items: &mut Vec<Value>) {
for tb in &m.thinking {
let Some(id) = &tb.id else { continue };
items.push(json!({
"type": "reasoning",
"id": id,
"summary": [],
"encrypted_content": tb.signature,
}));
}
if !m.content.is_empty() {
items.push(json!({
"type": "message", "role": "assistant", "content": m.content,
}));
}
for tc in &m.tool_calls {
let args = if tc.arguments.is_empty() {
"{}"
} else {
tc.arguments.as_str()
};
items.push(json!({
"type": "function_call",
"call_id": tc.id,
"name": tc.name,
"arguments": args,
}));
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum RespEvent {
#[serde(rename = "response.output_text.delta")]
OutputTextDelta {
#[serde(default)]
delta: String,
},
#[serde(rename = "response.reasoning_summary_text.delta")]
ReasoningSummaryDelta {
#[serde(default)]
delta: String,
},
#[serde(rename = "response.reasoning_text.delta")]
ReasoningTextDelta {
#[serde(default)]
delta: String,
},
#[serde(rename = "response.output_item.added")]
OutputItemAdded {
#[serde(default)]
output_index: usize,
item: RespItem,
},
#[serde(rename = "response.output_item.done")]
OutputItemDone { item: RespItem },
#[serde(rename = "response.function_call_arguments.delta")]
FunctionArgsDelta {
#[serde(default)]
output_index: usize,
#[serde(default)]
delta: String,
},
#[serde(rename = "response.completed")]
Completed { response: RespBody },
#[serde(rename = "response.incomplete")]
Incomplete {
#[serde(default)]
response: Option<RespBody>,
},
#[serde(rename = "response.failed")]
Failed {
#[serde(default)]
response: Option<RespBody>,
},
#[serde(rename = "error")]
Error {
#[serde(default)]
code: Option<String>,
#[serde(default)]
message: Option<String>,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum RespItem {
#[serde(rename = "function_call")]
FunctionCall {
#[serde(default)]
call_id: String,
#[serde(default)]
name: String,
},
#[serde(rename = "reasoning")]
Reasoning {
#[serde(default)]
id: String,
#[serde(default)]
encrypted_content: Option<String>,
},
#[serde(other)]
Other,
}
#[derive(Debug, Default, Deserialize)]
pub struct RespBody {
#[serde(default)]
pub usage: Option<RespUsage>,
#[serde(default)]
pub error: Option<RespError>,
#[serde(default)]
pub incomplete_details: Option<RespIncomplete>,
}
#[derive(Debug, Default, Deserialize)]
pub struct RespIncomplete {
#[serde(default)]
pub reason: String,
}
impl RespIncomplete {
pub fn finish_reason(details: Option<&Self>) -> FinishReason {
match details.map(|d| d.reason.as_str()) {
Some("content_filter") => FinishReason::Filtered,
_ => FinishReason::Length,
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct RespError {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct RespUsage {
#[serde(default)]
pub input_tokens: u32,
#[serde(default)]
pub output_tokens: u32,
#[serde(default)]
pub output_tokens_details: RespOutputTokensDetails,
}
#[derive(Debug, Default, Deserialize)]
pub struct RespOutputTokensDetails {
#[serde(default)]
pub reasoning_tokens: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::sampling::{ReasoningEffort, SamplingConfig, Verbosity};
use crate::shared::api::contract::{ApiMessage, ApiToolCall, ThinkingBlock, ToolSchema};
fn base_req(messages: Vec<ApiMessage>) -> ChatRequest {
ChatRequest {
continue_final: false,
system: Some("Ты — ассистент.".into()),
messages,
sampling: SamplingConfig {
max_tokens: Some(256),
..Default::default()
},
tools: vec![],
}
}
#[test]
fn a_text_only_user_item_keeps_its_string_content() {
let json = serde_json::to_value(build_request(
&base_req(vec![ApiMessage::user("привет")]),
"gpt-5",
false,
))
.unwrap();
assert_eq!(json["input"][0]["type"], "message");
assert!(
json["input"][0]["content"].is_string(),
"got {:?}",
json["input"][0]["content"]
);
assert_eq!(json["input"][0]["content"], "привет");
}
#[test]
fn images_become_input_image_parts_ahead_of_the_text() {
let msg =
ApiMessage::user("what is this?").with_images(vec![crate::shared::api::ApiImage::new(
"image/png",
"QUJD",
Some("Image #1 — \"a.png\":".into()),
)]);
let json =
serde_json::to_value(build_request(&base_req(vec![msg]), "gpt-5", false)).unwrap();
let parts = json["input"][0]["content"].as_array().unwrap();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0]["type"], "input_text");
assert_eq!(parts[0]["text"], "Image #1 — \"a.png\":");
assert_eq!(parts[1]["type"], "input_image");
assert_eq!(parts[1]["image_url"], "data:image/png;base64,QUJD");
assert_eq!(parts[2]["type"], "input_text");
assert_eq!(parts[2]["text"], "what is this?");
}
#[test]
fn an_image_only_item_carries_no_empty_text_part() {
let msg = ApiMessage::user("").with_images(vec![crate::shared::api::ApiImage::new(
"image/jpeg",
"QQ==",
None,
)]);
let json =
serde_json::to_value(build_request(&base_req(vec![msg]), "gpt-5", false)).unwrap();
let parts = json["input"][0]["content"].as_array().unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["image_url"], "data:image/jpeg;base64,QQ==");
}
#[test]
fn a_tool_output_without_images_keeps_its_string_form() {
let r = base_req(vec![
ApiMessage::user("посчитай"),
ApiMessage::assistant_tool_calls(
"",
vec![ApiToolCall {
thought_signature: None,
id: "call_1".into(),
name: "calc".into(),
arguments: "{}".into(),
}],
),
ApiMessage::tool("call_1", "2"),
]);
let json = serde_json::to_value(build_request(&r, "gpt-x", false)).unwrap();
assert_eq!(json["input"][2]["type"], "function_call_output");
assert!(
json["input"][2]["output"].is_string(),
"got {:?}",
json["input"][2]["output"]
);
assert_eq!(json["input"][2]["output"], "2");
}
#[test]
fn a_tool_output_image_follows_the_result_text() {
let r = base_req(vec![
ApiMessage::user("сними скриншот"),
ApiMessage::assistant_tool_calls(
"",
vec![ApiToolCall {
thought_signature: None,
id: "call_1".into(),
name: "screenshot".into(),
arguments: "{}".into(),
}],
),
ApiMessage::tool("call_1", "screenshot taken").with_images(vec![
crate::shared::api::ApiImage::new(
"image/png",
"QUJD",
Some("Image #1 — \"shot.png\":".into()),
),
]),
]);
let json = serde_json::to_value(build_request(&r, "gpt-x", false)).unwrap();
let item = &json["input"][2];
assert_eq!(item["type"], "function_call_output");
assert_eq!(item["call_id"], "call_1");
let parts = item["output"].as_array().unwrap();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0]["type"], "input_text");
assert_eq!(parts[0]["text"], "screenshot taken");
assert_eq!(parts[1]["type"], "input_text");
assert_eq!(parts[1]["text"], "Image #1 — \"shot.png\":");
assert_eq!(parts[2]["type"], "input_image");
assert_eq!(parts[2]["image_url"], "data:image/png;base64,QUJD");
}
#[test]
fn an_image_only_tool_output_carries_no_empty_text_part() {
let r = base_req(vec![ApiMessage::tool("call_1", "").with_images(vec![
crate::shared::api::ApiImage::new("image/jpeg", "QQ==", None),
])]);
let json = serde_json::to_value(build_request(&r, "gpt-x", false)).unwrap();
let parts = json["input"][0]["output"].as_array().unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["type"], "input_image");
assert_eq!(parts[0]["image_url"], "data:image/jpeg;base64,QQ==");
}
#[test]
fn system_is_instructions_and_store_false() {
let json = serde_json::to_value(build_request(
&base_req(vec![ApiMessage::user("hi")]),
"gpt-x",
true,
))
.unwrap();
assert_eq!(json["model"], "gpt-x");
assert_eq!(json["instructions"], "Ты — ассистент.");
assert_eq!(json["store"], false);
assert_eq!(json["max_output_tokens"], 256);
assert!(json.get("max_tokens").is_none());
assert_eq!(json["input"][0]["type"], "message");
assert_eq!(json["input"][0]["role"], "user");
assert_eq!(json["input"][0]["content"], "hi");
assert!(json.get("include").is_none());
assert!(json.get("reasoning").is_none());
assert!(json.get("text").is_none());
}
#[test]
fn reasoning_summary_and_effort_and_verbosity() {
let mut r = base_req(vec![ApiMessage::user("посчитай")]);
r.sampling.thinking = Some(true);
r.sampling.reasoning_effort = Some(ReasoningEffort::XHigh);
r.sampling.verbosity = Some(Verbosity::Low);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["reasoning"]["effort"], "xhigh");
assert_eq!(json["reasoning"]["summary"], "detailed");
assert_eq!(json["text"]["verbosity"], "low");
assert_eq!(json["include"][0], "reasoning.encrypted_content");
}
#[test]
fn reasoning_budget_zero_forces_off() {
let mut r = base_req(vec![ApiMessage::user("hi")]);
r.sampling.thinking = Some(true);
r.sampling.reasoning_budget = Some(0);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["reasoning"]["effort"], "none");
assert!(json["reasoning"].get("summary").is_none());
}
#[test]
fn effort_without_summary_when_thinking_off() {
let mut r = base_req(vec![ApiMessage::user("hi")]);
r.sampling.reasoning_effort = Some(ReasoningEffort::Low);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["reasoning"]["effort"], "low");
assert!(json["reasoning"].get("summary").is_none());
assert_eq!(json["include"][0], "reasoning.encrypted_content");
}
#[test]
fn tools_are_flat_with_strict_false() {
let mut r = base_req(vec![ApiMessage::user("hi")]);
r.tools = vec![ToolSchema {
name: "calc".into(),
description: "Считает".into(),
parameters: json!({"type":"object"}),
}];
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["tools"][0]["type"], "function");
assert_eq!(json["tools"][0]["name"], "calc");
assert_eq!(json["tools"][0]["strict"], false);
assert_eq!(json["tools"][0]["parameters"]["type"], "object");
assert_eq!(json["tool_choice"], "auto");
}
#[test]
fn tool_call_and_result_become_items() {
let r = base_req(vec![
ApiMessage::user("посчитай"),
ApiMessage::assistant_tool_calls(
"",
vec![ApiToolCall {
thought_signature: None,
id: "call_1".into(),
name: "calc".into(),
arguments: "{\"x\":1}".into(),
}],
),
ApiMessage::tool("call_1", "2"),
]);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["input"][1]["type"], "function_call");
assert_eq!(json["input"][1]["call_id"], "call_1");
assert_eq!(json["input"][1]["name"], "calc");
assert_eq!(json["input"][1]["arguments"], "{\"x\":1}");
assert_eq!(json["input"][2]["type"], "function_call_output");
assert_eq!(json["input"][2]["call_id"], "call_1");
assert_eq!(json["input"][2]["output"], "2");
}
#[test]
fn reasoning_item_precedes_function_call() {
let r = base_req(vec![
ApiMessage::user("посчитай"),
ApiMessage::assistant_tool_calls(
"",
vec![ApiToolCall {
thought_signature: None,
id: "call_1".into(),
name: "calc".into(),
arguments: "{}".into(),
}],
)
.with_thinking_blocks(vec![ThinkingBlock {
text: "резюме".into(),
signature: "gAAA-enc".into(),
id: Some("rs_42".into()),
}]),
ApiMessage::tool("call_1", "2"),
]);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["input"][1]["type"], "reasoning");
assert_eq!(json["input"][1]["id"], "rs_42");
assert_eq!(json["input"][1]["encrypted_content"], "gAAA-enc");
assert_eq!(json["input"][1]["summary"], json!([]));
assert_eq!(json["input"][2]["type"], "function_call");
}
#[test]
fn thinking_without_id_omits_reasoning_item() {
let r = base_req(vec![
ApiMessage::user("hi"),
ApiMessage::assistant_tool_calls(
"",
vec![ApiToolCall {
thought_signature: None,
id: "call_1".into(),
name: "calc".into(),
arguments: "{}".into(),
}],
)
.with_thinking_blocks(vec![ThinkingBlock {
text: "x".into(),
signature: "sig".into(),
id: None,
}]),
]);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
assert_eq!(json["input"][1]["type"], "function_call");
}
#[test]
fn several_reasoning_items_are_resent_each_under_its_own_id_in_order() {
let blocks: Vec<ThinkingBlock> = ["rs_1", "rs_2", "rs_3"]
.iter()
.enumerate()
.map(|(i, id)| ThinkingBlock {
text: String::new(),
signature: format!("enc-{i}"),
id: Some((*id).into()),
})
.collect();
let r = base_req(vec![
ApiMessage::user("hi"),
ApiMessage::assistant_tool_calls(
"",
vec![ApiToolCall {
thought_signature: None,
id: "call_1".into(),
name: "calc".into(),
arguments: "{}".into(),
}],
)
.with_thinking_blocks(blocks),
ApiMessage::tool("call_1", "2"),
]);
let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
let input = json["input"].as_array().unwrap();
assert_eq!(input.len(), 6);
for (i, id) in ["rs_1", "rs_2", "rs_3"].iter().enumerate() {
assert_eq!(input[i + 1]["type"], "reasoning");
assert_eq!(input[i + 1]["id"], *id);
assert_eq!(input[i + 1]["encrypted_content"], format!("enc-{i}"));
assert_eq!(input[i + 1]["summary"], json!([]));
}
assert_eq!(input[4]["type"], "function_call");
assert_eq!(input[5]["type"], "function_call_output");
}
#[test]
fn parses_stream_events() {
let td = r#"{"type":"response.output_text.delta","delta":"hi"}"#;
assert!(matches!(
serde_json::from_str::<RespEvent>(td).unwrap(),
RespEvent::OutputTextDelta { delta } if delta == "hi"
));
let rd = r#"{"type":"response.reasoning_summary_text.delta","delta":"думаю"}"#;
assert!(matches!(
serde_json::from_str::<RespEvent>(rd).unwrap(),
RespEvent::ReasoningSummaryDelta { delta } if delta == "думаю"
));
let rt = r#"{"type":"response.reasoning_text.delta","delta":"шаг"}"#;
assert!(matches!(
serde_json::from_str::<RespEvent>(rt).unwrap(),
RespEvent::ReasoningTextDelta { delta } if delta == "шаг"
));
let added = r#"{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"calc","arguments":""}}"#;
match serde_json::from_str::<RespEvent>(added).unwrap() {
RespEvent::OutputItemAdded {
output_index,
item: RespItem::FunctionCall { call_id, name },
} => {
assert_eq!(output_index, 1);
assert_eq!(call_id, "call_1");
assert_eq!(name, "calc");
}
other => panic!("expected function_call added, got {other:?}"),
}
let done = r#"{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_9","encrypted_content":"ENC"}}"#;
match serde_json::from_str::<RespEvent>(done).unwrap() {
RespEvent::OutputItemDone {
item:
RespItem::Reasoning {
id,
encrypted_content: Some(enc),
},
..
} => {
assert_eq!(id, "rs_9");
assert_eq!(enc, "ENC");
}
other => panic!("expected reasoning done, got {other:?}"),
}
let args = r#"{"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"x\":1}"}"#;
assert!(matches!(
serde_json::from_str::<RespEvent>(args).unwrap(),
RespEvent::FunctionArgsDelta { output_index, delta } if output_index == 1 && delta == "{\"x\":1}"
));
let completed = r#"{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":42,"output_tokens":7}}}"#;
match serde_json::from_str::<RespEvent>(completed).unwrap() {
RespEvent::Completed { response } => {
let u = response.usage.unwrap();
assert_eq!(u.input_tokens, 42);
assert_eq!(u.output_tokens, 7);
}
other => panic!("expected completed, got {other:?}"),
}
assert!(matches!(
serde_json::from_str::<RespEvent>(r#"{"type":"response.created","response":{}}"#)
.unwrap(),
RespEvent::Other
));
let msg_added = r#"{"type":"response.output_item.added","output_index":2,"item":{"type":"message","role":"assistant","content":[]}}"#;
assert!(matches!(
serde_json::from_str::<RespEvent>(msg_added).unwrap(),
RespEvent::OutputItemAdded {
item: RespItem::Other,
..
}
));
}
}
#[cfg(test)]
mod failure_event_tests {
use super::*;
#[test]
fn response_failed_carries_its_reason() {
let data = r#"{"type":"response.failed","response":{"id":"resp_1","status":"failed",
"error":{"code":"server_error","message":"The model failed to generate a response."}}}"#;
let RespEvent::Failed { response } = serde_json::from_str(data).unwrap() else {
panic!("expected response.failed")
};
let err = response
.and_then(|r| r.error)
.expect("the reason must survive");
assert_eq!(err.code.as_deref(), Some("server_error"));
assert!(err.message.contains("failed to generate"));
assert!(crate::shared::api::error::stream_error_transient(
err.code.as_deref().unwrap_or_default(),
None
));
}
#[test]
fn response_failed_without_a_reason_still_parses() {
let data = r#"{"type":"response.failed"}"#;
let RespEvent::Failed { response } = serde_json::from_str(data).unwrap() else {
panic!("expected response.failed")
};
assert!(response.is_none());
}
#[test]
fn the_error_event_carries_code_and_message() {
let data = r#"{"type":"error","code":"rate_limit_exceeded","message":"Rate limit reached","param":null,"sequence_number":7}"#;
let RespEvent::Error { code, message } = serde_json::from_str(data).unwrap() else {
panic!("expected an error event")
};
assert_eq!(code.as_deref(), Some("rate_limit_exceeded"));
assert_eq!(message.as_deref(), Some("Rate limit reached"));
}
#[test]
fn completed_still_parses_with_usage() {
let data = r#"{"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":7,
"output_tokens_details":{"reasoning_tokens":2}}}}"#;
let RespEvent::Completed { response } = serde_json::from_str(data).unwrap() else {
panic!("expected response.completed")
};
let u = response.usage.unwrap();
assert_eq!((u.input_tokens, u.output_tokens), (5, 7));
assert!(response.error.is_none());
}
}