mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
parent
4aa499ff9f
commit
b9146a47bc
3 changed files with 75 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue