diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index c5338d5eaa..f6ea921906 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1628,20 +1628,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // name or arguments value fails closed through the #1325 channel here rather // than escaping later as a TypeError from string handling at flush time. const rawFunction = (rawToolCall as { function?: unknown }).function; - if (rawFunction !== undefined && rawFunction !== null) { - if (!isRecord(rawFunction)) { - logInvalidToolCalls("stream", rawToolCalls); - return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage)); - } - const rawName = rawFunction.name; - const rawArguments = rawFunction.arguments; - // Some OpenAI-compatible streamers repeat already-sent fields as null on - // continuation deltas. Treat only null/undefined as absent; every other - // non-string value still fails closed before entering the accumulator. - if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) { - logInvalidToolCalls("stream", rawToolCalls); - return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage)); - } + if (rawFunction !== undefined && rawFunction !== null && !isRecord(rawFunction)) { + logInvalidToolCalls("stream", rawToolCalls); + return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage)); } if (isInvalidStreamStringField(tc.id)) { logInvalidToolCalls("stream", rawToolCalls); @@ -1660,14 +1649,34 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.openCall(call.key); } if (tc.id && !call.id) call.id = tc.id; - if (tc.function?.name && !call.name) call.name = tc.function.name; - if (tc.function?.arguments) { + // #1731 generalization: some gateways repeat the function envelope on + // continuation deltas with wrong JSON types (an object `name` observed from + // opencode.ai/zen) instead of null. Once the pending call carries its + // canonical name, a non-string repeat adds nothing — ignore it, and let only + // string fragments reach the accumulator. A non-string value while the call is + // still unnamed fails closed (#1531): it was the field's only chance to be + // established, and nothing else can name or parameterize the call. + const fnRecord = isRecord(rawFunction) ? rawFunction : undefined; + const hasCanonicalName = typeof call.name === "string" && call.name.trim().length > 0; + if (isInvalidStreamStringField(fnRecord?.name) && !hasCanonicalName) { + logInvalidToolCalls("stream", rawToolCalls); + return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage)); + } + if (isInvalidStreamStringField(fnRecord?.arguments) && !hasCanonicalName) { + logInvalidToolCalls("stream", rawToolCalls); + return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage)); + } + if (typeof tc.function?.name === "string" && !hasCanonicalName) call.name = tc.function.name; + // Only string fragments enter the accumulator: an accepted non-string repeat + // (padding, guarded above) must not concatenate "[object Object]" into args. + const argsDelta = typeof tc.function?.arguments === "string" ? tc.function.arguments : ""; + if (argsDelta) { const previousBytes = call.argsBytes; - const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength; + const nextBytes = previousBytes + budgetEncoder.encode(argsDelta).byteLength; const scope = { kind: "tool_args" as const, callId: call.key }; const reservation = budget.reserveTransient(nextBytes, scope); try { - call.args += tc.function.arguments; + call.args += argsDelta; reservation.commitRetained(); budget.releaseRetained(previousBytes, scope); call.argsBytes = nextBytes; diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index f7435e0a28..61abd93937 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -476,6 +476,111 @@ describe("openai-chat stream response hardening", () => { expect(lines).toContain('"callIndex":1'); expect(lines).not.toContain('"tool_call_function_name_invalid"'); }); + + test("non-string repeated name/arguments on a resolved call are padding, not terminal", async () => { + // opencode.ai/zen continuation deltas: the first chunk carries id+name; later repeats + // can arrive with wrong JSON types (object name) instead of the null #1731 describes. + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: "chatcmpl-tool-abc", + type: "function", + function: { name: "terminal", arguments: "" }, + }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: null, + type: null, + function: { name: { unexpected: true }, arguments: { no: "string" } }, + }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: null, + type: null, + function: { name: null, arguments: "{\"command\":\"ls\"}" }, + }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 7, completion_tokens: 3 } })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([ + { type: "tool_call_start", id: "chatcmpl-tool-abc", name: "terminal" }, + { type: "tool_call_delta", arguments: '{"command":"ls"}' }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 7, outputTokens: 3 } }, + ]); + }); + + test("non-string name on a call without its canonical value still fails closed", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: "chatcmpl-tool-abc", + type: "function", + function: { name: { unexpected: true }, arguments: "" }, + }] } }] })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (tool_call_function_name_invalid; callIndex=0; valueType=object)", + }]); + }); + + test("non-string arguments on a call that has accumulated nothing still fails closed", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: "chatcmpl-tool-abc", + type: "function", + function: { name: "terminal", arguments: { command: "ls" } }, + }] } }] })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (tool_call_function_arguments_invalid; callIndex=0; valueType=object)", + }]); + }); + + test("whitespace-only initial name followed by non-string delta fails closed", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: "chatcmpl-tool-abc", + type: "function", + function: { name: " ", arguments: "" }, + }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: null, + type: null, + function: { name: { unexpected: true }, arguments: { no: "string" } }, + }] } }] })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (tool_call_function_name_invalid; callIndex=0; valueType=object)", + }]); + }); }); describe("openai-chat credential hardening", () => {