mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
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:
parent
f4fb0d310b
commit
bf366f3da5
3 changed files with 177 additions and 24 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
120
tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts
Normal file
120
tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts
Normal 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"}');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue