Skip to content

fix(mitm): strip trailing assistant prefill to prevent upstream Anthropic 400 errors - #7520

Merged
diegosouzapw merged 10 commits into
diegosouzapw:release/v3.8.49from
chirag127:pr/strip-assistant-prefill
Jul 19, 2026
Merged

diegosouzapw merged 10 commits into
diegosouzapw:release/v3.8.49from
chirag127:pr/strip-assistant-prefill

Conversation

@chirag127

Copy link
Copy Markdown
Contributor

Problem

When using newer Anthropic models (e.g. Claude 4.6 Sonnet) or specific configurations where the upstream provider enforces strict formatting constraints, assistant message prefill is not supported. Claude Code automatically sends trailing assistant messages in its payloads, triggering a 400 Bad Request error:
This model does not support assistant message prefill. The conversation must end with a user message.

Solution

Strip trailing assistant prefill messages inside the Claude Code MITM handler before forwarding the request to the router. This ensures the messages array always ends with a user message.

Acceptance Criteria

  • Prevents 400 Bad Request errors in Claude Code when prefill is rejected upstream.

diegosouzapw and others added 8 commits July 14, 2026 13:28
… (queue_conditions alone are eligibility-only) (#7179)
…uto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216)
…ils every PR (#7341)

main's copy of this test still does git I/O inside a unit test:

    const baseSrc = git(['show', 'origin/main:' + FILE]);

Runners check out a shallow single ref, so origin/main does not resolve and the
test dies with 'fatal: invalid object name origin/main'. Every PR into main
fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336
and #7337, six PRs red on a defect none of them introduced. #7313 has no other
red at all.

release/v3.8.49 already carries a fix (2e42b8e, #7174: try/catch, fetch
origin/main on demand, t.skip() when unreachable), but it only reaches main at
release time — so main stays broken for the whole cycle. Cherry-picking it would
also import a new problem: PR Test Policy classifies t.skip() as a silenced
assertion, which we watched it correctly catch on #7300 today.

This is the hermetic version instead (ported from #7327, which does the same for
the release branch): read the file straight off disk, compare against an empty
base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point —
and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the
runner's checkout depth can break.

The #6634 regression stays covered: the guard's logic lives in
SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both
ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and
the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left
byte-identical.

Co-authored-by: growab <nekron@icloud.com>
…bers (#7347)

main's ratchet had been failing --require-tighten on every PR: 11 metrics
improved but the baseline was never tightened. Same class as the #6634
selfref guard — an infra fix that lands only on the release branch leaves
main red for the whole cycle, and every PR into main pays for it.

Values are the merged-coverage numbers from a run on main itself (a local
run measures ~68% vs CI's ~80%; the baseline's own note warns about that
gap). Only the 11 coverage values change — gitleaks and semgrepFindings
keep main's own state.

No changelog fragment: #7326 carries it on release/v3.8.49, and a second
one here would double the entry at release time.
@chirag127
chirag127 requested a review from diegosouzapw as a code owner July 16, 2026 22:59
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b4515472b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/mitm/handlers/claudeCode.ts Outdated
Comment on lines +32 to +33
if (lastMsg && lastMsg.role === "assistant") {
payload.messages.pop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip all trailing assistant prefills

When a Claude Code payload contains more than one trailing assistant turn, this removes only the final element and still forwards a body whose last message is assistant, so the strict upstreams this change targets can return the same “conversation must end with a user message” 400. Use a loop that stops before emptying the array so every consecutive trailing prefill is removed while preserving at least one message.

Useful? React with 👍 / 👎.

@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks for chasing this down — the trailing-prefill 400 is a real bug class, and it matches three fixes we already carry for the same upstream error: dropTrailingAssistantPrefill in open-sse/executors/github.ts (Copilot), the Antigravity/Vertex strip in open-sse/executors/antigravity.ts, and the CC-compatible-sites strip in open-sse/services/claudeCodeCompatible.ts. Comparing against those, two things need fixing before this can merge:

  1. The strip should loop over all trailing assistant turns, not pop just one (while (...) pop() instead of if (...) pop()) — a conversation ending in two consecutive assistant messages would still 400 after this patch.
  2. It needs a guard against emptying the array to zero. I tested locally: a payload whose only message is {role: "assistant", ...} becomes messages: [] after this patch, which the upstream API will likely reject with a different 400 (empty messages array) instead of the one you're fixing. Both existing precedents explicitly guard against this.

One more thing worth discussing: this fires in the MITM ingress handler, before OmniRoute has picked a downstream provider/model, so it strips prefill unconditionally even for models that do support it. The three existing fixes are all scoped to the specific executor/provider that actually rejects prefill for that reason. Could you scope this the same way, or add a short comment explaining why blanket-stripping at ingress is the intended trade-off for Claude Code traffic?

Finally, please add a test to tests/unit/mitm-handler-claudeCode.test.ts — there's already a runHandler harness there, and right now the only test is the happy path with no trailing assistant turn at all.

@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.8.49 July 17, 2026 19:59
chirag127 and others added 2 commits July 18, 2026 06:59
The trailing-assistant-prefill strip only popped a single message, so a
conversation ending in 2+ consecutive assistant turns still hit the
upstream "This model does not support assistant message prefill" 400.
It also had no floor, so a payload whose entire history was trailing
assistant turns collapsed to messages: [], trading one 400 for another
("messages: at least 1 item required").

Loop over all consecutive trailing assistant messages and never strip
below 1 remaining message, mirroring the same pop-loop bound already used
by dropTrailingAssistantPrefill (open-sse/executors/github.ts) and
stripTrailingAntigravityAssistantTurn (open-sse/executors/antigravity.ts).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Resolve add/add conflict in .mergify.yml by taking release's current
canonical version — the file is functionally inert on release branches
(Mergify only reads config from the default branch, per #7168), and
this branch's copy predates several Mergify-config iterations already
on the release tip (auto-enqueue migration, dast-smoke tolerance,
batch_size removal). config/quality/quality-baseline.json auto-merged
identical to release's own copy (this branch's own baseline-tightening
commit was already superseded). The PR's own while-loop
trailing-assistant-prefill fix in src/mitm/handlers/claudeCode.ts
auto-merged cleanly with zero conflicts, unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw
diegosouzapw merged commit fac866f into diegosouzapw:release/v3.8.49 Jul 19, 2026
5 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Merged into release/v3.8.49 — thank you for the contribution, @chirag127! 🎉 Validated in today's full-suite merge-train (33 PRs, 19k+ tests green) before landing.

@chirag127
chirag127 deleted the pr/strip-assistant-prefill branch July 22, 2026 17:39
@diegosouzapw diegosouzapw mentioned this pull request Jul 23, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…opic 400 errors (diegosouzapw#7520)

* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (diegosouzapw#7168)

* fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (diegosouzapw#7179)

* fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (diegosouzapw#7216)

* fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (diegosouzapw#7220)

* fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (diegosouzapw#7225)

* test(ci): make the diegosouzapw#6634 selfref guard hermetic — main's copy hard-fails every PR (diegosouzapw#7341)

main's copy of this test still does git I/O inside a unit test:

    const baseSrc = git(['show', 'origin/main:' + FILE]);

Runners check out a shallow single ref, so origin/main does not resolve and the
test dies with 'fatal: invalid object name origin/main'. Every PR into main
fails Unit Tests (7/8) on it — today that is diegosouzapw#7313, diegosouzapw#7315, diegosouzapw#7316, diegosouzapw#7334, diegosouzapw#7336
and diegosouzapw#7337, six PRs red on a defect none of them introduced. diegosouzapw#7313 has no other
red at all.

release/v3.8.49 already carries a fix (8bbd411, diegosouzapw#7174: try/catch, fetch
origin/main on demand, t.skip() when unreachable), but it only reaches main at
release time — so main stays broken for the whole cycle. Cherry-picking it would
also import a new problem: PR Test Policy classifies t.skip() as a silenced
assertion, which we watched it correctly catch on diegosouzapw#7300 today.

This is the hermetic version instead (ported from diegosouzapw#7327, which does the same for
the release branch): read the file straight off disk, compare against an empty
base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point —
and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the
runner's checkout depth can break.

The diegosouzapw#6634 regression stays covered: the guard's logic lives in
SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both
ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and
the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left
byte-identical.

Co-authored-by: growab <nekron@icloud.com>

* chore(quality): tighten main's coverage baseline to the CI's real numbers (diegosouzapw#7347)

main's ratchet had been failing --require-tighten on every PR: 11 metrics
improved but the baseline was never tightened. Same class as the diegosouzapw#6634
selfref guard — an infra fix that lands only on the release branch leaves
main red for the whole cycle, and every PR into main pays for it.

Values are the merged-coverage numbers from a run on main itself (a local
run measures ~68% vs CI's ~80%; the baseline's own note warns about that
gap). Only the 11 coverage values change — gitleaks and semgrepFindings
keep main's own state.

No changelog fragment: diegosouzapw#7326 carries it on release/v3.8.49, and a second
one here would double the entry at release time.

* fix(mitm): strip trailing assistant prefill to prevent upstream Anthropic 400 errors

* fix(mitm): loop over all trailing assistant turns + never-empty guard

The trailing-assistant-prefill strip only popped a single message, so a
conversation ending in 2+ consecutive assistant turns still hit the
upstream "This model does not support assistant message prefill" 400.
It also had no floor, so a payload whose entire history was trailing
assistant turns collapsed to messages: [], trading one 400 for another
("messages: at least 1 item required").

Loop over all consecutive trailing assistant messages and never strip
below 1 remaining message, mirroring the same pop-loop bound already used
by dropTrailingAssistantPrefill (open-sse/executors/github.ts) and
stripTrailingAntigravityAssistantTurn (open-sse/executors/antigravity.ts).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: growab <nekron@icloud.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…opic 400 errors (diegosouzapw#7520)

* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (diegosouzapw#7168)

* fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (diegosouzapw#7179)

* fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (diegosouzapw#7216)

* fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (diegosouzapw#7220)

* fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (diegosouzapw#7225)

* test(ci): make the diegosouzapw#6634 selfref guard hermetic — main's copy hard-fails every PR (diegosouzapw#7341)

main's copy of this test still does git I/O inside a unit test:

    const baseSrc = git(['show', 'origin/main:' + FILE]);

Runners check out a shallow single ref, so origin/main does not resolve and the
test dies with 'fatal: invalid object name origin/main'. Every PR into main
fails Unit Tests (7/8) on it — today that is diegosouzapw#7313, diegosouzapw#7315, diegosouzapw#7316, diegosouzapw#7334, diegosouzapw#7336
and diegosouzapw#7337, six PRs red on a defect none of them introduced. diegosouzapw#7313 has no other
red at all.

release/v3.8.49 already carries a fix (83a7551, diegosouzapw#7174: try/catch, fetch
origin/main on demand, t.skip() when unreachable), but it only reaches main at
release time — so main stays broken for the whole cycle. Cherry-picking it would
also import a new problem: PR Test Policy classifies t.skip() as a silenced
assertion, which we watched it correctly catch on diegosouzapw#7300 today.

This is the hermetic version instead (ported from diegosouzapw#7327, which does the same for
the release branch): read the file straight off disk, compare against an empty
base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point —
and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the
runner's checkout depth can break.

The diegosouzapw#6634 regression stays covered: the guard's logic lives in
SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both
ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and
the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left
byte-identical.

Co-authored-by: growab <nekron@icloud.com>

* chore(quality): tighten main's coverage baseline to the CI's real numbers (diegosouzapw#7347)

main's ratchet had been failing --require-tighten on every PR: 11 metrics
improved but the baseline was never tightened. Same class as the diegosouzapw#6634
selfref guard — an infra fix that lands only on the release branch leaves
main red for the whole cycle, and every PR into main pays for it.

Values are the merged-coverage numbers from a run on main itself (a local
run measures ~68% vs CI's ~80%; the baseline's own note warns about that
gap). Only the 11 coverage values change — gitleaks and semgrepFindings
keep main's own state.

No changelog fragment: diegosouzapw#7326 carries it on release/v3.8.49, and a second
one here would double the entry at release time.

* fix(mitm): strip trailing assistant prefill to prevent upstream Anthropic 400 errors

* fix(mitm): loop over all trailing assistant turns + never-empty guard

The trailing-assistant-prefill strip only popped a single message, so a
conversation ending in 2+ consecutive assistant turns still hit the
upstream "This model does not support assistant message prefill" 400.
It also had no floor, so a payload whose entire history was trailing
assistant turns collapsed to messages: [], trading one 400 for another
("messages: at least 1 item required").

Loop over all consecutive trailing assistant messages and never strip
below 1 remaining message, mirroring the same pop-loop bound already used
by dropTrailingAssistantPrefill (open-sse/executors/github.ts) and
stripTrailingAntigravityAssistantTurn (open-sse/executors/antigravity.ts).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: growab <nekron@icloud.com>
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.

2 participants