Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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)) {
Expand Down
28 changes: 28 additions & 0 deletions tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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]]) {
Expand Down
Loading