fix(responses): drop reasoning items without a native rs_* id

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.
This commit is contained in:
2026-07-23 00:49:18 -04:00
parent d6e49bcc7d
commit 1fa87566d9
@@ -2251,9 +2251,23 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
} }
ConversationItem::Reasoning(r) => { ConversationItem::Reasoning(r) => {
// Reasoning items round-trip back to the Responses API in their // Reasoning items round-trip back to the Responses API in their
// native typed form. `status` is output-only (the API rejects it // native typed form — but ONLY items the Responses API itself
// on input), so strip it before emission; everything else // produced. A native item always carries a server-issued `rs_*`
// (summary, content, encrypted_content, id) passes through. // 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(); let mut r = r.clone();
r.status = None; r.status = None;
vec![rs::InputItem::Item(rs::Item::Reasoning(r))] 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] #[test]
fn test_encrypted_reasoning_included_in_responses_api_request() { fn test_encrypted_reasoning_included_in_responses_api_request() {
// Test that when building a Responses API request, encrypted reasoning is included // Test that when building a Responses API request, encrypted reasoning is included
@@ -4687,12 +4758,15 @@ mod tests {
#[test] #[test]
fn test_only_encrypted_reasoning_included_in_request() { fn test_only_encrypted_reasoning_included_in_request() {
// Test that when there's only encrypted content (no visible summary), // Encrypted-only reasoning replays ONLY with a native (server-issued)
// it's still included in the request // 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![ let req = ConversationRequest::from_items(vec![
ConversationItem::user("Hello"), ConversationItem::user("Hello"),
ConversationItem::Reasoning(rs::ReasoningItem { ConversationItem::Reasoning(rs::ReasoningItem {
id: String::new(), id: "rs_hidden_1".to_string(),
summary: vec![], summary: vec![],
content: None, content: None,
encrypted_content: Some("enc_hidden_thoughts".to_string()), encrypted_content: Some("enc_hidden_thoughts".to_string()),
@@ -4725,6 +4799,7 @@ mod tests {
assert_eq!(reasoning_items.len(), 1); assert_eq!(reasoning_items.len(), 1);
let reasoning = reasoning_items[0]; let reasoning = reasoning_items[0];
assert_eq!(reasoning.id, "rs_hidden_1");
// Encrypted content should be present // Encrypted content should be present
assert_eq!( assert_eq!(