Skip to content

fix(translator): prevent doubled tool args in OpenAI-to-Claude response - #2279

Closed
vishalrajv wants to merge 3 commits into
decolua:masterfrom
vishalrajv:fix/invalid-tool-parameters-claude-code
Closed

vishalrajv wants to merge 3 commits into
decolua:masterfrom
vishalrajv:fix/invalid-tool-parameters-claude-code

Conversation

@vishalrajv

Copy link
Copy Markdown
Contributor

Problem

When using OpenAI-compatible models (e.g. mimo) with Claude Code clients, tool calls frequently fail with InputValidationError because the model's arguments arrive as doubled JSON (e.g. {query:x}{query:x}).

Root Cause

The OpenAI-compatible model sends duplicate SSE chunks with finish_reason set. The translator's finish block had no guard against this, so it ran twice — emitting tool arguments as two separate input_json_delta events. Claude Code concatenated both halves, producing invalid doubled JSON.

Changes

  • openai-to-claude.js: Add !state.finishReason guard to prevent double finish emission
  • openai-to-claude.js: Add deduplicateDoubledJson() as defense-in-depth in sanitizeToolArgs
  • claude-to-openai.js: Guard string tool input on request side to prevent double-encoding
  • openai-to-claude.test.js: Fix test state (add missing nextBlockIndex, finish_reason)

Testing

  • All 12 translator unit tests pass
  • Verified with live Claude Code + mimo-v2.5-free — tool calls now work correctly

Some OpenAI-compatible models (e.g. mimo) send duplicate SSE chunks with
finish_reason set. Without a guard, the finish block in
openaiToClaudeResponse ran twice, emitting tool arguments as two separate
input_json_delta events. Claude Code concatenated both halves, producing
invalid doubled JSON that triggered InputValidationError.

Changes:
- Add !state.finishReason guard to prevent double finish emission
- Add deduplicateDoubledJson() as defense-in-depth in sanitizeToolArgs
- Guard string tool input on request side to prevent double-encoding
- Fix test state (add missing nextBlockIndex, finish_reason)
@bloodf

bloodf commented Jul 2, 2026

Copy link
Copy Markdown

Heads-up: this guard regresses the multi-format pivot paths.

The !state.finishReason guard added here keys off state.finishReason, which is a shared translator-state field that upstream pivot stages also write. In Antigravity/Gemini → OpenAI → Claude (and any X → OpenAI → Claude pivot whose first stage sets state.finishReason), gemini-to-openai.js sets state.finishReason — for stream.js usage injection — before openaiToClaudeResponse runs. So by the time the finish chunk reaches openai-to-claude, state.finishReason is already truthy → the finish block is skipped → the buffered tool-call arguments are never flushed as input_json_delta, and content_block_stop/message_stop are never emitted. Tool calls then reach Claude clients with empty input.

Minimal repro — translateResponse(ANTIGRAVITY, CLAUDE, …) with a single functionCall emits only message_start + content_block_start (no input_json_delta, no stops).

A fix that preserves the dedupe intent without the cross-stage collision is to use a Claude-specific dedupe flag instead of the shared field:

// before
if (choice.finish_reason && !state.finishReason) {
// after
if (choice.finish_reason && !state.claudeFinishHandled) {
  state.claudeFinishHandled = true;

Keep state.finishReason = choice.finish_reason; inside the block for stream.js. This still fixes the mimo double-finish case while not being suppressed by an upstream pivot stage.

The previous  guard collided with upstream pivot
stages (e.g. Antigravity/Gemini → OpenAI → Claude) that set
 before  ran, causing tool
calls to emit with empty input.

Use a Claude-specific  flag instead, keeping
 for stream.js usage injection.
@vishalrajv

Copy link
Copy Markdown
Contributor Author

Addressed the regression flagged in the review.

Fix: Replaced the shared !state.finishReason guard with a Claude-specific !state.claudeFinishHandled flag. This prevents the finish block from being suppressed when an upstream pivot stage (e.g. Antigravity/Gemini → OpenAI → Claude) sets state.finishReason before openaiToClaudeResponse runs.

  • state.claudeFinishHandled = true is set inside the finish block (prevents double emission)
  • state.finishReason = choice.finish_reason is kept inside the block for stream.js usage injection

All translator tests pass (golden-response-stream, format-roundtrip, bugs-antigravity, bugs-openai-bridge).

bloodf pushed a commit to bloodf/durindoor that referenced this pull request Jul 10, 2026
Translator regression asserting the OpenAI->Claude response translator
deduplicates doubled JSON tool arguments (same object emitted twice) into a
single parseable input_json_delta, and emits message_stop exactly once.

Logic (claudeFinishHandled finish guard + deduplicateDoubledJson) already
present in dev; this locks the behavior.

Ported from decolua/9router#2279 @ 1c9ad466ed
bloodf pushed a commit to bloodf/durindoor that referenced this pull request Jul 10, 2026
Translator regression asserting the OpenAI->Claude response translator
deduplicates doubled JSON tool arguments (same object emitted twice) into a
single parseable input_json_delta, and emits message_stop exactly once.

Logic (claudeFinishHandled finish guard + deduplicateDoubledJson) already
present in dev; this locks the behavior.

Ported from decolua/9router#2279 @ 1c9ad466ed
bloodf pushed a commit to bloodf/durindoor that referenced this pull request Jul 10, 2026
Translator regression asserting the OpenAI->Claude response translator
deduplicates doubled JSON tool arguments (same object emitted twice) into a
single parseable input_json_delta, and emits message_stop exactly once.

Logic (claudeFinishHandled finish guard + deduplicateDoubledJson) already
present in dev; this locks the behavior.

Ported from decolua/9router#2279 @ 1c9ad466ed
bloodf added a commit to bloodf/durindoor that referenced this pull request Jul 10, 2026
* port(upstream): #2237 - salvage orphaned tool results across formats

Non-lossy salvageOrphanedToolResults folds orphan tool output into user text
(`[Tool result: ...]`) instead of dropping it, across messages[] (OpenAI role:tool,
Claude tool_result blocks) and contents[] (Gemini/Antigravity functionResponse).
Runs unconditionally in the request pipeline and after each compression stage
(RTK/Headroom/PXPIPE) with fixMissingToolResponses to restore the tool-pairing
invariant. Responses API function_call_output stays structurally stripped in
openai-responses.js. Removes the obsolete shouldStripOrphanedToolResults gate;
Gemini-family standalone functionResponse (no functionCall) is preserved.

Ported from decolua/9router#2237 @ a32bda9e44

* port(upstream): #2279 - test doubled tool args collapse openai->claude

Translator regression asserting the OpenAI->Claude response translator
deduplicates doubled JSON tool arguments (same object emitted twice) into a
single parseable input_json_delta, and emits message_stop exactly once.

Logic (claudeFinishHandled finish guard + deduplicateDoubledJson) already
present in dev; this locks the behavior.

Ported from decolua/9router#2279 @ 1c9ad466ed

* port(upstream): #2190 - test thinking stays out of visible content

Translator regression asserting a Claude->OpenAI stream surfaces reasoning via
the reasoning channel and never emits literal <think>/</think> text in
delta.content. Logic (reasoningDelta channel, no think-tag content deltas)
already present in dev; this locks the behavior.

Ported from decolua/9router#2190 @ dc417f9b3f

* port(upstream): #2318 - test strip Responses-only fields before forward

Translator regression asserting Responses->Chat translation drops
client_metadata/background/truncation (Responses-API-only fields rejected by
third-party chat providers with HTTP 400) while the plain OPENAI->OPENAI path
preserves them (negative control). Logic already present in dev; locks behavior.

Ported from decolua/9router#2318 @ edb20e143d

* fix(translator): skip salvage on native Gemini contents[]

restore the Gemini-family (gemini/gemini-cli/antigravity/vertex) guard
around salvageOrphanedToolResults in the translateRequest pipeline. the
body still carries native contents[] at that point; salvage keys known
functionCalls globally and rewrites any functionResponse whose id is not
in that set into '[Tool result: ...]' text, dropping legitimate tool
results before gemini->openai conversion can read them.

fixes CI run 29122517702 (tests/unit/gemini-to-openai-function-response).

---------

Co-authored-by: CortexOS <cortexos@localhost>
bloodf added a commit to bloodf/durindoor that referenced this pull request Jul 11, 2026
… response (#214)

Ported-from: decolua/9router#2279 @ 30b24bf4ad (includes follow-up 1c9ad466ed Claude-specific finish guard).

The response translator (openai-to-claude.js) + finish-guard flag + pivot-aware comment already landed on origin/dev @ 1e0d897 via the integrator stack before this worktree branched. This port adds only the gap:

- 3-line JSDoc comment in claude-to-openai.js:206-208 documenting the existing string-input passthrough (AGENTS.md §1 docs ALWAYS).
- New tests/translator/port-2279-claude-finish-guard.test.js (9 tests, imports ./registerAll.js) covering: doubled-args collapse, pivot-path regression (state.finishReason already truthy at handler entry must still flush message_delta exactly once), request-bridge string passthrough, and 3 QA adversarial boundaries (empty-string finishReason, claudeFinishHandled preset skips finish, triple-finish-chunks).

Verified by QA with mutation testing: disabling the finish guard makes the pivot regression test fail.

Authoring disclosure: model MiniMax-M3, harness Oh My Pi. Sol-equivalent review by Claude/slow per maintainer amendment (GPT 5.6-sol unavailable in harness).

Co-authored-by: durindoor-agent <durindoor-agent@local>
Co-authored-by: CortexOS <cortexos@localhost>
@vishalrajv

Copy link
Copy Markdown
Contributor Author

Hi @decolua, just a gentle reminder about this PR when you get a chance. It fixes doubled tool arguments that cause InputValidationError when using OpenAI-compatible models with Claude Code clients. All translator tests pass. Happy to address any feedback or make adjustments. Thank you for your time!

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