Skip to content
Closed
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
45 changes: 27 additions & 18 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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;
Expand Down
105 changes: 105 additions & 0 deletions tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading