Internal message fields leaking to strict API providers
NousResearch/hermes-agent · cluster 30959 · agent
Data as of
4 open items. Fix merged in #31456. Check it covers your case.
The short answer
- Known?
- Yes. 29 related items grouped here, 10 issues already closed.
- Fix event
- A fix was merged to main in PR #31456. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #31456, #49840. That is separate from the fix event. - Still open
- 1 issue and 3 PRs.
Next step: read #31456, then check whether your report is one of the 1 still open.
Read #31456Pick up a Lab taskMultiple bug reports and fixes address internal bookkeeping fields (timestamp, message_id, observed, etc.) being included in outgoing API message payloads, causing HTTP 400/422 errors on strict-schema providers like Mistral and GLM. The fixes involve stripping these fields before sending requests to external LLM endpoints.
Open issues1
- Issue#48523convert_messages doesn't strip timestamp/message_id/observed/finish_reason — causes 400 with strict providers in gateway mode
Open PRs3
Fixes on record
-
#31456 fix(transport): strip Hermes-internal scaffolding keys + classify 5xx validation errors (salvage #26054)
files:
agent/error_classifier.py,agent/transports/chat_completions.py,scripts/release.py,tests/agent/test_error_classifier.py,tests/agent/transports/test_chat_completions.pyShow the diff
-
agent/error_classifier.py@@ -240,6 +240,24 @@ def is_auth(self) -> bool: "unsupported model", ] +# Request-validation patterns — the request is malformed and will fail +# identically on every retry. Some OpenAI-compatible gateways (notably +# codex.nekos.me) return these as 5xx instead of the standard 4xx, which +# makes the generic "5xx → retryable server_error" rule misfire: the retry +# loop hammers the same deterministic rejection 3+ times, then the +# transport-recovery path resets the counter and does it again, producing +# a request flood. When a 5xx body carries one of these unambiguous +# request-validation signals, classify as a non-retryable format_error so +# the loop fails fast and falls back instead of looping. +_REQUEST_VALIDATION_PATTERNS = [ + "unknown parameter", + "unsupported parameter", + "unrecognized request argument", + "invalid_request_error", + "unknown_parameter", + "unsupported_parameter", +] + # OpenRouter aggregator policy-block patterns. # # When a user's OpenRouter account privacy setting (or a per-request @@ -745,6 +763,23 @@ def _classify_by_status( ) if status_code in {500, 502}: + # Some OpenAI-compatible gateways return request-validation errors + # with a 5xx status (codex.nekos.me returns 502 for unknown/ + # unsupported parameters). These are deterministic — every retry + # gets the identical rejection — so the generic "5xx → retryable + # server_error" rule turns one bad request into a retry flood. + # Detect the unambiguous request-validation signals (in either the + # message text or the structured error code) and fail fast. + if ( + any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS) + or error_code.lower() in {"invalid_request_error", "unknown_parameter", + "unsupported_parameter"} + ): + return result_fn( + FailoverReason.format_error, + retryable=False, + should_fallback=True, + ) return result_fn(FailoverReason.server_error, retryable=True) if status_code in {503, 529}: -
agent/transports/chat_completions.py@@ -113,9 +113,8 @@ def convert_messages( self, messages: list[dict[str, Any]], **kwargs ) -> list[dict[str, Any]]: """Messages are already in OpenAI format — strip internal fields - that strict chat-completions providers reject with HTTP 400/422. - - Strips: + that strict chat-completions providers reject with HTTP 400/422 + (or, in the case of some OpenAI-compatible gateways, 5xx): - Codex Responses API fields: ``codex_reasoning_items`` / ``codex_message_items`` on the message, ``call_id`` / @@ -127,6 +126,16 @@ def convert_messages( ``Extra inputs are not permitted, field: 'messages[N].tool_name'``. Permissive providers (OpenRouter, MiniMax) silently ignore the field, which masked the bug for months. + - Hermes-internal scaffolding markers — any top-level message key + starting with ``_`` (e.g. ``_empty_recovery_synthetic``, + ``_empty_terminal_sentinel``, ``_thinking_prefill``). These are + bookkeeping flags the agent loop attaches to messages so the + persistence layer can later strip its own scaffolding; they must + never reach the wire. Permissive providers (real OpenAI, + Anthropic) silently drop unknown message keys, but strict + gateways (e.g. opencode-go, codex.nekos.me) reject with + ``Extra inputs are not permitted, field: 'messages[N]._empty_recovery_synthetic'``, + which then poisons every subsequent request in the session. """ needs_sanitize = False for msg in messages: @@ -139,6 +148,9 @@ def convert_messages( ): needs_sanitize = True break + if any(isinstance(k, str) and k.startswith("_") for k in msg): + needs_sanitize = True + break tool_calls = msg.get("tool_calls") if isinstance(tool_calls, list): for tc in tool_calls: @@ -160,6 +172,11 @@ def convert_messages( msg.pop("codex_reasoning_items", None) msg.pop("codex_message_items", None) msg.pop("tool_name", None) + # Drop all Hermes-internal scaffolding markers (``_``-prefixed). + # OpenAI's message schema has no ``_``-prefixed fields, so this + # is safe and future-proofs against new markers being added. + for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]: + msg.pop(key, None) tool_calls = msg.get("tool_calls") if isinstance(tool_calls, list): for tc in tool_calls: -
scripts/release.py@@ -70,6 +70,8 @@ "[redacted:email]": "qiyin-code", "[redacted:email]": "aashizpoudel", "[redacted:email]": "shaun0927", + "[redacted:email]": "Soju06", + "[redacted:email]": "Soju06", "[redacted:email]": "Bihruze", "[redacted:email]": "Lempkey", "[redacted:email]": "m0n3r0", -
tests/agent/test_error_classifier.py@@ -293,6 +293,64 @@ def test_529_anthropic_overloaded(self): result = classify_api_error(e) assert result.reason == FailoverReason.overloaded + # ── 5xx that are actually request-validation errors ── + # Some OpenAI-compatible gateways (e.g. codex.nekos.me) return + # request-validation failures with a 5xx status. These are + # deterministic, so they must NOT be retried — otherwise the retry + # loop hammers the identical bad request into a flood. + + def test_502_with_unknown_parameter_is_non_retryable(self): + e = MockAPIError( + "Unknown parameter: 'input[617]._empty_recovery_synthetic'", + status_code=502, + body={ + "error": { + "type": "invalid_request_error", + "message": ( + "[ObjectParam] [input[617]._empty_recovery_synthetic] " + "[unknown_parameter] Unknown parameter: " + "'input[617]._empty_recovery_synthetic'." + ), + } + }, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.format_error + assert result.retryable is False + assert result.should_fallback is True + + def test_502_with_unsupported_parameter_is_non_retryable(self): + e = MockAPIError( + "Unsupported parameter: logprobs", + status_code=502, + body={ + "error": { + "type": "invalid_request_error", + "message": "Unsupported parameter: logprobs", + } + }, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.format_error + assert result.retryable is False + + def test_500_with_invalid_request_error_type_is_non_retryable(self): + e = MockAPIError( + "bad request", + status_code=500, + body={"error": {"type": "invalid_request_error", "message": "bad request"}}, + ) + result = classify_api_error(e) + assert result.reason == FailoverReason.format_error + assert result.retryable is False + + def test_502_plain_bad_gateway_still_retryable(self): + """A genuine 502 with no request-validation signal stays retryable.""" + e = MockAPIError("Bad Gateway", status_code=502) + result = classify_api_error(e) + assert result.reason == FailoverReason.server_error + assert result.retryable is True + # ── Model not found ── def test_404_model_not_found(self): -
tests/agent/transports/test_chat_completions.py@@ -66,6 +66,38 @@ def test_convert_messages_strips_tool_name(self, transport): # Original list untouched (deepcopy-on-demand) assert msgs[2]["tool_name"] == "execute_code" + def test_convert_messages_strips_internal_scaffolding_markers(self, transport): + """Hermes-internal ``_``-prefixed markers must never reach the wire. + + The empty-response recovery path appends synthetic messages tagged + with ``_empty_recovery_synthetic``; permissive providers ignore the + unknown key, but strict gateways (opencode-go, codex.nekos.me) + reject the request, poisoning every later turn in the session. + """ + msgs = [ + {"role": "user", "content": "run the task"}, + {"role": "assistant", "content": "(empty)", "_empty_recovery_synthetic": True}, + {"role": "user", "content": "continue", "_empty_recovery_synthetic": True}, + {"role": "assistant", "content": "done", "_thinking_prefill": True, + "_empty_terminal_sentinel": True}, + ] + result = transport.convert_messages(msgs) + for m in result: + assert not any(k.startswith("_") for k in m), m + # Visible content preserved + assert result[1]["content"] == "(empty)" + assert result[2]["content"] == "continue" + # Original list untouched (deepcopy-on-demand) + assert msgs[1]["_empty_recovery_synthetic"] is True + + def test_convert_messages_clean_list_is_identity(self, transport): + """A list with no internal/codex keys is returned as-is (no copy).""" + msgs = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + assert transport.convert_messages(msgs) is msgs + class TestChatCompletionsBuildKwargs:
-
-
#49840 fix(chat-completions): strip internal timestamp field before strict providers
files:
agent/transports/chat_completions.py,scripts/release.py,tests/agent/transports/test_chat_completions.pyShow the diff
-
agent/transports/chat_completions.py@@ -172,6 +172,7 @@ def convert_messages( "codex_reasoning_items" in msg or "codex_message_items" in msg or "tool_name" in msg + or "timestamp" in msg # #47868 — strict providers reject this ): needs_sanitize = True break @@ -201,6 +202,7 @@ def convert_messages( msg.pop("codex_reasoning_items", None) msg.pop("codex_message_items", None) msg.pop("tool_name", None) + msg.pop("timestamp", None) # #47868 — leak into strict providers # Drop all Hermes-internal scaffolding markers (``_``-prefixed). # OpenAI's message schema has no ``_``-prefixed fields, so this # is safe and future-proofs against new markers being added. -
scripts/release.py@@ -46,6 +46,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "[redacted:email]": "w31rdm4ch1nZ", + "[redacted:email]": "x7peeps", "[redacted:email]": "rratmansky", "[redacted:email]": "lkz-de", "[redacted:email]": "salesondemandio", -
tests/agent/transports/test_chat_completions.py@@ -104,6 +104,31 @@ def test_convert_messages_strips_tool_name(self, transport): # Original list untouched (deepcopy-on-demand) assert msgs[2]["tool_name"] == "execute_code" + def test_convert_messages_strips_timestamp(self, transport): + """Internal per-message ``timestamp`` metadata (stamped by + ``_apply_persist_user_message_override`` to preserve platform event + time without embedding it in content, and persisted to the SQLite + store) is not part of the OpenAI Chat Completions schema. Strict + providers like Mistral / Fireworks-backed endpoints reject it with + HTTP 422 'Extra inputs are not permitted, field: messages[N].timestamp'. + Regression test for #47868. + """ + msgs = [ + {"role": "user", "content": "hi", "timestamp": 1781976577.0}, + ] + result = transport.convert_messages(msgs) + assert "timestamp" not in result[0] + assert result[0]["content"] == "hi" + assert result[0]["role"] == "user" + # Original list untouched (deepcopy-on-demand) + assert msgs[0]["timestamp"] == 1781976577.0 + + def test_convert_messages_no_copy_without_timestamp(self, transport): + """A timestamp-free message list needs no sanitize pass and is + returned by identity (preserves the deepcopy-on-demand contract).""" + msgs = [{"role": "user", "content": "hi"}] + assert transport.convert_messages(msgs) is msgs + def test_convert_messages_strips_internal_scaffolding_markers(self, transport): """Hermes-internal ``_``-prefixed markers must never reach the wire.
-
13 PRs closed without merging
From the close comments: done elsewhere 4 · duplicate 3 · superseded by another PR 3 · merged via another change 2 · other 1
-
#30982 fix(transport): strip internal bookkeeping fields before API calls · done elsewhere →
775a172,#30959,#31456 -
#47875 fix(chat-completions): strip timestamp from messages before sending to strict providers · merged via another change →
4467c22,#49840,#47868 -
#47909 fix: strip internal message metadata before LLM calls · superseded by another PR →
#47875,#47868 -
#48266 fix:sanitize internal fields (timestamp) from API messages #48263 · duplicate →
#47868,#48263 -
#48295 fix(transports/chat_completions): strip session timestamp from API-facing messages · superseded by another PR →
#47875
Show 8 more
-
#48358 fix(transport): strip internal timestamp from API messages (#48263) · merged via another change →
#47875,#48295,#48331,#48263,#47868 -
#48592 fix(chat_completions): strip timestamp/observed/platform_message_id from API messages · duplicate →
#47875,#48358,#48170,#47868 -
#48604 fix(agent): strip gateway replay metadata from messages before API calls (#48523) · duplicate →
#47875,#48592,#48358,#48575,#48523,#47868 -
#49573 fix(transport): strip timestamp and observed from chat completions messages · superseded by another PR →
#49595,#49572 - #55907 fix(agent): strip message timestamp before API calls · other
-
#55987 fix(agent): strip timestamp field from API messages (#55902) · done elsewhere →
4467c22 -
#56112 fix(agent): strip timestamp field in summary path sanitizer · done elsewhere →
6d3d9d0 -
#68166 fix(agent): strip gateway-only timestamp/message_id keys before LLM call · done elsewhere →
4467c22,#68077
Closed issues · 10
- Closed#30959Internal bookkeeping fields leak to strict providers, causing HTTP 400 retry loops
- Closed#47868Strict chat-completions providers reject leaked messages[].timestamp metadata
- Closed#48263[Bug]: GLM 5.2 via OpenCode Go fails with HTTP 400 — internal `timestamp` field leaked into API messages
- Closed#48386[Bug]: Gateway sends timestamp metadata field in message objects → HTTP 422 on strict OpenAI-compatible endpoints (api.mistral.ai)
- Closed#48772cannot disable the timestamp in config
- Closed#49490[Bug]: OpenCode Zen provider does not work on v0.17.0
- Closed#49572chat_completions transport: timestamp field on message dicts causes HTTP 400 on strict providers
- Closed#53139[BUG] GLM 5.2 multi-turn sessions fail via OpenCode Go due to timestamp field injection
- Closed#55902HTTP 400 with OpenCode Go + Kimi K2.5: 'messages[N].timestamp' is not permitted
- Closed#68077sanitize_api_messages() doesn't strip gateway-only timestamp/message_id keys, causing HTTP 400 on strict-schema providers (GLM-5.2)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab