From 1fa87566d91286bc920f4aed9c0995fbadffbd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 23 Jul 2026 00:49:18 -0400 Subject: [PATCH] fix(responses): drop reasoning items without a native rs_* id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 400 Invalid 'input[N].id': '' on chatgpt.com/backend-api/ codex/responses: the Responses input builder replayed every stored Reasoning item verbatim, and rs::ReasoningItem.id serializes unconditionally — so foreign items (Messages-captured Anthropic signatures, chat-completions-synthesized reasoning, stream-delta fallbacks, legacy upgrades — all id '') reached the wire with an empty id the API rejects. This also self-poisoned pure codex sessions whose reasoning arrived only as deltas. A native item always carries a server-issued rs_* id: empty id = foreign = unusable by any Responses provider = dropped at the builder — the exact mirror of the Messages builder's prune_replayed_thinking. The encrypted-only fixture that pinned the poison shape now uses a native id (the pass-through case it always meant to cover). Part 1 of the cross-provider replay audit (Pi transform-messages policy: builders emit only items valid for their target). Verified: sampling-types 286 + sampler/chat-state/shell 5791 green. --- .../kigi-sampling-types/src/conversation.rs | 87 +++++++++++++++++-- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/codegen/kigi-sampling-types/src/conversation.rs b/crates/codegen/kigi-sampling-types/src/conversation.rs index 1ff8d06..b66c5a2 100644 --- a/crates/codegen/kigi-sampling-types/src/conversation.rs +++ b/crates/codegen/kigi-sampling-types/src/conversation.rs @@ -2251,9 +2251,23 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec { // Reasoning items round-trip back to the Responses API in their - // native typed form. `status` is output-only (the API rejects it - // on input), so strip it before emission; everything else - // (summary, content, encrypted_content, id) passes through. + // native typed form — but ONLY items the Responses API itself + // produced. A native item always carries a server-issued `rs_*` + // id; an EMPTY id marks a foreign item (Messages capture stores + // the Anthropic signature with id "", chat-completions and the + // stream-delta fallback synthesize with id "", legacy upgraders + // reconstruct with id ""), and the API rejects it outright: + // 400 "Invalid 'input[N].id': ''. Expected an ID that contains + // letters, numbers, underscores, or dashes". Foreign reasoning + // is unusable by a Responses provider anyway — drop it (the + // exact mirror of the Messages builder's + // `prune_replayed_thinking`). + if r.id.is_empty() { + return vec![]; + } + // `status` is output-only (the API rejects it on input), so + // strip it before emission; everything else (summary, content, + // encrypted_content, id) passes through. let mut r = r.clone(); r.status = None; vec![rs::InputItem::Item(rs::Item::Reasoning(r))] @@ -4621,6 +4635,63 @@ mod tests { } } + /// The Responses API requires a server-issued id on every replayed + /// reasoning input item — an empty one 400s with "Invalid + /// 'input[N].id': ''" (observed on the Codex backend after a + /// cross-backend session switched to a GPT model). Empty-id reasoning + /// can only be foreign: Messages-captured (Anthropic signature, + /// id "") or chat-completions-synthesized (id "", no encrypted + /// content). Neither is usable by a Responses provider — drop them; + /// native `rs_*` items pass through untouched. + #[test] + fn responses_input_drops_reasoning_without_native_id() { + let req = ConversationRequest::from_items(vec![ + ConversationItem::user("q1"), + // Messages-captured: Anthropic signature, empty id. + reasoning("claude thinking", Some("anthropic-sig")), + assistant_text("a1"), + ConversationItem::user("q2"), + // Chat-completions synthesized: empty id, nothing encrypted. + ConversationItem::Reasoning(synthesized_reasoning_item("kimi thinking")), + assistant_text("a2"), + ConversationItem::user("q3"), + // Native Responses item: server-issued id. + ConversationItem::Reasoning(rs::ReasoningItem { + id: "rs_native_1".to_string(), + summary: vec![], + content: None, + encrypted_content: Some("gAAAA-native".to_string()), + status: None, + }), + assistant_text("a3"), + ConversationItem::user("q4"), + ]); + + let responses_req: rs::CreateResponse = (&req).into(); + let json = serde_json::to_value(&responses_req).unwrap(); + let reasoning_items: Vec<&serde_json::Value> = json["input"] + .as_array() + .unwrap() + .iter() + .filter(|i| i.get("type").and_then(|t| t.as_str()) == Some("reasoning")) + .collect(); + assert_eq!( + reasoning_items.len(), + 1, + "only the native rs_* item may be replayed:\n{json:#}" + ); + assert_eq!(reasoning_items[0]["id"], "rs_native_1"); + assert_eq!(reasoning_items[0]["encrypted_content"], "gAAAA-native"); + assert!( + !json["input"] + .as_array() + .unwrap() + .iter() + .any(|i| i.get("id").and_then(|v| v.as_str()) == Some("")), + "no input item may carry an empty id:\n{json:#}" + ); + } + #[test] fn test_encrypted_reasoning_included_in_responses_api_request() { // Test that when building a Responses API request, encrypted reasoning is included @@ -4687,12 +4758,15 @@ mod tests { #[test] fn test_only_encrypted_reasoning_included_in_request() { - // Test that when there's only encrypted content (no visible summary), - // it's still included in the request + // Encrypted-only reasoning replays ONLY with a native (server-issued) + // id. An id-less encrypted blob is by construction FOREIGN (the + // Responses stream always captures the `rs_*` id; Messages capture + // stores the Anthropic signature with id "") and the API rejects + // empty ids — see `responses_input_drops_reasoning_without_native_id`. let req = ConversationRequest::from_items(vec![ ConversationItem::user("Hello"), ConversationItem::Reasoning(rs::ReasoningItem { - id: String::new(), + id: "rs_hidden_1".to_string(), summary: vec![], content: None, encrypted_content: Some("enc_hidden_thoughts".to_string()), @@ -4725,6 +4799,7 @@ mod tests { assert_eq!(reasoning_items.len(), 1); let reasoning = reasoning_items[0]; + assert_eq!(reasoning.id, "rs_hidden_1"); // Encrypted content should be present assert_eq!(