use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::harness::message::{AssistantMessage, ContentBlock, Message};
use crate::harness::model::ModelResponse;
use crate::harness::usage::Usage;
#[derive(Debug, Serialize)]
pub(super) struct ResponsesRequest {
pub(super) model: String,
pub(super) input: Vec<ResponsesInput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) max_output_tokens: Option<u32>,
}
#[derive(Debug, Serialize)]
pub(super) struct ResponsesInput {
pub(super) role: String,
pub(super) content: Vec<ResponsesContentPart>,
}
#[derive(Debug, Serialize)]
pub(super) struct ResponsesContentPart {
#[serde(rename = "type")]
pub(super) kind: String,
pub(super) text: String,
}
#[derive(Debug, Deserialize)]
pub(super) struct ResponsesResponse {
#[serde(default)]
pub(super) output: Vec<ResponsesOutput>,
#[serde(default)]
pub(super) output_text: Option<String>,
#[serde(default)]
pub(super) usage: Option<ResponsesUsage>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ResponsesOutput {
#[serde(default)]
pub(super) content: Vec<ResponsesContent>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ResponsesContent {
#[serde(rename = "type")]
pub(super) kind: Option<String>,
pub(super) text: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ResponsesUsage {
#[serde(default)]
pub(super) input_tokens: Option<u64>,
#[serde(default)]
pub(super) output_tokens: Option<u64>,
}
fn message_text(content: &[ContentBlock]) -> String {
content
.iter()
.filter_map(ContentBlock::as_text)
.collect::<Vec<_>>()
.join("")
}
fn normalize_role(message: &Message) -> &'static str {
match message {
Message::Assistant(_) | Message::Tool(_) => "assistant",
_ => "user",
}
}
pub(super) fn build_responses_input(messages: &[Message]) -> (Option<String>, Vec<ResponsesInput>) {
let mut instructions_parts = Vec::new();
let mut input = Vec::new();
for message in messages {
let text = match message {
Message::System(m) => {
let t = message_text(&m.content);
if !t.trim().is_empty() {
instructions_parts.push(t);
}
continue;
}
Message::User(m) => message_text(&m.content),
Message::Assistant(m) => message_text(&m.content),
Message::Tool(m) => message_text(&m.content),
};
if text.trim().is_empty() {
continue;
}
let role = normalize_role(message);
input.push(ResponsesInput {
role: role.to_string(),
content: vec![ResponsesContentPart {
kind: if role == "assistant" {
"output_text".to_string()
} else {
"input_text".to_string()
},
text,
}],
});
}
let instructions = (!instructions_parts.is_empty()).then(|| instructions_parts.join("\n\n"));
(instructions, input)
}
pub(super) fn extract_responses_text(response: &ResponsesResponse) -> Option<String> {
if let Some(text) = response
.output_text
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
{
return Some(text.to_string());
}
for item in &response.output {
for content in &item.content {
if content.kind.as_deref() == Some("output_text")
&& let Some(text) = content
.text
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
{
return Some(text.to_string());
}
}
}
None
}
pub(super) fn parse_responses_response(value: Value) -> ModelResponse {
let parsed: ResponsesResponse =
serde_json::from_value(value.clone()).unwrap_or(ResponsesResponse {
output: Vec::new(),
output_text: None,
usage: None,
});
let text = extract_responses_text(&parsed).unwrap_or_default();
let usage = parsed.usage.as_ref().map(|u| Usage {
input_tokens: u.input_tokens.unwrap_or(0),
output_tokens: u.output_tokens.unwrap_or(0),
total_tokens: u.input_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0),
..Usage::default()
});
ModelResponse {
message: AssistantMessage {
id: None,
content: vec![ContentBlock::Text(text)],
tool_calls: Vec::new(),
usage,
},
usage,
finish_reason: Some("stop".to_string()),
raw: Some(value),
resolved_model: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness::message::Message;
use serde_json::json;
#[test]
fn build_input_folds_system_into_instructions_and_keys_roles() {
let messages = vec![
Message::system("be terse"),
Message::system("and correct"),
Message::user("hi"),
Message::assistant("hello"),
Message::user(" "), ];
let (instructions, input) = build_responses_input(&messages);
assert_eq!(instructions.as_deref(), Some("be terse\n\nand correct"));
assert_eq!(input.len(), 2);
assert_eq!(input[0].role, "user");
assert_eq!(input[0].content[0].kind, "input_text");
assert_eq!(input[0].content[0].text, "hi");
assert_eq!(input[1].role, "assistant");
assert_eq!(input[1].content[0].kind, "output_text");
assert_eq!(input[1].content[0].text, "hello");
}
#[test]
fn extract_text_prefers_output_text_then_scans_content() {
let with_convenience = ResponsesResponse {
output: Vec::new(),
output_text: Some(" final ".to_string()),
usage: None,
};
assert_eq!(
extract_responses_text(&with_convenience).as_deref(),
Some("final")
);
let via_content = ResponsesResponse {
output: vec![ResponsesOutput {
content: vec![
ResponsesContent {
kind: Some("reasoning".into()),
text: Some("...".into()),
},
ResponsesContent {
kind: Some("output_text".into()),
text: Some("answer".into()),
},
],
}],
output_text: None,
usage: None,
};
assert_eq!(
extract_responses_text(&via_content).as_deref(),
Some("answer")
);
let empty = ResponsesResponse {
output: Vec::new(),
output_text: None,
usage: None,
};
assert_eq!(extract_responses_text(&empty), None);
}
#[test]
fn parse_maps_text_and_usage_onto_model_response() {
let body = json!({
"output_text": "the answer",
"usage": { "input_tokens": 12, "output_tokens": 5 }
});
let resp = parse_responses_response(body);
assert_eq!(resp.text(), "the answer");
assert_eq!(resp.finish_reason.as_deref(), Some("stop"));
let usage = resp.usage.expect("usage mapped");
assert_eq!(usage.input_tokens, 12);
assert_eq!(usage.output_tokens, 5);
assert_eq!(usage.total_tokens, 17);
}
#[test]
fn parse_tolerates_a_body_without_output() {
let resp = parse_responses_response(json!({ "id": "resp_1" }));
assert_eq!(resp.text(), "");
}
}