Skip to content

fix(api): normalize content field format before sending to provider - #67767

Open
jelloeater-agent wants to merge 1 commit into
NousResearch:mainfrom
jelloeater-agent:fix/content-format-sanitization
Open

fix(api): normalize content field format before sending to provider#67767
jelloeater-agent wants to merge 1 commit into
NousResearch:mainfrom
jelloeater-agent:fix/content-format-sanitization

Conversation

@jelloeater-agent

Copy link
Copy Markdown

Problem

Bifrost and OpenRouter strictly validate the message content field, rejecting assistant messages whose content is neither a string nor an array of Content block dicts with HTTP 400: "content field is neither a string nor an array of Content blocks".

This is triggered when malformed content reaches the wire via:

  • Thinking-only prefill continuation — the model emits reasoning but no visible text, and the prefill message gets appended with content in an invalid format
  • Message mergingdrop_thinking_only_and_merge_users can produce content as an array of plain strings instead of proper Content blocks

Fix

Add content format normalization to sanitize_api_messages(), the final pre-API chokepoint that already handles empty tool_calls, orphaned tool results, and duplicate call IDs.

Incoming content What gets sent to API
["hello", "world"] (strings array) [{"type":"text","text":"hello"}, {"type":"text","text":"world"}]
[{"text":"no type"}] (dicts missing type) [{"type":"text","text":"no type"}]
{"weird": "format"} (non-string, non-list) str(..)
None / valid string / valid content blocks Pass through unchanged

Testing

All core normalization paths verified:

  • Normal messages pass through unchanged
  • List of strings → proper Content block array
  • Dicts missing typetype="text" fallback added
  • Non-string/non-list values → string conversion
  • None content → passes through
  • Valid mixed content blocks preserved
  • Empty all-text blocks collapsed to empty string

Bifrost and OpenRouter strictly validate the message content field,
rejecting assistant messages whose content is neither a string nor
an array of Content block dicts with HTTP 400: 'content field is
neither a string nor an array of Content blocks'.

This was triggered when malformed content (e.g., an array of plain
strings like ["thinking", "text"], or dicts missing the 'type'
key) reached the wire via thinking-only prefill continuation or
message merging.

Fix: add content format normalization to sanitize_api_messages(),
the final pre-API chokepoint that already handles empty tool_calls,
orphaned tool results, and duplicate call IDs. Normalizations:
  - Array of plain strings -> Content block array
    [{'type':'text','text':'...'}, ...]
  - Dicts missing 'type' key -> add type='text' fallback
  - Non-string, non-list values -> convert to str
  - None/null -> pass through unchanged
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/openrouter OpenRouter aggregator labels Jul 20, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #64296 and #17632. This normalizes the content field at the final API-message sanitizer; those PRs preserve other strict-provider message fields, so the mechanisms are complementary.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for targeting strict-provider message validation. The compatibility gap is still actionable: sanitize_api_messages() on current main has no content-shape normalization (agent/agent_runtime_helpers.py:2762-2957).

Problems

  • The new pass is not the final request mutation. The main loop calls _sanitize_api_messages() before _drop_thinking_only_and_merge_users() (agent/conversation_loop.py:1403-1416), so content can still be changed after this normalization.
  • The diff contains no regression tests. Existing merge coverage only asserts typed-block inputs and outputs (tests/run_agent/test_thinking_only_sanitizer.py:244-268).
  • The proposed merge explanation is not reproduced by current code: mixed string/list branches create typed text blocks, while list/list only concatenates its existing operands (agent/agent_runtime_helpers.py:1352-1369).

Suggested changes

  • Normalize after the drop/merge pass in both request paths, or prove later mutations cannot introduce invalid content.
  • Add full request-path tests for malformed lists, typeless dicts, scalar/dict values, and valid typed blocks.

Automated hermes-sweeper review.


# --- Normalize content field formats ────────────────────────────
# Bifrost/OpenRouter reject assistant messages whose `content` is
# neither a string nor an array of Content block dicts (e.g. an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the final content mutation in the main path: _drop_thinking_only_and_merge_users() runs after this sanitizer at agent/conversation_loop.py:1403-1416. Please place the normalization after that pass (and mirror the summary path), or demonstrate that the later pass cannot create an invalid wire shape.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/openrouter OpenRouter aggregator sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants