Repository navigation
fix(translator): prevent doubled tool args in OpenAI-to-Claude response - #2279
vishalrajv wants to merge 3 commits into
Conversation
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)
|
Heads-up: this guard regresses the multi-format pivot paths. The Minimal repro — 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 |
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.
|
Addressed the regression flagged in the review. Fix: Replaced the shared
All translator tests pass (golden-response-stream, format-roundtrip, bugs-antigravity, bugs-openai-bridge). |
…parameters-claude-code
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
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
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): #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>
… 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>
|
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! |
Problem
When using OpenAI-compatible models (e.g. mimo) with Claude Code clients, tool calls frequently fail with
InputValidationErrorbecause 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_reasonset. The translator's finish block had no guard against this, so it ran twice — emitting tool arguments as two separateinput_json_deltaevents. Claude Code concatenated both halves, producing invalid doubled JSON.Changes
openai-to-claude.js: Add!state.finishReasonguard to prevent double finish emissionopenai-to-claude.js: AdddeduplicateDoubledJson()as defense-in-depth insanitizeToolArgsclaude-to-openai.js: Guard string tool input on request side to prevent double-encodingopenai-to-claude.test.js: Fix test state (add missingnextBlockIndex,finish_reason)Testing