From 98bf1454d216bca5bfd10c9e33ac520c38fa1be3 Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:29:39 +0900 Subject: [PATCH] fix(openai-chat): tolerate null tool-call padding --- src/adapters/openai-chat.ts | 9 +++++---- tests/openai-chat-hardening.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 383544fb40..94cddca397 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -975,10 +975,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } const rawToolCalls = delta.tool_calls; - if (rawToolCalls !== undefined) { - // A claimed tool-call payload is not benign padding. Dropping it can leave the + if (rawToolCalls !== undefined && rawToolCalls !== null) { + // A non-null claimed tool-call payload is not benign padding. Dropping it can leave the // matching result permanently orphaned, so malformed nested shapes fail closed - // through the adapter error channel instead of escaping as TypeError (#1325). + // through the adapter error channel instead of escaping as TypeError (#1325). Null is + // tolerated as absent because OpenAI-compatible providers may emit it as stream padding. if (!Array.isArray(rawToolCalls)) { return yield* terminateWithError(invalidToolCallsEvent(pendingUsage)); } @@ -1146,7 +1147,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content }); const rawToolCalls = msg.tool_calls; - if (rawToolCalls !== undefined) { + if (rawToolCalls !== undefined && rawToolCalls !== null) { if (!Array.isArray(rawToolCalls)) return [invalidToolCallsEvent(usage)]; for (const rawToolCall of rawToolCalls) { if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) { diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 95900b3e11..b78c3102b8 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -117,6 +117,19 @@ describe("openai-chat non-stream response hardening", () => { expect(events).toEqual([{ type: "error", message: "upstream response contained invalid choices" }]); }); + test("treats null tool calls as absent", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok", tool_calls: null } }], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + }))); + + expect(events).toEqual([ + { type: "text_delta", text: "ok" }, + { type: "done", usage: { inputTokens: 7, outputTokens: 2 } }, + ]); + }); + test("rejects malformed nested tool calls without throwing", async () => { const adapter = createOpenAIChatAdapter(provider()); for (const toolCalls of [ @@ -180,6 +193,21 @@ describe("openai-chat stream response hardening", () => { expect(events.some(event => event.type === "done")).toBe(false); }); + test("treats null streaming tool calls as padding", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + 'data: {"choices":[{"delta":{"content":"ok","tool_calls":null}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":2}}\n\n', + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([ + { type: "text_delta", text: "ok" }, + { type: "done", usage: { inputTokens: 7, outputTokens: 2 } }, + ]); + }); + test("malformed nested streaming tool calls are terminal errors", async () => { const adapter = createOpenAIChatAdapter(provider()); for (const toolCalls of [{ unexpected: true }, [null]]) {