use super::*;
use crate::error::EncodeError;
use crate::error::ProviderError;
use crate::message;
use serde_json::json;
use std::collections::HashMap;
fn fold_body(response: Value) -> Result<completion::CompletionResponse, ProviderError> {
let body = serde_json::to_string(&response)?;
crate::test_utils::decode_reply(
&openai_wire("gpt-5-mini"),
&crate::completion::CompletionRequest::new("hello"),
crate::wire::Mode::Unary,
[crate::wire::WireFrame::Text(body)],
serde_json::Value::Null,
)
}
fn openai_wire(model: &str) -> wire::Responses {
crate::providers::openai::OpenAIConfig::new("dummy-key").responses(model)
}
fn wire_request(wire: &wire::Responses, request: completion::CompletionRequest) -> Value {
json_of(wire, request).expect("request should convert")
}
fn json_of(
wire: &wire::Responses,
request: completion::CompletionRequest,
) -> Result<Value, EncodeError> {
let body = wire.responses_request(&request, Delivery::Http(crate::wire::Mode::Unary))?;
Ok(serde_json::to_value(body)?)
}
fn convert(model: &str, request: completion::CompletionRequest) -> Result<Value, EncodeError> {
json_of(&openai_wire(model), request)
}
fn prepared(model: &str, request: completion::CompletionRequest) -> Value {
use crate::wire::{Operation, Wire};
let wire = openai_wire(model);
let request = crate::operation::Completion::prepare(request, &wire.describe())
.expect("the request prepares");
json_of(&wire, request).expect("request should convert")
}
fn input_items(message: completion::Message) -> Result<Vec<Value>, EncodeError> {
let wire = openai_wire("gpt-5");
let mut custom = Custom {
tools: Default::default(),
calls: Default::default(),
};
super::input(&[message.into()], &wire, "gpt-5", &mut custom, false).map(|(items, _)| items)
}
fn test_document(id: &str, text: &str) -> crate::completion::Document {
crate::completion::Document {
id: id.to_string(),
text: text.to_string(),
additional_props: HashMap::new(),
}
}
fn weather_tool_definition() -> completion::ToolDefinition {
completion::ToolDefinition {
name: crate::message::ToolName::new("get_weather").expect("tool name"),
description: "Get the weather".to_string(),
parameters: json!({
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}),
annotations: Default::default(),
}
}
fn rig_tool_result(content: message::ToolResultContent) -> message::Message {
rig_tool_result_of(vec![content])
}
fn rig_tool_result_of(content: Vec<message::ToolResultContent>) -> message::Message {
message::Message::User {
content: vec![message::UserContent::ToolResult(message::ToolResult {
is_error: false,
call: crate::message::CallId::from_wire("call-id"),
name: crate::message::ToolName::new("tool".to_string()).expect("tool name"),
content,
})],
}
}
#[test]
fn tool_result_file_id_image_uses_the_native_wire_field() {
let input = rig_tool_result(message::ToolResultContent::Image(message::Image {
data: message::DocumentSourceKind::FileId("file-image-123".to_string()),
media_type: None,
detail: None,
native: None,
}));
let items = input_items(input).expect("input item conversion");
assert_eq!(
items[0],
json!({
"type": "function_call_output",
"call_id": "call-id",
"output": [{
"type": "input_image",
"file_id": "file-image-123",
"detail": "auto"
}],
"status": "completed"
})
);
}
fn weather_tool_request() -> completion::CompletionRequest {
completion::CompletionRequest::new("what's the weather?").tools(vec![weather_tool_definition()])
}
#[test]
fn responses_http_serializes_input_and_output_cap_last() -> anyhow::Result<()> {
use crate::completion::{GenerationOptions, OnUnsupported};
use crate::wire::{Body, Mode, Wire};
use anyhow::Context;
for mode in [Mode::Unary, Mode::Streaming] {
for cap in [None, Some(73)] {
let mut request = completion::CompletionRequest::new("turn")
.options(GenerationOptions::default().on_unsupported(OnUnsupported::Ignore));
let mut raw = Map::from_iter([
("zzz_order_sentinel".to_owned(), json!("stable")),
("stream".to_owned(), json!(false)),
]);
if let Some(cap) = cap {
request.max_tokens = Some(50);
raw.insert("max_output_tokens".to_owned(), json!(cap));
}
request.additional_params = Some(Value::Object(raw));
let encoded = openai_wire("gpt-4o").encode(request, mode)?;
let Body::Bytes(bytes) = encoded.request.into_body() else {
anyhow::bail!("Responses must send JSON bytes");
};
let text = std::str::from_utf8(&bytes)?;
let (stable, tail) = text.split_once("\"input\":").context("input field")?;
anyhow::ensure!(
stable.contains("\"zzz_order_sentinel\":\"stable\""),
"{text}"
);
anyhow::ensure!(stable.contains("\"model\":\"gpt-4o\""), "{text}");
let parsed: Value = serde_json::from_slice(&bytes)?;
let input = serde_json::to_string(parsed.get("input").context("input value")?)?;
let expected = match cap {
Some(cap) => format!("{input},\"max_output_tokens\":{cap}}}"),
None => format!("{input}}}"),
};
anyhow::ensure!(tail == expected, "unexpected tail: {text}");
anyhow::ensure!(
stable.contains("\"stream\":true") == (mode == Mode::Streaming),
"unexpected stream mode: {text}"
);
}
}
Ok(())
}
#[test]
fn responses_tool_choice_modes_serialize_as_plain_strings() {
for (choice, expected) in [
(message::ToolChoice::Auto, json!("auto")),
(message::ToolChoice::None, json!("none")),
(message::ToolChoice::Required, json!("required")),
] {
assert_eq!(
super::tool_choice(choice).expect("mode should convert"),
expected
);
}
}
#[test]
fn responses_tool_choice_specific_single_name_serializes_as_named_function() {
let converted = super::tool_choice(message::ToolChoice::Specific {
function_names: vec![crate::message::ToolName::new("get_weather").expect("tool name")],
})
.expect("single specific tool should convert");
assert_eq!(
converted,
json!({"type": "function", "name": "get_weather"})
);
}
#[test]
fn responses_tool_choice_specific_multiple_names_serialize_as_allowed_tools() {
let converted = super::tool_choice(message::ToolChoice::Specific {
function_names: vec![
crate::message::ToolName::new("add").expect("tool name"),
crate::message::ToolName::new("subtract").expect("tool name"),
],
})
.expect("multiple specific tools should convert");
assert_eq!(
converted,
json!({
"type": "allowed_tools",
"mode": "required",
"tools": [
{"type": "function", "name": "add"},
{"type": "function", "name": "subtract"}
]
})
);
}
#[test]
fn responses_tool_choice_specific_empty_names_error() {
let converted = super::tool_choice(message::ToolChoice::Specific {
function_names: vec![],
});
assert!(matches!(
converted.map_err(ProviderError::from),
Err(ProviderError::Request(error))
if error.to_string().contains("at least one function name")
));
}
#[test]
fn responses_request_with_specific_tool_choice_serializes_named_function() {
let mut request = weather_tool_request();
request.tool_choice = Some(message::ToolChoice::Specific {
function_names: vec![crate::message::ToolName::new("get_weather").expect("tool name")],
});
let request_json = convert("gpt-test", request).expect("convert");
assert_eq!(
request_json.get("tool_choice"),
Some(&json!({"type": "function", "name": "get_weather"}))
);
}
#[test]
fn responses_strict_function_tools_sanitize_schema() {
let tool = ResponsesToolDefinition::strict_function(
"get_weather",
"Get the weather",
weather_tool_definition().parameters,
);
assert!(tool.strict);
assert_eq!(tool.parameters["additionalProperties"], json!(false));
assert_eq!(tool.parameters["required"], json!(["location", "unit"]));
}
fn request_with_preamble(preamble: &str) -> completion::CompletionRequest {
completion::CompletionRequest::from(vec![
message::Message::system(preamble),
message::Message::user("Hello"),
])
}
fn system_only_request(system_text: &str) -> completion::CompletionRequest {
completion::CompletionRequest::new(completion::Message::system(system_text))
}
#[test]
fn responses_request_with_only_system_messages_keeps_them_in_input() {
let req = convert("gpt-4o-mini", system_only_request("System only"))
.expect("request conversion should succeed");
let serialized = serde_json::to_value(&req).expect("request should serialize");
let input = serialized["input"]
.as_array()
.expect("input should be array");
assert!(
serialized.get("instructions").is_none(),
"lifting a system-only history would leave input empty, so it stays in input"
);
assert_eq!(input.len(), 1);
assert_eq!(input[0]["role"], "system");
assert!(input[0].to_string().contains("System only"));
}
#[test]
fn responses_wire_can_fallback_to_system_messages_in_input() {
let wire = openai_wire("gpt-4o-mini").with_system_instructions_as_messages();
let req = wire_request(&wire, request_with_preamble("You are concise."));
let serialized = serde_json::to_value(&req).expect("request should serialize");
let input = serialized["input"]
.as_array()
.expect("input should be array");
assert!(serialized.get("instructions").is_none());
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], "system");
assert!(input[0].to_string().contains("You are concise."));
assert_eq!(input[1]["role"], "user");
}
#[test]
fn all_instructions_system_only_input_reports_non_system_requirement() {
for (case, system) in [
("system-only input", "System only"),
(
"whitespace-only system input, which produces no `instructions` field",
" ",
),
] {
let Err(err) = json_of(
&openai_wire("gpt-4o-mini")
.with_system_instructions_placement(SystemInstructionsPlacement::AllInstructions),
system_only_request(system),
) else {
panic!("{case}: should fail once every item is lifted");
};
assert!(
err.to_string().contains("non-system item"),
"{case}: the error should explain that lifted system messages left input empty: {err}"
);
}
}
#[test]
fn responses_request_conversion_keeps_tools_non_strict_by_default() {
let req = convert("gpt-4o-mini", weather_tool_request()).expect("request should convert");
let tool = &req["tools"][0];
assert_eq!(tool["strict"], json!(false));
assert_eq!(tool["parameters"]["required"], json!(["location"]));
assert!(tool["parameters"].get("additionalProperties").is_none());
}
#[test]
fn responses_wire_strict_tools_opt_in_sanitizes_all_function_tools() {
let wire =
openai_wire("gpt-4o-mini")
.with_strict_tools()
.with_tool(completion::ToolDefinition {
name: crate::message::ToolName::new("lookup").expect("tool name"),
description: "Look something up".to_string(),
parameters: json!({
"type": "object",
"properties": {"q": {"type": "string"}}
}),
annotations: Default::default(),
});
let mut request = weather_tool_request();
request.additional_params = Some(json!({
"tools": [{
"type": "function",
"name": "extra",
"description": "An additional_params tool",
"parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
}]
}));
let req = wire_request(&wire, request);
let tools = req["tools"].as_array().expect("tools");
assert_eq!(tools.len(), 3);
for tool in tools {
assert_eq!(
tool["strict"],
json!(true),
"{} should be strict",
tool["name"]
);
assert_eq!(tool["parameters"]["additionalProperties"], json!(false));
}
}
#[test]
fn responses_request_keeps_documents_after_lifted_system_messages() {
let request = crate::completion::CompletionRequest::new("Prompt")
.message(completion::Message::system("System prompt"))
.message(completion::Message::user("Earlier user turn"))
.message(completion::Message::assistant("Earlier assistant turn"))
.document(test_document("doc1", "Document text."));
let responses_request = prepared("gpt-4o-mini", request);
let serialized = serde_json::to_value(&responses_request).expect("request should serialize");
let input = serialized["input"]
.as_array()
.expect("input should be an array");
assert_eq!(serialized["instructions"], json!("System prompt"));
assert_eq!(input.len(), 4);
assert_eq!(input[0]["role"], "user");
assert!(
input[0].to_string().contains("<file id: doc1>"),
"document input should be first after system instructions are lifted: {input:?}"
);
assert_eq!(input[1]["role"], "user");
assert!(
input[1].to_string().contains("Earlier user turn"),
"prior user history should follow document input: {input:?}"
);
assert_eq!(input[2]["role"], "assistant");
assert!(
input[2].to_string().contains("Earlier assistant turn"),
"prior assistant history should follow prior user history: {input:?}"
);
assert_eq!(input[3]["role"], "user");
assert!(
input[3].to_string().contains("Prompt"),
"prompt should remain last: {input:?}"
);
}
#[test]
fn responses_direct_request_keeps_mid_conversation_system_messages_in_input() {
let request = crate::completion::CompletionRequest::from(vec![
completion::Message::system("System prompt"),
completion::Message::assistant("Earlier assistant turn"),
completion::Message::system("Mid-conversation instruction"),
completion::Message::user("Prompt"),
])
.documents(vec![test_document("doc1", "Document text.")]);
let responses_request = prepared("gpt-4o-mini", request);
let serialized = serde_json::to_value(&responses_request).expect("request should serialize");
let input = serialized["input"]
.as_array()
.expect("input should be an array");
assert_eq!(
serialized["instructions"],
json!("System prompt"),
"only the leading run of system messages should be lifted"
);
assert_eq!(input.len(), 4);
assert_eq!(input[0]["role"], "user");
assert!(
input[0].to_string().contains("<file id: doc1>"),
"document input should follow lifted system instructions: {input:?}"
);
assert_eq!(input[1]["role"], "assistant");
assert_eq!(input[2]["role"], "system");
assert!(
input[2]
.to_string()
.contains("Mid-conversation instruction"),
"mid-conversation system messages should keep their position: {input:?}"
);
assert_eq!(input[3]["role"], "user");
assert_eq!(
input
.iter()
.filter(|message| message.to_string().contains("<file id: doc1>"))
.count(),
1,
"document input should appear exactly once: {input:?}"
);
}
#[test]
fn completion_response_incomplete_reports_the_truncation_reason() {
let response = json!({
"id": "resp_123",
"object": "response",
"created_at": 0,
"status": "incomplete",
"incomplete_details": { "reason": "max_output_tokens" },
"model": "gpt-5.4",
"output": [{
"type": "message",
"id": "msg_456",
"status": "incomplete",
"role": "assistant",
"content": [{ "type": "output_text", "annotations": [], "text": "half an ans" }]
}],
"tools": []
});
let completion: completion::CompletionResponse =
fold_body(response).expect("response should convert");
assert_eq!(
completion.finish_reason(),
Some(completion::FinishReason::Length)
);
}
#[test]
fn file_id_document_serializes_as_input_item_content() {
let message = completion::Message::User {
content: vec![message::UserContent::Document(message::Document {
data: DocumentSourceKind::FileId("file_abc".to_string()).into(),
media_type: None,
additional_params: None,
})],
};
let converted = input_items(message).expect("conversion should succeed");
let json = serde_json::to_value(&converted[0]).expect("serialize input item");
assert_eq!(json["type"], "message");
assert_eq!(json["role"], "user");
assert_eq!(json["content"][0]["type"], "input_file");
assert_eq!(json["content"][0]["file_id"], "file_abc");
assert!(json["content"][0].get("file_data").is_none());
assert!(json["content"][0].get("file_url").is_none());
}
#[test]
fn completion_response_with_unknown_output_keeps_usage() {
let response = json!({
"id": "resp_123",
"object": "response",
"created_at": 0,
"status": "completed",
"model": "gpt-5.4",
"output": [
{
"type": "web_search_call",
"id": "ws_001",
"status": "completed",
},
{
"type": "message",
"id": "msg_1",
"role": "assistant",
"status": "completed",
"content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
},
],
"usage": {
"input_tokens": 100,
"input_tokens_details": { "cached_tokens": 25 },
"output_tokens": 50,
"output_tokens_details": { "reasoning_tokens": 15 },
"total_tokens": 150,
},
});
let completion = fold_body(response).expect("an unknown item decodes");
assert!(matches!(
completion.choice.first(),
Some(completion::AssistantContent::Opaque(_))
));
assert_eq!(completion.usage.total_tokens, Some(150));
assert_eq!(completion.usage.cached_input_tokens, Some(25));
assert_eq!(completion.usage.reasoning_tokens, Some(15));
}
const PDF_URL: &str = "https://example.com/resume.pdf";
fn url_pdf_message() -> message::Message {
message::Message::User {
content: vec![message::UserContent::document_url(
PDF_URL,
Some(message::DocumentMediaType::PDF),
)],
}
}
fn find_input_files(value: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
match value {
serde_json::Value::Object(map) => {
if map.get("type").and_then(|t| t.as_str()) == Some("input_file") {
out.push(value.clone());
}
map.values().for_each(|v| find_input_files(v, out));
}
serde_json::Value::Array(items) => {
items.iter().for_each(|v| find_input_files(v, out));
}
_ => {}
}
}
fn sole_input_file(value: &serde_json::Value) -> serde_json::Value {
let mut found = Vec::new();
find_input_files(value, &mut found);
assert_eq!(
found.len(),
1,
"expected exactly one input_file item in {value:#}"
);
found.pop().unwrap()
}
fn assert_url_only_input_file(input_file: &serde_json::Value) {
assert_eq!(
input_file.get("file_url").and_then(|v| v.as_str()),
Some(PDF_URL),
"URL PDF should carry file_url: {input_file:#}"
);
assert_eq!(
input_file.get("filename"),
None,
"filename must be absent for URL PDFs (issue #1429): {input_file:#}"
);
assert_eq!(
input_file.get("file_data"),
None,
"file_data must be absent for URL PDFs: {input_file:#}"
);
}
#[test]
fn url_pdf_via_input_item_path_omits_filename() {
let items = input_items(url_pdf_message()).expect("URL PDF should convert to input items");
let json = serde_json::to_value(&items).expect("input items should serialize");
assert_url_only_input_file(&sole_input_file(&json));
}
#[test]
fn base64_pdf_via_input_item_path_keeps_filename() {
let input = message::Message::User {
content: vec![message::UserContent::Document(message::Document {
data: DocumentSourceKind::base64("dGVzdA==").into(),
media_type: Some(message::DocumentMediaType::PDF),
additional_params: None,
})],
};
let items = input_items(input).expect("base64 PDF should convert to input items");
let json = serde_json::to_value(&items).expect("input items should serialize");
let input_file = sole_input_file(&json);
assert_eq!(
input_file.get("file_data").and_then(|v| v.as_str()),
Some("data:application/pdf;base64,dGVzdA=="),
"base64 PDF should carry file_data: {input_file:#}"
);
assert_eq!(
input_file.get("filename").and_then(|v| v.as_str()),
Some("document.pdf"),
"base64 PDF should keep the default filename: {input_file:#}"
);
assert_eq!(
input_file.get("file_url"),
None,
"base64 PDF should not carry file_url: {input_file:#}"
);
}
mod raw_capture {
use super::*;
use crate::test_utils::RecordingHttpClient;
const REQUEST_ID: &str = "req_unit_responses_0001";
const BODY: &str = r#"{
"id": "resp_raw_1",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"error": null,
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"model": "gpt-4o-mini-2024-07-18",
"service_tier": "default",
"usage": {
"input_tokens": 4,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens": 3,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": 7
},
"output": [{
"type": "message",
"id": "msg_raw_1",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hello", "annotations": []}]
}],
"tools": []
}"#;
#[tokio::test]
async fn completion_captures_the_provider_body_as_raw() {
let mut headers = http::HeaderMap::new();
headers.insert("x-request-id", http::HeaderValue::from_static(REQUEST_ID));
let model = crate::driver::Model::new(
openai_wire("gpt-4o-mini"),
RecordingHttpClient::with_error_response_headers(http::StatusCode::OK, BODY, headers),
);
let response = model
.call(crate::completion::CompletionRequest::new("hello"))
.await
.expect("completion");
let raw = &response.raw;
let body: Value = serde_json::from_str(BODY).expect("the body is JSON");
assert_eq!(*raw, body, "the capture is the provider's body");
assert_eq!(raw["service_tier"], "default");
assert!(raw.get("provider_request_id").is_none());
let mut refolded = fold_body(raw.clone()).expect("re-fold the capture");
refolded.provider_request_id = Some(REQUEST_ID.to_string());
assert_eq!(response.identity(), refolded.identity());
assert_eq!(response.finish_reason(), refolded.finish_reason());
assert_eq!(response.model(), refolded.model());
assert_eq!(response.usage.cost(None), refolded.usage.cost(None));
assert!(
response.usage.cost.is_some(),
"the catalog prices gpt-4o-mini"
);
assert_eq!(response.choice, refolded.choice);
assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
}
}
fn lookup_tool() -> completion::ToolDefinition {
completion::ToolDefinition {
name: message::ToolName::new("lookup").expect("tool name"),
description: "Look something up".to_owned(),
parameters: json!({"type": "object"}),
annotations: Default::default(),
}
}
#[test]
fn foreign_call_ids_are_normalized_as_pi_normalizes_them() {
use crate::completion::ReplayTarget;
let wire = openai_wire("gpt-5.4");
let long = format!("call_{}", "a".repeat(80));
for (id, expected) in [
("call_1", "call_1".to_owned()),
("toolu_01|fc_abc", "toolu_01_fc_abc".to_owned()),
("call.with spaces..", "call_with_spaces".to_owned()),
(long.as_str(), long[..64].to_owned()),
] {
assert_eq!(
wire.normalize_tool_call_id(id, wire.model(), None),
expected,
"{id}"
);
}
}
#[test]
fn a_stored_continuation_keeps_the_results_of_the_stored_calls() {
use crate::wire::{Operation, Wire};
let wire = openai_wire("gpt-5.4");
let result = completion::Message::tool_result(
message::CallId::from_wire("call_1"),
message::ToolName::new("lookup").expect("tool name"),
"sunny",
);
let mut request =
crate::completion::CompletionRequest::from(vec![result]).tools(vec![lookup_tool()]);
request.additional_params = Some(json!({"previous_response_id": "resp_1", "store": true}));
let prepared = crate::operation::Completion::prepare(request.clone(), &wire.describe())
.expect("the continuation prepares");
let body = json_of(&wire, prepared).expect("encodes");
assert_eq!(body["input"][0]["type"], "function_call_output");
assert_eq!(body["input"][0]["call_id"], "call_1");
assert_eq!(body["previous_response_id"], "resp_1");
request.additional_params = None;
let error = crate::operation::Completion::prepare(request, &wire.describe())
.and_then(|prepared| json_of(&wire, prepared).map_err(Into::into));
assert!(error.is_err(), "{error:?}");
}
fn carries_text(value: &Value, text: &str) -> bool {
match value {
Value::Object(fields) => {
fields.get("text").and_then(Value::as_str) == Some(text)
|| fields.values().any(|field| carries_text(field, text))
}
Value::Array(items) => items.iter().any(|item| carries_text(item, text)),
_ => false,
}
}
#[test]
fn base64_pdf_is_data_and_document_text_is_text() -> anyhow::Result<()> {
use crate::wire::{Operation, Wire};
let pdf = "JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PD4+CmVuZG9iagp0cmFpbGVyCjw8Pj4KJSVFT0YK";
let wire = openai_wire("gpt-5");
let sent = |data: message::DocumentData| {
let request = completion::CompletionRequest::new(message::Message::User {
content: vec![
message::UserContent::text("Summarize the file."),
message::UserContent::Document(message::Document {
data,
media_type: Some(message::DocumentMediaType::PDF),
additional_params: None,
}),
],
});
crate::operation::Completion::prepare(request, &wire.describe())
.map_err(anyhow::Error::from)
.and_then(|request| Ok(json_of(&wire, request)?))
};
let body = sent(DocumentSourceKind::base64(pdf).into())?;
anyhow::ensure!(
!carries_text(&body, pdf),
"the PDF's base64 goes to the model as text: {body:#}"
);
let legacy = serde_json::from_value(json!({"type": "string", "value": pdf}))?;
let body = sent(legacy)?;
anyhow::ensure!(
carries_text(&body, pdf),
"a text document goes as its text: {body:#}"
);
Ok(())
}
#[test]
fn an_unprepared_part_with_no_responses_form_is_refused() {
let refused = |content: message::UserContent| {
json_of(
&openai_wire("gpt-5"),
completion::CompletionRequest::new(message::Message::User {
content: vec![content],
}),
)
.map(|body| body.to_string())
.map_err(|error| error.to_string())
};
let document = refused(message::UserContent::Document(message::Document {
data: DocumentSourceKind::base64("aGVsbG8=").into(),
media_type: Some(message::DocumentMediaType::TXT),
additional_params: None,
}));
assert!(
document
.as_ref()
.is_err_and(|error| error.contains("cannot carry this document")),
"{document:?}"
);
let image = refused(message::UserContent::Image(message::Image {
data: DocumentSourceKind::Unknown,
..Default::default()
}));
assert!(
image
.as_ref()
.is_err_and(|error| error.contains("cannot carry this image")),
"{image:?}"
);
}
fn breakpoints(body: &Value) -> Vec<usize> {
body["input"]
.as_array()
.into_iter()
.flatten()
.enumerate()
.filter(|(_, item)| {
["content", "output"].iter().any(|field| {
item.get(*field)
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|part| part.get("prompt_cache_breakpoint").is_some())
})
})
.map(|(at, _)| at)
.collect()
}
fn lookup_call() -> message::ToolCall {
message::ToolCall::new(
message::CallId::from_wire("call_1"),
message::ToolFunction::new(
message::ToolName::new("lookup").expect("tool name"),
json!({}),
),
)
}
fn cached_conversation() -> completion::CompletionRequest {
let call = lookup_call();
let mut request = completion::CompletionRequest::new("And now?")
.preamble("Be brief.")
.tool(lookup_tool())
.messages([
completion::Message::user("Look it up."),
completion::Message::Assistant(message::AssistantMessage::new(vec![
AssistantContent::ToolCall(call.clone()),
])),
completion::Message::from(UserContent::ToolResult(call.result(vec![
ToolResultContent::text("found"),
ToolResultContent::text("twice"),
]))),
completion::Message::assistant("It is found."),
]);
request.cache_points = vec![
completion::CachePoint::Tools,
completion::CachePoint::System,
completion::CachePoint::Messages(1),
completion::CachePoint::Messages(2),
completion::CachePoint::Messages(3),
completion::CachePoint::Messages(4),
];
request
}
#[test]
fn cache_points_mark_input_parts_from_gpt_5_6_on() {
let body = prepared("gpt-5.6-luna", cached_conversation());
let input = body["input"].as_array().cloned().unwrap_or_default();
let kinds: Vec<_> = input
.iter()
.map(|item| item["type"].as_str().unwrap_or_default())
.collect();
assert_eq!(
kinds,
[
"message",
"function_call",
"function_call_output",
"message",
"message"
]
);
assert_eq!(breakpoints(&body), [0, 2]);
assert_eq!(
input.get(2).map(|item| &item["output"]),
Some(&json!([
{"type": "input_text", "text": "found"},
{"type": "input_text", "text": "twice", "prompt_cache_breakpoint": {"mode": "explicit"}},
]))
);
}
#[test]
fn every_cache_point_is_marked() {
let turns = (0..3).flat_map(|turn| {
[
completion::Message::user(format!("Question {turn}.")),
completion::Message::assistant(format!("Answer {turn}.")),
]
});
let mut request = completion::CompletionRequest::new("Last question.").messages(turns);
request.cache_points = [1, 3, 5, 7].map(completion::CachePoint::Messages).to_vec();
assert_eq!(
breakpoints(&prepared("gpt-5.6-luna", request)),
[0, 2, 4, 6]
);
}
#[test]
fn cache_points_mark_nothing_before_gpt_5_6_or_with_caching_off() {
assert_eq!(
breakpoints(&prepared("gpt-5.4", cached_conversation())),
[0usize; 0]
);
let off = cached_conversation().cache(completion::CacheRetention::None);
assert_eq!(breakpoints(&prepared("gpt-5.6-luna", off)), [0usize; 0]);
}
#[test]
fn caller_input_goes_ahead_of_the_messages_as_given() {
use crate::providers::openai::extension::{OpenAiOptions, OpenAiResponsesOptions};
let marked = |text: &str| {
json!({"role": "user", "content": [
{"type": "input_text", "text": text, "prompt_cache_breakpoint": {"mode": "explicit"}}
]})
};
let held = vec![
marked("Look it up."),
json!({"type": "compaction", "id": "cmp_1", "encrypted_content": "opaque"}),
marked("Use both tools."),
json!({"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"}),
json!({"type": "custom_tool_call", "call_id": "call_2", "name": "lookup", "input": "x"}),
];
let result = |id: &str| {
UserContent::ToolResult(
message::ToolCall::new(
message::CallId::from_wire(id),
message::ToolFunction::new(
message::ToolName::new("lookup").expect("tool name"),
json!({}),
),
)
.result(vec![ToolResultContent::text("found")]),
)
};
let request = |points: &[completion::CachePoint]| {
let mut request = completion::CompletionRequest::new("And now?")
.tool(lookup_tool())
.messages([completion::Message::User {
content: vec![result("call_1"), result("call_2")],
}])
.provider_option(
OpenAiOptions::default()
.responses(OpenAiResponsesOptions::default().input(held.clone())),
);
request.cache_points = points.to_vec();
prepared("gpt-5.6-luna", request)
};
use completion::CachePoint::{Messages, System};
let body = request(&[System, Messages(1), Messages(2)]);
let input = body["input"].as_array().cloned().unwrap_or_default();
assert_eq!(input.get(..5), Some(held.as_slice()));
let answers: Vec<_> = input
.iter()
.skip(5)
.map(|item| (item["type"].clone(), item["call_id"].clone()))
.collect();
assert_eq!(
answers,
[
(json!("function_call_output"), json!("call_1")),
(json!("custom_tool_call_output"), json!("call_2")),
(json!("message"), Value::Null),
]
);
assert_eq!(breakpoints(&body), [0, 2, 6, 7]);
let body = request(&[Messages(1)]);
assert_eq!(breakpoints(&body), [0, 2, 6]);
assert_eq!(
body["input"][6]["output"],
json!([{"type": "input_text", "text": "found", "prompt_cache_breakpoint": {"mode": "explicit"}}])
);
}
#[test]
fn a_preamble_beside_caller_items_goes_to_instructions() {
use crate::providers::openai::extension::{OpenAiOptions, OpenAiResponsesOptions};
let held = vec![json!({"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "Where were we?"}
]})];
let mut request = completion::CompletionRequest::new("unused").provider_option(
OpenAiOptions::default().responses(OpenAiResponsesOptions::default().input(held.clone())),
);
request.chat_history = vec![completion::Message::system("Be brief.").into()];
let body = prepared("gpt-5.6-luna", request);
assert_eq!(body["instructions"], "Be brief.");
assert_eq!(body["input"], Value::Array(held));
}
#[test]
fn a_request_with_no_input_item_is_refused() {
let error = convert("gpt-5.4", completion::CompletionRequest::new(" "))
.expect_err("blank text encodes no item");
assert!(error.to_string().contains("at least one item"), "{error}");
}