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
119 changes: 106 additions & 13 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,20 @@ function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "e
};
}

function invalidToolCallsEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
function invalidToolCallsEvent(
rawToolCalls: unknown,
mode: "stream" | "response",
usage?: OcxUsage,
): Extract<AdapterEvent, { type: "error" }> {
const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
const detail = diagnostic
? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
: "";
return {
type: "error",
message: "upstream response contained invalid tool calls",
status: 502,
errorType: "upstream_error",
message: `upstream response contained invalid tool calls${detail}`,
...(usage !== undefined ? { usage } : {}),
};
}
Expand Down Expand Up @@ -333,6 +343,83 @@ type InvalidToolCallReason =
| "tool_call_function_name_blank"
| "tool_call_function_arguments_invalid";

type InvalidToolCallDiagnostic = {
reason: InvalidToolCallReason;
callIndex?: number;
valueType: string;
};

type InvalidFieldShape =
| {
kind: "object";
knownKeys: string[];
knownFieldTypes: Record<string, string>;
hasUnknownKeys: boolean;
}
| {
kind: "array";
length: number;
};

const SAFE_TOOL_CALL_SHAPE_KEYS = [
"name",
"type",
"value",
"function",
"arguments",
"id",
"index",
] as const;
const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set<string>(SAFE_TOOL_CALL_SHAPE_KEYS);

function structuralValueType(value: unknown): string {
return value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
}

function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown {
if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls;
if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined;

const rawToolCall = rawToolCalls[diagnostic.callIndex];
if (diagnostic.reason === "tool_call_not_object") return rawToolCall;
if (!isRecord(rawToolCall)) return undefined;
if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function;

const rawFunction = rawToolCall.function;
switch (diagnostic.reason) {
case "tool_call_id_invalid":
return rawToolCall.id;
case "tool_call_function_name_invalid":
return isRecord(rawFunction) ? rawFunction.name : undefined;
case "tool_call_function_arguments_invalid":
return isRecord(rawFunction) ? rawFunction.arguments : undefined;
default:
return undefined;
}
}

function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined {
if (Array.isArray(value)) return { kind: "array", length: value.length };
if (!isRecord(value)) return undefined;

const knownKeys: string[] = [];
const knownFieldTypes: Record<string, string> = {};
for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) {
if (!Object.hasOwn(value, key)) continue;
knownKeys.push(key);
knownFieldTypes[key] = structuralValueType(value[key]);
}

let hasUnknownKeys = false;
for (const key of Object.keys(value)) {
if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) {
hasUnknownKeys = true;
break;
}
}
return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys };
}

/**
* Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
* streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
Expand All @@ -350,7 +437,7 @@ function isInvalidStreamStringField(value: unknown): boolean {
function diagnoseInvalidToolCalls(
rawToolCalls: unknown,
mode: "stream" | "response",
): { reason: InvalidToolCallReason; callIndex?: number; valueType: string } | undefined {
): InvalidToolCallDiagnostic | undefined {
if (!Array.isArray(rawToolCalls)) {
return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls };
}
Expand Down Expand Up @@ -426,8 +513,15 @@ function diagnoseInvalidToolCalls(
}

function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void {
if (!isDebugEnabled()) return;
const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic });
if (!diagnostic) return;
const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
mode,
...diagnostic,
...(fieldShape ? { fieldShape } : {}),
});
}

function developerSystemText(message: OcxMessage): string | undefined {
Expand Down Expand Up @@ -1184,7 +1278,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd

buildRequest(parsed: OcxParsedRequest) {
const { url, headers, hasCredential } = openAIChatTransport(provider);

const messages = messagesToChatFormat(parsed, provider);
const tools = toolsToChatFormatForProvider(parsed, provider);
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
Expand Down Expand Up @@ -1479,12 +1572,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
// tolerated as absent because OpenAI-compatible providers may emit it as stream padding.
if (!Array.isArray(rawToolCalls)) {
logInvalidToolCalls("stream", rawToolCalls);
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
}
for (const rawToolCall of rawToolCalls) {
if (!isRecord(rawToolCall)) {
logInvalidToolCalls("stream", rawToolCalls);
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
}
const tc = rawToolCall as {
index?: number;
Expand All @@ -1499,7 +1592,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
if (rawFunction !== undefined && rawFunction !== null) {
if (!isRecord(rawFunction)) {
logInvalidToolCalls("stream", rawToolCalls);
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
}
const rawName = rawFunction.name;
const rawArguments = rawFunction.arguments;
Expand All @@ -1508,12 +1601,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
// non-string value still fails closed before entering the accumulator.
if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) {
logInvalidToolCalls("stream", rawToolCalls);
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
}
}
if (isInvalidStreamStringField(tc.id)) {
logInvalidToolCalls("stream", rawToolCalls);
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
}
const key = typeof tc.index === "number"
? `i:${tc.index}`
Expand Down Expand Up @@ -1675,12 +1768,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
if (rawToolCalls !== undefined && rawToolCalls !== null) {
if (!Array.isArray(rawToolCalls)) {
logInvalidToolCalls("response", rawToolCalls);
return [invalidToolCallsEvent(usage)];
return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
}
for (const rawToolCall of rawToolCalls) {
if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
logInvalidToolCalls("response", rawToolCalls);
return [invalidToolCallsEvent(usage)];
return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
}
const id = rawToolCall.id;
const name = rawToolCall.function.name;
Expand All @@ -1691,7 +1784,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string"
|| name.trim().length === 0) {
logInvalidToolCalls("response", rawToolCalls);
return [invalidToolCallsEvent(usage)];
return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
}
events.push({ type: "tool_call_start", id, name });
events.push({ type: "tool_call_delta", arguments: args });
Expand Down
44 changes: 33 additions & 11 deletions tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,18 +244,20 @@ describe("openai-chat non-stream response hardening", () => {

test("rejects malformed nested tool calls without throwing", async () => {
const adapter = createOpenAIChatAdapter(provider());
for (const toolCalls of [
{ unexpected: true },
[null],
[{ id: "call_missing_function" }],
]) {
for (const [toolCalls, message] of [
[{ unexpected: true }, "upstream response contained invalid tool calls (tool_calls_not_array; valueType=object)"],
[[null], "upstream response contained invalid tool calls (tool_call_not_object; callIndex=0; valueType=null)"],
[[{ id: "call_missing_function" }], "upstream response contained invalid tool calls (tool_call_function_not_object; callIndex=0; valueType=undefined)"],
] as const) {
const events = await adapter.parseResponse!(new Response(JSON.stringify({
choices: [{ message: { role: "assistant", tool_calls: toolCalls } }],
usage: { prompt_tokens: 7, completion_tokens: 2 },
})));
expect(events).toEqual([{
type: "error",
message: "upstream response contained invalid tool calls",
status: 502,
errorType: "upstream_error",
message,
usage: { inputTokens: 7, outputTokens: 2 },
}]);
}
Expand All @@ -272,7 +274,12 @@ describe("openai-chat non-stream response hardening", () => {
}] } }],
})));

expect(events).toEqual([{ type: "error", message: "upstream response contained invalid tool calls" }]);
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)",
}]);
const lines = getDebugLogEntries().map(entry => entry.line).join("\n");
expect(lines).toContain("[ocx:openai-chat:invalid-tool-calls]");
expect(lines).toContain('"mode":"response"');
Expand Down Expand Up @@ -393,7 +400,10 @@ describe("openai-chat stream response hardening", () => {

test("malformed nested streaming tool calls are terminal errors", async () => {
const adapter = createOpenAIChatAdapter(provider());
for (const toolCalls of [{ unexpected: true }, [null]]) {
for (const [toolCalls, message] of [
[{ unexpected: true }, "upstream response contained invalid tool calls (tool_calls_not_array; valueType=object)"],
[[null], "upstream response contained invalid tool calls (tool_call_not_object; callIndex=0; valueType=null)"],
] as const) {
const response = new Response([
`data: ${JSON.stringify({
choices: [{ delta: { tool_calls: toolCalls } }],
Expand All @@ -405,7 +415,9 @@ describe("openai-chat stream response hardening", () => {
const events = await collect(adapter.parseStream(response));
expect(events).toEqual([{
type: "error",
message: "upstream response contained invalid tool calls",
status: 502,
errorType: "upstream_error",
message,
usage: { inputTokens: 7, outputTokens: 2 },
}]);
}
Expand All @@ -424,7 +436,12 @@ describe("openai-chat stream response hardening", () => {
].join(""));

const events = await collect(adapter.parseStream(response));
expect(events).toEqual([{ type: "error", message: "upstream response contained invalid tool calls" }]);
expect(events).toEqual([{
type: "error",
status: 502,
errorType: "upstream_error",
message: "upstream response contained invalid tool calls (tool_call_not_object; callIndex=1; valueType=null)",
}]);
const lines = getDebugLogEntries().map(entry => entry.line).join("\n");
expect(lines).toContain('"mode":"stream"');
expect(lines).toContain('"reason":"tool_call_not_object"');
Expand All @@ -446,7 +463,12 @@ describe("openai-chat stream response hardening", () => {
].join(""));

const events = await collect(adapter.parseStream(response));
expect(events).toEqual([{ type: "error", message: "upstream response contained invalid tool calls" }]);
expect(events).toEqual([{
type: "error",
status: 502,
errorType: "upstream_error",
message: "upstream response contained invalid tool calls (tool_call_not_object; callIndex=1; valueType=null)",
}]);
const lines = getDebugLogEntries().map(entry => entry.line).join("\n");
// The null-padded continuation delta at index 0 is accepted by the accumulator, so the
// diagnostic must point at index 1 rather than claiming the padding was the defect.
Expand Down
Loading
Loading