fix(codex): never replay message-item id on Copilot Responses connections

Copilot (api.githubcopilot.com/responses) binds replayed assistant
codex_message_items ids to a specific backend "connection". Credential-
pool rotation, a gateway restart, or routine load-balancer churn between
turns all invalidate that binding, and Copilot rejects the stale id with
HTTP 401 "input item ID does not belong to this connection" — even for
short ids well under the #27038 64-char length cap, since this is a
connection-scope problem, not a length problem. Once a session captures
one of these ids it is persisted and replayed forever, permanently
bricking the session.

Thread an is_github_responses flag from build_kwargs/convert_messages
into _chat_messages_to_responses_input and drop the id unconditionally
on that path, mirroring how reasoning items already strip id on replay.
phase/status/content are still replayed so cache-relevant signal isn't
lost — only the connection-scoped id is unsafe to reuse.

Written to apply independently of the #27038 length-cap fix so the two
PRs don't block each other; they touch adjacent conditions in the same
block and merge cleanly in either order.

Fixes #32716

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
joaomarcos 2026-07-10 15:32:06 -03:00 committed by kshitij
parent 4aa499ff9f
commit b9146a47bc
3 changed files with 75 additions and 1 deletions

View file

@ -314,6 +314,7 @@ def _chat_messages_to_responses_input(
messages: List[Dict[str, Any]],
*,
is_xai_responses: bool = False,
is_github_responses: bool = False,
replay_encrypted_reasoning: bool = True,
current_issuer_kind: Optional[str] = None,
) -> List[Dict[str, Any]]:
@ -338,6 +339,16 @@ def _chat_messages_to_responses_input(
items from the conversation history and threads ``replay_enabled=False``
through this converter so subsequent turns send no reasoning items.
``is_github_responses`` drops the ``id`` field from replayed
``codex_message_items`` regardless of length. The Copilot backend
(api.githubcopilot.com/responses) binds these ids to a specific
backend "connection" credential-pool rotation, a gateway restart,
or routine load-balancer churn between turns all invalidate it and
rejects a stale id with HTTP 401 "input item ID does not belong to
this connection" even for short ids (see #32716). ``phase``/
``status``/``content`` are still replayed; only ``id`` is unsafe to
reuse across a Copilot connection.
``current_issuer_kind`` enables a per-item cross-issuer guard. The
Responses API's ``encrypted_content`` blob is decryptable only by the
endpoint that minted it replaying a Codex-issued blob against xAI
@ -470,7 +481,11 @@ def _chat_messages_to_responses_input(
"content": normalized_content_parts,
}
item_id = raw_item.get("id")
if isinstance(item_id, str) and item_id.strip():
if (
not is_github_responses
and isinstance(item_id, str)
and item_id.strip()
):
stripped_id = item_id.strip()
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
replay_item["id"] = stripped_id

View file

@ -81,6 +81,7 @@ class ResponsesApiTransport(ProviderTransport):
return _chat_messages_to_responses_input(
messages,
is_xai_responses=bool(kwargs.get("is_xai_responses")),
is_github_responses=bool(kwargs.get("is_github_responses")),
replay_encrypted_reasoning=bool(
kwargs.get("replay_encrypted_reasoning", True)
),
@ -239,6 +240,7 @@ class ResponsesApiTransport(ProviderTransport):
"input": _chat_messages_to_responses_input(
payload_messages,
is_xai_responses=is_xai_responses,
is_github_responses=is_github_responses,
replay_encrypted_reasoning=replay_encrypted_reasoning,
current_issuer_kind=issuer_kind,
),

View file

@ -121,6 +121,63 @@ class TestCodexBuildKwargs:
)
assert "prompt_cache_key" not in kw
def test_github_responses_drops_message_item_id_end_to_end(self, transport):
# #32716: Copilot binds codex_message_items ids to a backend
# "connection" that doesn't survive credential rotation, a gateway
# restart, or load-balancer churn — replaying a stale id gets HTTP
# 401 "input item ID does not belong to this connection", even for
# ids well under the #27038 64-char length cap. build_kwargs must
# thread is_github_responses through to the input converter so the
# id never reaches the request.
messages = [
{"role": "system", "content": "You are Hermes."},
{
"role": "assistant",
"content": "pong",
"codex_message_items": [
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "pong"}],
"id": "msg_short_but_connection_scoped",
"phase": "final_answer",
}
],
},
]
kw = transport.build_kwargs(
model="gpt-5.5", messages=messages, tools=[],
is_github_responses=True,
)
message_item = next(item for item in kw["input"] if item.get("type") == "message")
assert "id" not in message_item
assert message_item["phase"] == "final_answer"
def test_non_github_responses_keeps_message_item_id_end_to_end(self, transport):
messages = [
{"role": "system", "content": "You are Hermes."},
{
"role": "assistant",
"content": "pong",
"codex_message_items": [
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "pong"}],
"id": "msg_short_id",
}
],
},
]
kw = transport.build_kwargs(
model="gpt-5.5", messages=messages, tools=[],
is_codex_backend=True,
)
message_item = next(item for item in kw["input"] if item.get("type") == "message")
assert message_item["id"] == "msg_short_id"
def test_xai_responses_sends_cache_key_via_extra_body(self, transport):
"""xAI's Responses API documents ``prompt_cache_key`` as the
body-level cache-routing key (the ``x-grok-conv-id`` header is