Skip to content

fix(openai): re-generate when gpt-5.4 leaks internal tool-call format into assistant text#6483

Open
markine wants to merge 1 commit into
livekit:mainfrom
markine:fix/openai-responses-scaffolding-leak
Open

fix(openai): re-generate when gpt-5.4 leaks internal tool-call format into assistant text#6483
markine wants to merge 1 commit into
livekit:mainfrom
markine:fix/openai-responses-scaffolding-leak

Conversation

@markine

@markine markine commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

The gpt-5.4 series intermittently emits its internal parallel tool-call format as
assistant text
instead of as structured function calls — e.g.

to=multi_tool_use.parallel {"tool_uses":[{"recipient_name":"functions.foo","parameters":{...}}, ...]}

often interleaved with memorized training-data spam (non-Latin SEO tokens). The
Responses plugin faithfully forwards this as ChoiceDelta.content, so the corruption
reaches the caller: for a voice agent it is spoken aloud, it lands in the transcript,
and it is fed back into the next turn's context.

This adds a small, model-scoped guard to the Responses LLMStream: detect the
signature at the very start of the assistant text — before any of it is streamed — and
raise a retryable error so the existing LLMStream retry loop re-issues the
request for a fresh, clean generation.

Corroborating reports / prior art

multi_tool_use.parallel / recipient_name / tool_uses are old ChatGPT plugin-era
internal constructs that should never surface to an API caller. This corruption is
well documented:

Why this shape

  • Raise, don't interpret. We deliberately do not reconstruct the intended tool
    calls from the tool_uses payload — acting on a misread of corrupted tokens is worse
    than regenerating, and the barf is stochastic so a re-generation comes back clean.
  • Raise before emitting. A bounded head buffer (_SCAFFOLD_HEAD_CHARS, 40 chars)
    holds the first tokens of each message item so the signature is caught before anything
    is streamed. Since we raise before response.completed, no response state is committed
    and previous_response_id chaining is untouched.
  • Respect the retry invariant. The stream becomes non-retryable once an event has
    been forwarded (re-running would double-emit). If assistant text was already streamed
    this response, the leak is raised non-retryable (fail the turn) instead.
  • Model-scoped. Gated to the gpt-5.4 family (_model_may_leak_scaffolding). Every
    other model keeps the original, verbatim streaming path — no buffering, no detection,
    no behavior change.

Testing

tests/test_plugin_openai_responses.py (drives the real _process_event dispatch with
constructed Responses events; no network):

  • a leak raises retryable before any content is emitted (no state committed);
  • a leak after content was already streamed raises non-retryable;
  • clean long text streams unchanged; clean short text is flushed at output_item.done;
  • an unaffected model (gpt-4.1) streams the same leak input verbatim (guard off);
  • detection + model-scope helper unit checks.

ruff check / ruff format clean.

Note

On this base, tests/test_plugin_openai_responses.py::test_reasoning_object_serialized_without_null_fields
already fails on a clean checkout (_ResponsesWebsocket.__init__() missing 1 required positional argument: 'model' — a pre-existing test/source signature mismatch unrelated
to this change). Left untouched.

🤖 Generated with Claude Code

… into text

The gpt-5.4 series intermittently emits its internal parallel tool-call
format -- `to=multi_tool_use.parallel {"tool_uses":[{"recipient_name":...,
"parameters":{...}}, ...]}`, often interleaved with memorized training-data
spam -- as assistant TEXT instead of as structured function calls. The
Responses plugin faithfully forwards that text as ChoiceDelta content, so the
corruption reaches the caller (spoken aloud by a voice agent, shown in the
transcript, and fed back into the next turn's context).

Detect the signature at the very start of the assistant text -- within a
bounded head buffer, before any of it is streamed -- and raise a retryable
APIStatusError so the existing LLMStream retry loop re-issues the request for
a fresh, clean generation. Because we raise before response.completed, no
corrupt state is committed and previous_response_id chaining is untouched.

We deliberately do NOT reconstruct the intended tool calls from the corrupt
payload: acting on a misread of garbage tokens is worse than regenerating.

Scoped to the gpt-5.4 family; every other model keeps the original, verbatim
streaming path (no buffering, no detection, no behavior change). If content
was already streamed this response, the leak is raised non-retryable instead
(a retry would double-emit what the caller received).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@markine
markine requested a review from a team as a code owner July 18, 2026 19:47

@devin-ai-integration devin-ai-integration Bot 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@markine

markine commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Hi team. This is a Claude-generated patch, please let me know if there are conventions in your repo that I've missed or if there's a stylistic improvement you'd like to make. I don't know the intricacies of your websocket API. This is a hard model glitch to replicate live so I'm relying on the crafted unit test. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant