fix(translator): defer content_block_start until GLM streams the tool name (port from 9router#2077)

GLM 5.2 (and similar OpenAI-compatible upstreams) stream a tool call's id and
function.name across separate SSE delta chunks. The openai-to-claude streaming
translator emitted content_block_start immediately on the id-only chunk with an empty
name; the Claude SSE protocol cannot patch a block after emission, so the later
name-only chunk was dropped and Claude Code rejected the tool_use with an empty tool
name / "No such tool available:". Defer content_block_start until the name arrives
(start on args if they arrive first), and emit a start for any orphaned id-only tool
call at finish so content_block_stop is never orphaned.

Reported-by: itiwant (https://github.com/decolua/9router/issues/2077)
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-07-09 05:09:48 -03:00
parent f4fb0d310b
commit bf366f3da5
3 changed files with 177 additions and 24 deletions

View file

@ -14,6 +14,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
### 🐛 Bug Fixes
- **fix(translator):** GLM 5.2 (and other OpenAI-compatible upstreams that stream a tool call's `id` and `function.name` in **separate** SSE chunks) no longer produce an empty tool name / `No such tool available:` error through the Claude `/messages` path — the `openai-to-claude` streaming translator emitted `content_block_start` immediately on the id-only chunk with an empty `name`, and the Claude SSE protocol cannot patch a block after it is emitted, so the later name-only chunk was silently dropped. It now **defers** `content_block_start` until the tool name arrives (falling back to starting the block when arguments arrive first), so the emitted `tool_use` always carries the real name. Regression guard: `tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts`. (thanks @itiwant)
- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer <apiKey>` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy).
- **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur)
- **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`.

View file

@ -154,42 +154,62 @@ export function openaiToClaudeResponse(chunk, state) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (tc.id) {
// Strip the Claude OAuth prefix from an incoming tool name (if any).
const incomingName = (() => {
let n = tc.function?.name || "";
if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
return n;
})();
// A tool call is identified by its id. Some OpenAI-compatible upstreams
// (GLM 5.2) stream the id and function.name in SEPARATE SSE chunks. The
// Claude protocol cannot patch a content_block_start after it is emitted,
// so we register the tool call on the id chunk but DEFER content_block_start
// until the name arrives (#2077 / decolua/9router#2077).
if (tc.id && !state.toolCalls.has(idx)) {
stopThinkingBlock(state, results);
stopTextBlock(state, results);
const toolBlockIndex = state.nextBlockIndex++;
// Strip prefix from tool name for response
let toolName = tc.function?.name || "";
if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
}
state.toolCalls.set(idx, {
id: tc.id,
name: toolName,
blockIndex: toolBlockIndex,
name: incomingName,
blockIndex: state.nextBlockIndex++,
// Shimmed tools buffer their raw args and emit a single corrected
// input_json_delta at content_block_stop time (see finish handler).
shimmed: hasToolCallShim(toolName),
shimmed: incomingName ? hasToolCallShim(incomingName) : false,
argBuffer: "",
});
results.push({
type: "content_block_start",
index: toolBlockIndex,
content_block: {
type: "tool_use",
id: tc.id,
name: toolName,
input: {},
},
startEmitted: false,
});
}
const toolInfo = state.toolCalls.get(idx);
if (toolInfo) {
// Capture a late-arriving id or name (streamed after the initial chunk).
if (tc.id && !toolInfo.id) toolInfo.id = tc.id;
if (incomingName && !toolInfo.startEmitted && !toolInfo.name) {
toolInfo.name = incomingName;
toolInfo.shimmed = hasToolCallShim(incomingName);
}
// Emit content_block_start once we have a name. If arguments arrive before
// any name was ever seen, start the block anyway with the (empty) name so
// the input_json_delta stays well-formed.
if (!toolInfo.startEmitted && (toolInfo.name || tc.function?.arguments != null)) {
toolInfo.startEmitted = true;
results.push({
type: "content_block_start",
index: toolInfo.blockIndex,
content_block: {
type: "tool_use",
id: toolInfo.id,
name: toolInfo.name || "",
input: {},
},
});
}
}
if (tc.function?.arguments) {
const toolInfo = state.toolCalls.get(idx);
if (toolInfo) {
// Always buffer the raw stream so shimmed tools can re-emit a
// corrected JSON at stop time.
@ -238,6 +258,18 @@ export function openaiToClaudeResponse(chunk, state) {
stopTextBlock(state, results);
for (const [, toolInfo] of state.toolCalls) {
// A tool call whose name/args never arrived (only an id chunk was seen)
// still has a reserved block index but no content_block_start. Emit it now
// so the terminal content_block_stop is not orphaned (#2077 edge case).
if (!toolInfo.startEmitted) {
toolInfo.startEmitted = true;
results.push({
type: "content_block_start",
index: toolInfo.blockIndex,
content_block: { type: "tool_use", id: toolInfo.id, name: toolInfo.name || "", input: {} },
});
}
// For shimmed tools, emit one corrective input_json_delta with the
// fully patched JSON before closing the block.
if (toolInfo.shimmed) {

View file

@ -0,0 +1,120 @@
import test from "node:test";
import assert from "node:assert/strict";
import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts";
// Regression guard for GLM 5.2 (and similar OpenAI-compatible upstreams) that stream a
// tool call's `id` and `function.name` across SEPARATE SSE delta chunks. The Claude SSE
// protocol cannot patch a `content_block_start` after it is emitted, so the translator
// must DEFER `content_block_start` until the tool name has arrived. Previously the block
// was emitted immediately on the id-only chunk with an empty name, and the later
// name-only chunk was silently dropped — Claude Code then rejected the tool_use with
// "No such tool available:" / empty tool name. Ported from decolua/9router#2077.
function createState() {
return { toolCalls: new Map() };
}
function flatten(items) {
return items.flatMap((item) => item || []);
}
test("#2077: GLM streams tool id then name in separate chunks — content_block_start carries the real name", () => {
const state = createState();
// Chunk 1: id only, no function.name yet (GLM 5.2 behavior).
const c1 = openaiToClaudeResponse(
{
id: "chatcmpl-glm",
model: "glm/glm-5.2",
choices: [
{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_glm_1", type: "function" }] }, finish_reason: null },
],
},
state
);
// Chunk 2: function.name only, no id, no arguments.
const c2 = openaiToClaudeResponse(
{
id: "chatcmpl-glm",
model: "glm/glm-5.2",
choices: [
{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: "get_weather" } }] }, finish_reason: null },
],
},
state
);
// Chunk 3: arguments.
const c3 = openaiToClaudeResponse(
{
id: "chatcmpl-glm",
model: "glm/glm-5.2",
choices: [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: '{"city":"SP"}' } }] },
finish_reason: null,
},
],
},
state
);
const cEnd = openaiToClaudeResponse(
{
id: "chatcmpl-glm",
model: "glm/glm-5.2",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
state
);
const events = flatten([c1, c2, c3, cEnd]);
const starts = events.filter((e) => e?.type === "content_block_start" && e.content_block?.type === "tool_use");
assert.equal(starts.length, 1, "exactly one tool_use content_block_start");
assert.equal(starts[0].content_block.name, "get_weather", "tool name must be captured (not empty)");
assert.equal(starts[0].content_block.id, "call_glm_1", "tool id preserved");
// Arguments must still be delivered (the name-only chunk must not swallow them).
const argDeltas = events
.filter((e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta")
.map((e) => e.delta.partial_json)
.join("");
assert.equal(argDeltas, '{"city":"SP"}');
});
test("#2077 no-regression: id+name+arguments in one chunk still emits a single named start", () => {
const state = createState();
const c1 = openaiToClaudeResponse(
{
id: "chatcmpl-x",
model: "openai/gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{ index: 0, id: "call_1", type: "function", function: { name: "search", arguments: '{"q":"hi"}' } },
],
},
finish_reason: null,
},
],
},
state
);
const cEnd = openaiToClaudeResponse(
{ id: "chatcmpl-x", model: "openai/gpt-4", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
state
);
const events = flatten([c1, cEnd]);
const starts = events.filter((e) => e?.type === "content_block_start" && e.content_block?.type === "tool_use");
assert.equal(starts.length, 1);
assert.equal(starts[0].content_block.name, "search");
assert.equal(starts[0].content_block.id, "call_1");
const args = events
.filter((e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta")
.map((e) => e.delta.partial_json)
.join("");
assert.equal(args, '{"q":"hi"}');
});