fix(api): serialize tool-call args correctly through /anthropic translation (#6459) (#6609)

appendToolCallArgumentDelta() treated any non-string incoming fragment as
empty, silently dropping tool-call arguments delivered as an already-parsed
JSON object/array (a non-conformant shape some upstreams emit for
tool_calls[].function.arguments) instead of JSON-encoding them. This left
tool_use.input empty on the /anthropic streaming path and opened the door to
downstream [object Object] string coercion once buffers were concatenated.
Now JSON.stringify()s the non-string fragment instead of discarding it.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-07 21:48:27 -03:00
committed by GitHub
parent 978e92e104
commit f4cd3e8c80
3 changed files with 185 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
### 🐛 Bug Fixes
- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127)
- **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127)
- **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`.
- **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `<select>` to enable Send, asserts `sendBtn.disabled === false` before clicking, and asserts the streamed SSE delta (`"Hello!"`) actually reached the response editor. Root cause on the detector side: `check-test-masking.mjs`'s tautology subcheck only compares base-vs-HEAD counts within a PR's own diff (`headExtTaut > baseExtTaut`) and no-ops locally when `GITHUB_BASE_SHA`/`GITHUB_BASE_REF` are unset ("sem base ref — pulando") — so a tautology merged once, or checked with a bare local run, was invisible forever after. Added a new always-on, PR-independent absolute-floor scan (`scanBareTautologies` + `countBareTautologies`) over every git-tracked test file for the bare `expect(true).toBe(true)` / `assert.equal(1,1)` / `assert.strictEqual(1,1)` patterns specifically (deliberately excluding `assert.ok(true)`, which has ~15 pre-existing verified-legitimate try/catch-fallback uses repo-wide and stays governed by the lenient diff-only subcheck) — verified zero pre-existing hits repo-wide once this file was fixed, so the new floor is safe to enforce unconditionally. Regression guard: `tests/unit/check-test-masking.test.ts` (new `scanBareTautologies`/`countBareTautologies` cases) + `tests/unit/ui/playground-api-tab.test.tsx`. (thanks @chirag127)

View File

@@ -15,10 +15,32 @@
* A fuzzy suffix/prefix-overlap heuristic must NOT be used here: it silently
* drops bytes from legitimate incremental deltas (turning `ll` into `l`, `xx`
* into `x`), which trades a visible duplication bug for a silent truncation bug.
*
* A third, non-conformant shape some upstreams emit (#6459): the FULL
* `arguments` value delivered as an already-parsed JSON object/array instead
* of a JSON-encoded string (violates the OpenAI streaming contract, but seen
* from some Anthropic-shape-passthrough backends). Treating that as "not a
* string" and silently discarding it left `tool_use.input` empty upstream —
* or, when a caller re-serialized the buffer with plain string coercion
* instead of JSON, rendered literally as `[object Object]` in the client
* transcript. JSON.stringify it into a proper fragment instead of dropping it.
*/
function normalizeIncomingFragment(incoming: unknown): string {
if (typeof incoming === "string") return incoming;
if (incoming == null) return "";
if (typeof incoming === "object") {
try {
return JSON.stringify(incoming);
} catch {
return "";
}
}
return "";
}
export function appendToolCallArgumentDelta(current: unknown, incoming: unknown): string {
const existing = typeof current === "string" ? current : "";
const next = typeof incoming === "string" ? incoming : "";
const next = normalizeIncomingFragment(incoming);
if (!existing) return next;
if (!next) return existing;

View File

@@ -0,0 +1,161 @@
/**
* Regression test for #6459: tool-call arguments render as
* `[object Object][object Object]` in the user-visible transcript when the
* upstream provider delivers the FULL `tool_calls[].function.arguments` value
* as an already-parsed JSON object (not a JSON-encoded string), which is what
* some Anthropic-shape-compatible backends do instead of following the OpenAI
* streaming contract.
*
* Before the fix, `appendToolCallArgumentDelta()` treated any non-string
* `incoming` fragment as an empty string, so the accumulated `argBuffer`
* never picked up the object at all — `openaiToClaudeResponse()` (the
* translator that builds the live /anthropic SSE stream, see
* `open-sse/translator/response/openai-to-claude.ts`) then emitted no
* `input_json_delta` for that chunk, and the client is left to coerce
* whatever partial data it has via string concatenation/`String(object)`,
* which is exactly how `[object Object]` sequences end up in the transcript.
*
* The fix: JSON.stringify() a non-string, non-null object/array fragment
* instead of discarding it, so the assembled `partial_json` is always valid
* JSON that parses back into the original structured value.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts";
function createState() {
return { toolCalls: new Map() };
}
function flatten(items: unknown[][]) {
return items.flatMap((item) => item || []);
}
function assembleToolUseInput(events: Array<Record<string, unknown>>) {
const jsonDeltas = events.filter(
(e) => e?.type === "content_block_delta" && (e.delta as Record<string, unknown>)?.type === "input_json_delta"
);
const assembled = jsonDeltas
.map((e) => (e.delta as Record<string, unknown>).partial_json as string)
.join("");
return assembled;
}
test("#6459: tool-call arguments delivered as a structured object (not a JSON string) render as the real object, not [object Object]", () => {
const state = createState();
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-6459",
model: "auto/claude-opus",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_6459",
type: "function",
function: { name: "AskUserQuestion", arguments: "" },
},
],
},
finish_reason: null,
},
],
},
state
);
// Non-conformant upstream: the FULL arguments value arrives as an already-
// parsed JS object (mirroring a nested tool_use.input structure), not a
// JSON-encoded string fragment.
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-6459",
model: "auto/claude-opus",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: {
questions: [
{ header: "Deploy target", options: [{ label: "staging" }] },
{ header: "Confirm rollback", options: [{ label: "yes" }, { label: "no" }] },
],
},
},
},
],
},
finish_reason: "tool_calls",
},
],
},
state
);
const events = flatten([chunk1, chunk2]) as Array<Record<string, unknown>>;
const assembled = assembleToolUseInput(events);
assert.ok(assembled.length > 0, "expected at least one input_json_delta with the tool args");
assert.ok(
!assembled.includes("[object Object]"),
`assembled partial_json leaked a stringified-object coercion: ${assembled}`
);
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(assembled);
} catch {
assert.fail(`assembled partial_json is not valid JSON — arguments object was corrupted: ${assembled}`);
}
assert.ok(Array.isArray(parsed.questions), "questions array must survive as structured data");
assert.equal(parsed.questions.length, 2);
assert.equal((parsed.questions[0] as Record<string, unknown>).header, "Deploy target");
assert.equal((parsed.questions[1] as Record<string, unknown>).header, "Confirm rollback");
});
test("#6459 no-regression: a plain text-only turn still translates normally", () => {
const state = createState();
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-6459-text",
model: "auto/claude-opus",
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
},
state
);
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-6459-text",
model: "auto/claude-opus",
choices: [{ index: 0, delta: { content: "Hello, world!" }, finish_reason: null }],
},
state
);
const chunk3 = openaiToClaudeResponse(
{
id: "chatcmpl-6459-text",
model: "auto/claude-opus",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
state
);
const events = flatten([chunk1, chunk2, chunk3]) as Array<Record<string, unknown>>;
const textDeltas = events.filter(
(e) => e?.type === "content_block_delta" && (e.delta as Record<string, unknown>)?.type === "text_delta"
);
assert.equal(textDeltas.length, 1);
assert.equal((textDeltas[0].delta as Record<string, unknown>).text, "Hello, world!");
});