use super::*;
use types::openai::SpecErrorResponse;
use types::responses::*;
use super::parse_typed_sse as parse_responses_sse;
#[tokio::test]
async fn spec_responses_non_streaming_text_response_shape() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: SpecResponsesResponse = resp.json().await.unwrap();
assert!(!body.id.is_empty());
assert_eq!(body.object, "response");
assert_eq!(body.status, "completed");
assert!(!body.model.is_empty());
assert!(!body.output.is_empty());
let item: SpecOutputMessage = serde_json::from_value(body.output[0].clone()).unwrap();
assert_eq!(item.item_type, "message");
assert_eq!(item.role, "assistant");
assert!(!item.content.is_empty());
assert_eq!(item.content[0].content_type, "output_text");
assert_eq!(item.content[0].text.as_deref(), Some("world"));
assert!(body.usage.input_tokens > 0);
assert!(body.usage.output_tokens > 0);
assert_eq!(
body.usage.total_tokens,
body.usage.input_tokens + body.usage.output_tokens
);
}
#[tokio::test]
async fn spec_responses_non_streaming_tool_call_response_shape() {
let (server, client) = server_with_tool_call(
"weather",
"get_weather",
serde_json::json!({"location": "SF"}),
)
.await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "weather"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: SpecResponsesResponse = resp.json().await.unwrap();
let fc_item = body
.output
.iter()
.find(|o| o.get("type").and_then(|v| v.as_str()) == Some("function_call"))
.expect("must have function_call output item");
let fc: SpecFunctionCallItem = serde_json::from_value(fc_item.clone()).unwrap();
assert_eq!(fc.item_type, "function_call");
assert!(!fc.id.is_empty());
assert!(!fc.call_id.is_empty());
assert_eq!(fc.name, "get_weather");
let parsed: serde_json::Value = serde_json::from_str(&fc.arguments).unwrap();
assert_eq!(parsed["location"], "SF");
}
#[tokio::test]
async fn spec_responses_streaming_text_response_shape() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}],
"stream": true
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.text().await.unwrap();
let events = parse_responses_sse(&body);
assert!(!events.is_empty(), "must have streaming events");
let event_types: Vec<&str> = events.iter().map(|(et, _)| et.as_str()).collect();
assert_eq!(event_types[0], "response.created");
assert_eq!(event_types[1], "response.in_progress");
assert!(event_types.contains(&"response.output_item.added"));
assert!(event_types.contains(&"response.content_part.added"));
assert!(event_types.contains(&"response.output_text.delta"));
assert!(event_types.contains(&"response.output_text.done"));
assert!(event_types.contains(&"response.content_part.done"));
assert!(event_types.contains(&"response.output_item.done"));
assert_eq!(*event_types.last().unwrap(), "response.completed");
let created_data: serde_json::Value = serde_json::from_str(&events[0].1).unwrap();
assert_eq!(created_data["type"], "response.created");
assert!(
created_data.get("response").is_some(),
"response.created must have nested 'response' envelope"
);
assert_eq!(created_data["response"]["status"], "in_progress");
assert!(created_data.get("sequence_number").is_some());
let completed = events
.iter()
.find(|(et, _)| et == "response.completed")
.unwrap();
let completed_data: serde_json::Value = serde_json::from_str(&completed.1).unwrap();
assert_eq!(completed_data["type"], "response.completed");
assert!(completed_data.get("response").is_some());
assert_eq!(completed_data["response"]["status"], "completed");
let delta = events
.iter()
.find(|(et, _)| et == "response.output_text.delta")
.unwrap();
let delta_data: serde_json::Value = serde_json::from_str(&delta.1).unwrap();
assert!(
delta_data.get("item_id").is_some(),
"delta must have item_id"
);
assert!(delta_data.get("output_index").is_some());
assert!(delta_data.get("content_index").is_some());
assert!(delta_data.get("sequence_number").is_some());
}
#[tokio::test]
async fn spec_responses_streaming_tool_call_response_shape() {
let (server, client) = server_with_tool_call(
"weather",
"get_weather",
serde_json::json!({"location": "SF"}),
)
.await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "weather"}],
"stream": true
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.text().await.unwrap();
let events = parse_responses_sse(&body);
let event_types: Vec<&str> = events.iter().map(|(et, _)| et.as_str()).collect();
assert_eq!(event_types[0], "response.created");
assert_eq!(event_types[1], "response.in_progress");
assert!(event_types.contains(&"response.output_item.added"));
assert!(event_types.contains(&"response.function_call_arguments.delta"));
assert!(event_types.contains(&"response.function_call_arguments.done"));
assert!(event_types.contains(&"response.output_item.done"));
assert_eq!(*event_types.last().unwrap(), "response.completed");
let created_data: serde_json::Value = serde_json::from_str(&events[0].1).unwrap();
assert!(created_data.get("response").is_some());
let added = events
.iter()
.find(|(et, _)| et == "response.output_item.added")
.unwrap();
let added_data: serde_json::Value = serde_json::from_str(&added.1).unwrap();
let item = &added_data["item"];
assert_eq!(item["type"], "function_call");
assert!(
item.get("arguments").is_some(),
"output_item.added must preserve arguments"
);
let fc_done = events
.iter()
.find(|(et, _)| et == "response.function_call_arguments.done")
.unwrap();
let fc_done_data: serde_json::Value = serde_json::from_str(&fc_done.1).unwrap();
assert!(fc_done_data.get("item_id").is_some());
assert!(
fc_done_data.get("call_id").is_some(),
"function_call_arguments.done must have call_id"
);
assert!(fc_done_data.get("output_index").is_some());
assert!(fc_done_data.get("arguments").is_some());
assert!(
fc_done_data.get("name").is_some(),
"function_call_arguments.done must have name"
);
assert!(fc_done_data.get("sequence_number").is_some());
}
#[tokio::test]
async fn spec_responses_status_completed_for_text() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
let body: SpecResponsesResponse = resp.json().await.unwrap();
assert_eq!(body.status, "completed");
}
#[tokio::test]
async fn spec_responses_object_is_response() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
let body: SpecResponsesResponse = resp.json().await.unwrap();
assert_eq!(body.object, "response");
}
#[tokio::test]
async fn spec_responses_usage_total_equals_sum() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
let body: SpecResponsesResponse = resp.json().await.unwrap();
assert_eq!(
body.usage.total_tokens,
body.usage.input_tokens + body.usage.output_tokens
);
}
#[tokio::test]
async fn spec_responses_id_format() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
let body: SpecResponsesResponse = resp.json().await.unwrap();
assert!(
body.id.starts_with("resp"),
"Responses API ID should start with 'resp', got: {}",
body.id
);
}
#[tokio::test]
async fn spec_responses_tool_call_arguments_are_json_string() {
let (server, client) = server_with_tool_call(
"weather",
"get_weather",
serde_json::json!({"location": "SF"}),
)
.await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "weather"}]
}))
.send()
.await
.unwrap();
let body: SpecResponsesResponse = resp.json().await.unwrap();
let fc_item = body
.output
.iter()
.find(|o| o.get("type").and_then(|v| v.as_str()) == Some("function_call"))
.unwrap();
let args = fc_item["arguments"]
.as_str()
.expect("arguments must be a string");
let parsed: serde_json::Value = serde_json::from_str(args).unwrap();
assert_eq!(parsed["location"], "SF");
}
#[tokio::test]
async fn spec_responses_accepts_extra_request_fields() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}],
"temperature": 0.7,
"max_output_tokens": 100,
"metadata": {"session": "test-123"},
"instructions": "Be helpful"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: SpecResponsesResponse = resp.json().await.unwrap();
assert_eq!(body.status, "completed");
}
#[tokio::test]
async fn spec_responses_error_429_shape() {
let server = llmposter::ServerBuilder::new()
.fixture(
llmposter::Fixture::new()
.match_model("rate-limited")
.with_error(429, "Rate limit exceeded"),
)
.build()
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "rate-limited",
"input": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 429);
assert!(resp.headers().get("x-request-id").is_some());
let body: SpecErrorResponse = resp.json().await.unwrap();
assert_eq!(body.error.error_type, "rate_limit_error");
assert_eq!(body.error.code.as_deref(), Some("rate_limit_exceeded"));
assert_eq!(body.error.message, "Rate limit exceeded");
}
#[tokio::test]
async fn spec_responses_error_401_shape() {
let server = llmposter::ServerBuilder::new()
.fixture(
llmposter::Fixture::new()
.match_model("noauth")
.with_error(401, "Invalid API key"),
)
.build()
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "noauth",
"input": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
assert!(resp.headers().get("x-request-id").is_some());
let body: SpecErrorResponse = resp.json().await.unwrap();
assert_eq!(body.error.error_type, "authentication_error");
assert_eq!(body.error.code.as_deref(), Some("invalid_api_key"));
}
#[tokio::test]
async fn spec_responses_error_500_shape() {
let server = llmposter::ServerBuilder::new()
.fixture(
llmposter::Fixture::new()
.match_model("broken")
.with_error(500, "Internal error"),
)
.build()
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "broken",
"input": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 500);
assert!(resp.headers().get("x-request-id").is_some());
let body: SpecErrorResponse = resp.json().await.unwrap();
assert_eq!(body.error.error_type, "server_error");
assert_eq!(body.error.message, "Internal error");
}
#[tokio::test]
async fn spec_responses_error_400_shape() {
let server = llmposter::ServerBuilder::new()
.fixture(
llmposter::Fixture::new()
.match_model("bad")
.with_error(400, "Bad request"),
)
.build()
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "bad",
"input": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert!(resp.headers().get("x-request-id").is_some());
let body: SpecErrorResponse = resp.json().await.unwrap();
assert_eq!(body.error.error_type, "invalid_request_error");
}
#[tokio::test]
async fn spec_responses_request_id_header() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let request_id = resp
.headers()
.get("x-request-id")
.expect("must have x-request-id")
.to_str()
.unwrap();
assert!(request_id.starts_with("req-llmposter-"));
}
#[tokio::test]
async fn spec_responses_rate_limit_headers_on_429() {
let server = llmposter::ServerBuilder::new()
.fixture(
llmposter::Fixture::new()
.match_model("rate-limited")
.with_error(429, "Rate limit exceeded"),
)
.build()
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "rate-limited",
"input": [{"role": "user", "content": "hi"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 429);
assert_eq!(
resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
Some("60")
);
assert_eq!(
resp.headers()
.get("x-ratelimit-limit-requests")
.and_then(|v| v.to_str().ok()),
Some("100")
);
assert_eq!(
resp.headers()
.get("x-ratelimit-remaining-requests")
.and_then(|v| v.to_str().ok()),
Some("0")
);
assert_eq!(
resp.headers()
.get("x-ratelimit-reset-requests")
.and_then(|v| v.to_str().ok()),
Some("1m0s")
);
assert!(resp.headers().get("x-request-id").is_some());
}
#[tokio::test]
async fn spec_responses_no_rate_limit_headers_on_200() {
let (server, client) = server_with_text("hello", "world").await;
let resp = client
.post(format!("{}/v1/responses", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"input": [{"role": "user", "content": "hello"}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
for name in [
"retry-after",
"x-ratelimit-limit-requests",
"x-ratelimit-remaining-requests",
"x-ratelimit-reset-requests",
] {
assert!(
resp.headers().get(name).is_none(),
"200 response should not have {}",
name
);
}
assert!(resp.headers().get("x-request-id").is_some());
}