Skip to content

Commit e9a5b64

Browse files
committed
fix(client): strip replay status from Responses input
Signed-off-by: Todd Fisher <todd.fisher@gmail.com>
1 parent fa68d9e commit e9a5b64

1 file changed

Lines changed: 95 additions & 0 deletions

File tree

crates/libsy-llm-client/src/client.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ impl TranslatingLlmClient {
216216
strip_anthropic_incompatible_fields(&mut body);
217217
strip_unsigned_thinking_blocks(&mut body);
218218
}
219+
if matches!(backend, Backend::OpenAiResponses(_)) {
220+
strip_openai_responses_replay_status(&mut body);
221+
}
219222
merge_extra_body(&mut body, backend.extra_body());
220223
if matches!(backend, Backend::Anthropic(_)) {
221224
enable_anthropic_prompt_caching(&mut body);
@@ -688,6 +691,26 @@ fn strip_anthropic_incompatible_fields(body: &mut Value) {
688691
}
689692
}
690693

694+
// Drops response-only status metadata that coding agents replay as Responses input.
695+
// OpenAI rejects `status` on replayed assistant messages and reasoning items even
696+
// though those fields are present on the corresponding response objects.
697+
fn strip_openai_responses_replay_status(body: &mut Value) {
698+
let Some(Value::Array(input)) = body.get_mut("input") else {
699+
return;
700+
};
701+
for item in input {
702+
let Value::Object(item) = item else {
703+
continue;
704+
};
705+
if matches!(
706+
item.get("type").and_then(Value::as_str),
707+
Some("message" | "reasoning")
708+
) {
709+
item.remove("status");
710+
}
711+
}
712+
}
713+
691714
// Removes replayed `thinking` blocks that carry no signature.
692715
//
693716
// Anthropic requires signed thinking blocks on replay. A router can serve earlier
@@ -869,6 +892,14 @@ mod tests {
869892
)]
870893
}
871894

895+
fn responses_map(base_url: &str) -> Vec<ModelConfig> {
896+
vec![ModelConfig::new(
897+
"gpt",
898+
Backend::OpenAiResponses(config(base_url)),
899+
None,
900+
)]
901+
}
902+
872903
fn chat_map_with_retries(base_url: &str, max_retries: u32) -> Vec<ModelConfig> {
873904
vec![ModelConfig::new(
874905
"gpt",
@@ -1103,6 +1134,70 @@ mod tests {
11031134
Ok(())
11041135
}
11051136

1137+
#[tokio::test]
1138+
async fn responses_backend_strips_pi_replay_status_before_sending()
1139+
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1140+
let server = MockServer::start().await;
1141+
Mock::given(method("POST"))
1142+
.and(path("/v1/responses"))
1143+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
1144+
"id": "resp_1",
1145+
"object": "response",
1146+
"model": "gpt",
1147+
"status": "completed",
1148+
"output": [{
1149+
"id": "msg_1",
1150+
"type": "message",
1151+
"role": "assistant",
1152+
"status": "completed",
1153+
"content": [{"type": "output_text", "text": "ok"}]
1154+
}],
1155+
"usage": {}
1156+
})))
1157+
.mount(&server)
1158+
.await;
1159+
let client = TranslatingLlmClient::new(&responses_map(&format!("{}/v1", server.uri())))?;
1160+
let body = json!({
1161+
"model": "ctm-auto",
1162+
"input": [
1163+
{"type": "message", "role": "user", "content": "start"},
1164+
{
1165+
"type": "reasoning",
1166+
"id": "rs_1",
1167+
"status": "completed",
1168+
"summary": []
1169+
},
1170+
{
1171+
"type": "message",
1172+
"id": "msg_pi_0",
1173+
"role": "assistant",
1174+
"status": "completed",
1175+
"content": [{"type": "output_text", "text": "working"}]
1176+
},
1177+
{"type": "message", "role": "user", "content": "continue"}
1178+
]
1179+
});
1180+
1181+
client
1182+
.call_rewrite_model_raw(
1183+
body,
1184+
None,
1185+
Some(&ModelId::from("gpt")),
1186+
WireFormat::OpenAiResponses,
1187+
)
1188+
.await?;
1189+
1190+
let requests = server
1191+
.received_requests()
1192+
.await
1193+
.ok_or("request recording should be enabled")?;
1194+
let forwarded: Value = serde_json::from_slice(&requests[0].body)?;
1195+
assert_eq!(forwarded["model"], "gpt");
1196+
assert_eq!(forwarded["input"][1].get("status"), None);
1197+
assert_eq!(forwarded["input"][2].get("status"), None);
1198+
Ok(())
1199+
}
1200+
11061201
#[tokio::test]
11071202
async fn invalid_json_is_a_response_translation_error()
11081203
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {

0 commit comments

Comments
 (0)