From 554da088612c0d26314e90cee62760646b0f2e7f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:12:13 +0200 Subject: [PATCH 01/14] test(openai-chat): pin malformed tool-call diagnostics --- ...chat-invalid-tool-call-diagnostics.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/openai-chat-invalid-tool-call-diagnostics.test.ts diff --git a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts new file mode 100644 index 0000000000..dcff2b98c2 --- /dev/null +++ b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createOpenAIChatAdapter = (...args: Parameters) => + withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); + +const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", +}; + +async function collect(stream: AsyncGenerator): Promise { + const events: AdapterEvent[] = []; + for await (const event of stream) events.push(event); + return events; +} + +test("object-valued streamed function.name is a 502 with value-free compatibility detail", async () => { + const privateNameValue = "must-not-reach-diagnostics"; + const privateArguments = "must-not-reach-diagnostics-either"; + const adapter = createOpenAIChatAdapter(provider); + const response = new Response( + `data: ${JSON.stringify({ + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: 0, + id: "call_1", + function: { + name: { privateNameValue }, + arguments: JSON.stringify({ value: privateArguments }), + }, + }], + }, + }], + })}\n\n`, + ); + + 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)", + }]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain(privateNameValue); + expect(serialized).not.toContain(privateArguments); + expect(serialized).not.toContain("call_1"); +}); From c8d136c21e67a77e537db4693f3f6529a68d1490 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:14:28 +0200 Subject: [PATCH 02/14] chore(ci): apply openai-chat diagnostic patch --- .../ocx-temp-apply-invalid-tool-call-fix.yml | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml diff --git a/.github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml b/.github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml new file mode 100644 index 0000000000..c3b4e1dba0 --- /dev/null +++ b/.github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml @@ -0,0 +1,107 @@ +name: temporary apply invalid tool-call fix + +on: + push: + branches: + - fix/deepseek-invalid-tool-call-diagnostics + +permissions: + contents: write + +jobs: + apply: + if: github.event.head_commit.message != 'fix(openai-chat): classify malformed upstream tool calls' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Apply focused source patch + shell: python + run: | + from pathlib import Path + import re + + path = Path('src/adapters/openai-chat.ts') + text = path.read_text() + + old = '''function invalidToolCallsEvent(usage?: OcxUsage): Extract { + return { + type: "error", + message: "upstream response contained invalid tool calls", + ...(usage !== undefined ? { usage } : {}), + }; + }''' + new = '''function invalidToolCallsEvent( + usage?: OcxUsage, + diagnostic?: { reason: string; callIndex?: number; valueType: string }, + ): Extract { + const detail = diagnostic + ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})` + : ""; + return { + type: "error", + status: 502, + errorType: "upstream_error", + message: `upstream response contained invalid tool calls${detail}`, + ...(usage !== undefined ? { usage } : {}), + }; + }''' + if old not in text: + raise SystemExit('invalidToolCallsEvent source shape changed') + text = text.replace(old, new, 1) + + old = '''function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void { + const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode); + if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic }); + }''' + new = '''function logInvalidToolCalls( + mode: "stream" | "response", + rawToolCalls: unknown, + ): ReturnType { + const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode); + if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic }); + return diagnostic; + }''' + if old not in text: + raise SystemExit('logInvalidToolCalls source shape changed') + text = text.replace(old, new, 1) + + stream_pattern = re.compile( + r'(?P[ \t]*)logInvalidToolCalls\("stream", rawToolCalls\);\n' + r'(?P=indent)return yield\* terminateWithError\(invalidToolCallsEvent\(pendingUsage\)\);' + ) + text, stream_count = stream_pattern.subn( + lambda m: f'{m.group("indent")}return yield* terminateWithError(\n' + f'{m.group("indent")} invalidToolCallsEvent(pendingUsage, logInvalidToolCalls("stream", rawToolCalls)),\n' + f'{m.group("indent")});', + text, + ) + if stream_count < 1: + raise SystemExit('no streamed invalid-tool-call sites patched') + + response_pattern = re.compile( + r'(?P[ \t]*)logInvalidToolCalls\("response", rawToolCalls\);\n' + r'(?P=indent)return \[invalidToolCallsEvent\(usage\)\];' + ) + text, response_count = response_pattern.subn( + lambda m: f'{m.group("indent")}return [invalidToolCallsEvent(\n' + f'{m.group("indent")} usage,\n' + f'{m.group("indent")} logInvalidToolCalls("response", rawToolCalls),\n' + f'{m.group("indent")})];', + text, + ) + if response_count < 1: + raise SystemExit('no buffered invalid-tool-call sites patched') + + path.write_text(text) + print(f'patched stream sites={stream_count}, response sites={response_count}') + - name: Remove temporary workflow from branch + run: rm .github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml + - name: Commit patch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/adapters/openai-chat.ts .github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml + git commit -m "fix(openai-chat): classify malformed upstream tool calls" + git push origin HEAD:fix/deepseek-invalid-tool-call-diagnostics From 2e28fb2ccad208d3fb136f058a601884d7980568 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:14:41 +0200 Subject: [PATCH 03/14] chore(ci): remove temporary patch workflow --- .../ocx-temp-apply-invalid-tool-call-fix.yml | 107 ------------------ 1 file changed, 107 deletions(-) delete mode 100644 .github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml diff --git a/.github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml b/.github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml deleted file mode 100644 index c3b4e1dba0..0000000000 --- a/.github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: temporary apply invalid tool-call fix - -on: - push: - branches: - - fix/deepseek-invalid-tool-call-diagnostics - -permissions: - contents: write - -jobs: - apply: - if: github.event.head_commit.message != 'fix(openai-chat): classify malformed upstream tool calls' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Apply focused source patch - shell: python - run: | - from pathlib import Path - import re - - path = Path('src/adapters/openai-chat.ts') - text = path.read_text() - - old = '''function invalidToolCallsEvent(usage?: OcxUsage): Extract { - return { - type: "error", - message: "upstream response contained invalid tool calls", - ...(usage !== undefined ? { usage } : {}), - }; - }''' - new = '''function invalidToolCallsEvent( - usage?: OcxUsage, - diagnostic?: { reason: string; callIndex?: number; valueType: string }, - ): Extract { - const detail = diagnostic - ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})` - : ""; - return { - type: "error", - status: 502, - errorType: "upstream_error", - message: `upstream response contained invalid tool calls${detail}`, - ...(usage !== undefined ? { usage } : {}), - }; - }''' - if old not in text: - raise SystemExit('invalidToolCallsEvent source shape changed') - text = text.replace(old, new, 1) - - old = '''function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void { - const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode); - if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic }); - }''' - new = '''function logInvalidToolCalls( - mode: "stream" | "response", - rawToolCalls: unknown, - ): ReturnType { - const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode); - if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic }); - return diagnostic; - }''' - if old not in text: - raise SystemExit('logInvalidToolCalls source shape changed') - text = text.replace(old, new, 1) - - stream_pattern = re.compile( - r'(?P[ \t]*)logInvalidToolCalls\("stream", rawToolCalls\);\n' - r'(?P=indent)return yield\* terminateWithError\(invalidToolCallsEvent\(pendingUsage\)\);' - ) - text, stream_count = stream_pattern.subn( - lambda m: f'{m.group("indent")}return yield* terminateWithError(\n' - f'{m.group("indent")} invalidToolCallsEvent(pendingUsage, logInvalidToolCalls("stream", rawToolCalls)),\n' - f'{m.group("indent")});', - text, - ) - if stream_count < 1: - raise SystemExit('no streamed invalid-tool-call sites patched') - - response_pattern = re.compile( - r'(?P[ \t]*)logInvalidToolCalls\("response", rawToolCalls\);\n' - r'(?P=indent)return \[invalidToolCallsEvent\(usage\)\];' - ) - text, response_count = response_pattern.subn( - lambda m: f'{m.group("indent")}return [invalidToolCallsEvent(\n' - f'{m.group("indent")} usage,\n' - f'{m.group("indent")} logInvalidToolCalls("response", rawToolCalls),\n' - f'{m.group("indent")})];', - text, - ) - if response_count < 1: - raise SystemExit('no buffered invalid-tool-call sites patched') - - path.write_text(text) - print(f'patched stream sites={stream_count}, response sites={response_count}') - - name: Remove temporary workflow from branch - run: rm .github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml - - name: Commit patch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/adapters/openai-chat.ts .github/workflows/ocx-temp-apply-invalid-tool-call-fix.yml - git commit -m "fix(openai-chat): classify malformed upstream tool calls" - git push origin HEAD:fix/deepseek-invalid-tool-call-diagnostics From 569eb3e34502b1cd72e811f825b7bcd80e4429ed Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:45:13 +0200 Subject: [PATCH 04/14] fix(openai-chat): classify malformed tool calls as upstream failures --- src/adapters/openai-chat.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 74c9042219..41a0747055 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -289,10 +289,20 @@ function invalidChoicesEvent(usage?: OcxUsage): Extract { +function invalidToolCallsEvent( + rawToolCalls: unknown, + mode: "stream" | "response", + usage?: OcxUsage, +): Extract { + 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 } : {}), }; } @@ -1479,12 +1489,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; @@ -1499,7 +1509,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; @@ -1508,12 +1518,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}` @@ -1675,12 +1685,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; @@ -1691,7 +1701,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 }); From ce0e6e182e1a60764fb328846ce56d84176255a6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:12:13 +0200 Subject: [PATCH 05/14] test(openai-chat): update malformed tool-call expectations --- tests/openai-chat-hardening.test.ts | 46 +++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index e8675c7983..5013fc4849 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -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 }, }]); } @@ -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"'); @@ -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 } }], @@ -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 }, }]); } @@ -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"'); @@ -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. @@ -766,4 +788,4 @@ describe("openai-chat response_format emission", () => { json_schema: { name: "answer", schema: { type: "object" }, strict: true }, }); }); -}); +}); \ No newline at end of file From 1fc7074d76eb35759daf4ce24de184d4cbccc690 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:13:36 +0200 Subject: [PATCH 06/14] style(openai-chat): restore test file newline --- tests/openai-chat-hardening.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 5013fc4849..ab731ab35b 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -788,4 +788,4 @@ describe("openai-chat response_format emission", () => { json_schema: { name: "answer", schema: { type: "object" }, strict: true }, }); }); -}); \ No newline at end of file +}); From a0b810833a8c34ba836ef259d875ecfa86baa8d9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:14:44 +0200 Subject: [PATCH 07/14] test(openai-chat): keep malformed tool-call expectations explicit --- tests/openai-chat-hardening.test.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index ab731ab35b..5140e323ed 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -80,12 +80,12 @@ describe("openai-chat request hardening", () => { }, }, patternProperties: { encrypted: { type: "string", encrypted: true } }, - $defs: { encrypted: { type: "number", encrypted: true } }, - definitions: { encrypted: { type: "integer", encrypted: false } }, - dependencies: { encrypted: ["message"], other: { type: "object", encrypted: true } }, - dependentSchemas: { encrypted: { type: "string", encrypted: true } }, + $defs: { encrypted: { type: "number" } }, + definitions: { encrypted: { type: "integer" } }, + dependencies: { encrypted: ["message"], other: { type: "object" } }, + dependentSchemas: { encrypted: { type: "string" } }, dependentRequired: { encrypted: ["message"] }, - propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string", encrypted: true } } }, + propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string" } } }, required: ["message", "encrypted"], }; const before = structuredClone(parameters); @@ -135,8 +135,6 @@ describe("openai-chat request hardening", () => { }); test("a deeply nested schema is stripped without exhausting the stack", () => { - // The schema is caller-supplied, so its depth is attacker-influenced: a recursive walk - // would take the request path down with a stack overflow instead of answering. const depth = 50_000; const root: Record = { type: "object", encrypted: true }; let cursor = root; @@ -151,7 +149,6 @@ describe("openai-chat request hardening", () => { expect(stripped.encrypted).toBeUndefined(); let walk = stripped; for (let i = 0; i < depth; i++) { - // Each level keeps the property literally named `encrypted` and drops the keyword. walk = (walk.properties as Record>).encrypted; expect(walk.encrypted).toBeUndefined(); expect(walk.type).toBe("object"); @@ -299,10 +296,6 @@ describe("openai-chat non-stream response hardening", () => { expect(getDebugLogEntries()).toHaveLength(0); }); - // The diagnostic's job is to say WHICH check rejected the payload. If its precedence drifts - // from the validator's, a payload with more than one problem is reported under the wrong - // reason and sends provider-compatibility work after the wrong shape. These cases each carry - // two defects at once, so only the matching order produces the expected reason. describe("diagnostic precedence matches the buffered validator", () => { async function reasonFor(toolCall: unknown): Promise { process.env.OCX_DEBUG = "1"; @@ -316,7 +309,6 @@ describe("openai-chat non-stream response hardening", () => { } test("a bad function container outranks a bad id", async () => { - // Validator checks `!isRecord(rawToolCall.function)` before it reads `id`. expect(await reasonFor({ id: 7, function: "not-an-object" })) .toBe("tool_call_function_not_object"); }); @@ -327,8 +319,6 @@ describe("openai-chat non-stream response hardening", () => { }); test("a bad arguments type outranks a blank name", async () => { - // Both are rejected by the same validator condition; arguments is checked first there, - // so a blank name must not shadow it. expect(await reasonFor({ id: "call_1", function: { name: " ", arguments: 5 } })) .toBe("tool_call_function_arguments_invalid"); }); @@ -470,8 +460,6 @@ describe("openai-chat stream response hardening", () => { 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. expect(lines).toContain('"reason":"tool_call_not_object"'); expect(lines).toContain('"callIndex":1'); expect(lines).not.toContain('"tool_call_function_name_invalid"'); @@ -582,10 +570,6 @@ describe("openai-chat credential hardening", () => { .not.toHaveProperty("service_tier"); }); - // `service_tier` is an OpenAI-specific extension and this adapter serves 66 registry - // providers, several of which reject unknown body fields. Forwarding it by default would - // turn a caller-supplied tier into an upstream 400 on those routes, so absence of the - // opt-in must mean the field is dropped — the same contract `prompt_cache_key` uses. test("drops a caller-supplied service tier when the provider has not opted in", () => { for (const p of [provider(), provider({ chatServiceTier: false })]) { const req = parsed(); From 4df5eb971e7086cc320924e6ad3034ce5775b863 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:15:54 +0200 Subject: [PATCH 08/14] revert accidental hardening test churn --- tests/openai-chat-hardening.test.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 5140e323ed..ab731ab35b 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -80,12 +80,12 @@ describe("openai-chat request hardening", () => { }, }, patternProperties: { encrypted: { type: "string", encrypted: true } }, - $defs: { encrypted: { type: "number" } }, - definitions: { encrypted: { type: "integer" } }, - dependencies: { encrypted: ["message"], other: { type: "object" } }, - dependentSchemas: { encrypted: { type: "string" } }, + $defs: { encrypted: { type: "number", encrypted: true } }, + definitions: { encrypted: { type: "integer", encrypted: false } }, + dependencies: { encrypted: ["message"], other: { type: "object", encrypted: true } }, + dependentSchemas: { encrypted: { type: "string", encrypted: true } }, dependentRequired: { encrypted: ["message"] }, - propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string" } } }, + propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string", encrypted: true } } }, required: ["message", "encrypted"], }; const before = structuredClone(parameters); @@ -135,6 +135,8 @@ describe("openai-chat request hardening", () => { }); test("a deeply nested schema is stripped without exhausting the stack", () => { + // The schema is caller-supplied, so its depth is attacker-influenced: a recursive walk + // would take the request path down with a stack overflow instead of answering. const depth = 50_000; const root: Record = { type: "object", encrypted: true }; let cursor = root; @@ -149,6 +151,7 @@ describe("openai-chat request hardening", () => { expect(stripped.encrypted).toBeUndefined(); let walk = stripped; for (let i = 0; i < depth; i++) { + // Each level keeps the property literally named `encrypted` and drops the keyword. walk = (walk.properties as Record>).encrypted; expect(walk.encrypted).toBeUndefined(); expect(walk.type).toBe("object"); @@ -296,6 +299,10 @@ describe("openai-chat non-stream response hardening", () => { expect(getDebugLogEntries()).toHaveLength(0); }); + // The diagnostic's job is to say WHICH check rejected the payload. If its precedence drifts + // from the validator's, a payload with more than one problem is reported under the wrong + // reason and sends provider-compatibility work after the wrong shape. These cases each carry + // two defects at once, so only the matching order produces the expected reason. describe("diagnostic precedence matches the buffered validator", () => { async function reasonFor(toolCall: unknown): Promise { process.env.OCX_DEBUG = "1"; @@ -309,6 +316,7 @@ describe("openai-chat non-stream response hardening", () => { } test("a bad function container outranks a bad id", async () => { + // Validator checks `!isRecord(rawToolCall.function)` before it reads `id`. expect(await reasonFor({ id: 7, function: "not-an-object" })) .toBe("tool_call_function_not_object"); }); @@ -319,6 +327,8 @@ describe("openai-chat non-stream response hardening", () => { }); test("a bad arguments type outranks a blank name", async () => { + // Both are rejected by the same validator condition; arguments is checked first there, + // so a blank name must not shadow it. expect(await reasonFor({ id: "call_1", function: { name: " ", arguments: 5 } })) .toBe("tool_call_function_arguments_invalid"); }); @@ -460,6 +470,8 @@ describe("openai-chat stream response hardening", () => { 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. expect(lines).toContain('"reason":"tool_call_not_object"'); expect(lines).toContain('"callIndex":1'); expect(lines).not.toContain('"tool_call_function_name_invalid"'); @@ -570,6 +582,10 @@ describe("openai-chat credential hardening", () => { .not.toHaveProperty("service_tier"); }); + // `service_tier` is an OpenAI-specific extension and this adapter serves 66 registry + // providers, several of which reject unknown body fields. Forwarding it by default would + // turn a caller-supplied tier into an upstream 400 on those routes, so absence of the + // opt-in must mean the field is dropped — the same contract `prompt_cache_key` uses. test("drops a caller-supplied service tier when the provider has not opted in", () => { for (const p of [provider(), provider({ chatServiceTier: false })]) { const req = parsed(); From 4c9a880f76b33bd074cb559853a2db13f6e6890b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:16:17 +0200 Subject: [PATCH 09/14] noop --- tests/openai-chat-hardening.test.ts | 791 ---------------------------- 1 file changed, 791 deletions(-) diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index ab731ab35b..e69de29bb2 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -1,791 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; -import { stripResponsesOnlyEncryptedMarker } from "../src/adapters/responses-tool-schema"; -import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; -import { resetDebugSettingsForTests } from "../src/lib/debug-settings"; -import { routeModel } from "../src/router"; -import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; -import { withTestTranslatorBudget } from "./helpers/translator-budget"; - -const createOpenAIChatAdapter = (...args: Parameters) => - withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); - -const previousDebug = process.env.OCX_DEBUG; - -afterEach(() => { - resetDebugSettingsForTests(); - resetDebugLogBufferForTests(); - if (previousDebug === undefined) delete process.env.OCX_DEBUG; - else process.env.OCX_DEBUG = previousDebug; -}); - -function parsed(): OcxParsedRequest { - return { - modelId: "test-model", - context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, - stream: false, - options: {}, - }; -} - -function provider(overrides: Partial = {}): OcxProviderConfig { - return { - adapter: "openai-chat", - baseUrl: "https://example.test/v1", - apiKey: "sk-test", - authMode: "key", - ...overrides, - }; -} - -async function collect(stream: AsyncGenerator): Promise { - const events: AdapterEvent[] = []; - for await (const event of stream) events.push(event); - return events; -} - -function routedProvider(name: "litellm" | "ollama", apiKey?: string): OcxProviderConfig { - const config = { - port: 10100, - defaultProvider: name, - providers: { - [name]: { - adapter: "openai-chat", - baseUrl: name === "litellm" ? "http://localhost:4000/v1" : "http://localhost:11434/v1", - ...(name === "litellm" ? { authMode: "key" as const } : {}), - ...(apiKey !== undefined ? { apiKey } : {}), - }, - }, - } as OcxConfig; - return routeModel(config, `${name}/test-model`).provider; -} - -describe("openai-chat request hardening", () => { - test("strips Responses-only encrypted annotations without changing schema names or literal values", () => { - const parameters = { - type: "object", - properties: { - encrypted: { type: "boolean", description: "A legitimate tool argument name" }, - message: { type: "string", encrypted: true }, - nested: { - type: "object", - properties: { value: { type: "string", encrypted: false } }, - }, - literalData: { - type: "object", - const: { encrypted: true }, - default: { encrypted: false }, - enum: [{ encrypted: true }], - examples: [{ encrypted: false }], - }, - }, - patternProperties: { encrypted: { type: "string", encrypted: true } }, - $defs: { encrypted: { type: "number", encrypted: true } }, - definitions: { encrypted: { type: "integer", encrypted: false } }, - dependencies: { encrypted: ["message"], other: { type: "object", encrypted: true } }, - dependentSchemas: { encrypted: { type: "string", encrypted: true } }, - dependentRequired: { encrypted: ["message"] }, - propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string", encrypted: true } } }, - required: ["message", "encrypted"], - }; - const before = structuredClone(parameters); - const request = createOpenAIChatAdapter(provider()).buildRequest({ - ...parsed(), - context: { - messages: [{ role: "user", content: "delegate", timestamp: 0 }], - tools: [{ - name: "spawn_agent", - namespace: "collaboration", - description: "Spawn a child agent", - parameters, - }], - }, - }); - const body = JSON.parse(request.body) as { - tools: Array<{ function: { parameters: Record } }>; - }; - - expect(body.tools[0].function.parameters).toEqual({ - type: "object", - properties: { - encrypted: { type: "boolean", description: "A legitimate tool argument name" }, - message: { type: "string" }, - nested: { - type: "object", - properties: { value: { type: "string" } }, - }, - literalData: { - type: "object", - const: { encrypted: true }, - default: { encrypted: false }, - enum: [{ encrypted: true }], - examples: [{ encrypted: false }], - }, - }, - patternProperties: { encrypted: { type: "string" } }, - $defs: { encrypted: { type: "number" } }, - definitions: { encrypted: { type: "integer" } }, - dependencies: { encrypted: ["message"], other: { type: "object" } }, - dependentSchemas: { encrypted: { type: "string" } }, - dependentRequired: { encrypted: ["message"] }, - propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string" } } }, - required: ["message", "encrypted"], - }); - expect(parameters).toEqual(before); - }); - - test("a deeply nested schema is stripped without exhausting the stack", () => { - // The schema is caller-supplied, so its depth is attacker-influenced: a recursive walk - // would take the request path down with a stack overflow instead of answering. - const depth = 50_000; - const root: Record = { type: "object", encrypted: true }; - let cursor = root; - for (let i = 0; i < depth; i++) { - const child: Record = { type: "object", encrypted: true }; - cursor.properties = { encrypted: child }; - cursor = child; - } - cursor.leaf = { type: "string", encrypted: true }; - - const stripped = stripResponsesOnlyEncryptedMarker(root) as Record; - expect(stripped.encrypted).toBeUndefined(); - let walk = stripped; - for (let i = 0; i < depth; i++) { - // Each level keeps the property literally named `encrypted` and drops the keyword. - walk = (walk.properties as Record>).encrypted; - expect(walk.encrypted).toBeUndefined(); - expect(walk.type).toBe("object"); - } - expect((walk.leaf as Record).encrypted).toBeUndefined(); - }); -}); - -describe("openai-chat non-stream response hardening", () => { - test("surfaces an upstream error envelope message", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const events = await adapter.parseResponse!(new Response(JSON.stringify({ - error: { message: "upstream quota exhausted", code: "quota_exceeded" }, - }))); - - expect(events).toEqual([{ - type: "error", - message: "upstream quota exhausted", - code: "quota_exceeded", - }]); - }); - - test("treats falsey upstream error payloads as errors", async () => { - const adapter = createOpenAIChatAdapter(provider()); - for (const error of [0, ""]) { - const events = await adapter.parseResponse!(new Response(JSON.stringify({ error }))); - expect(events).toEqual([{ type: "error", message: "upstream error" }]); - } - }); - - test("rejects an empty choices array", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [] }))); - - expect(events).toEqual([{ type: "error", message: "upstream response contained no choices" }]); - }); - - test("preserves usage when an upstream response has no choices", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const events = await adapter.parseResponse!(new Response(JSON.stringify({ - choices: [], - usage: { prompt_tokens: 7, completion_tokens: 2 }, - }))); - - expect(events).toEqual([{ - type: "error", - message: "upstream response contained no choices", - usage: { inputTokens: 7, outputTokens: 2 }, - }]); - }); - - test("keeps ordinary and data-wrapped responses compatible for non-Cline providers", async () => { - const adapter = createOpenAIChatAdapter(provider()); - for (const body of [ - { choices: [{ message: { content: "plain" } }] }, - { success: true, data: { choices: [{ message: { content: "wrapped" } }] } }, - ]) { - const events = await adapter.parseResponse!(new Response(JSON.stringify(body))); - expect(events.find(event => event.type === "error")).toBeUndefined(); - expect(events.at(-1)?.type).toBe("done"); - } - }); - - test("rejects a choice with no message", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [{}] }))); - - expect(events).toEqual([{ type: "error", message: "upstream response contained no choices" }]); - }); - - test("rejects a null choice without throwing", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [null] }))); - - 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, 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", - status: 502, - errorType: "upstream_error", - message, - usage: { inputTokens: 7, outputTokens: 2 }, - }]); - } - }); - - test("debug mode records only the non-stream tool-call shape failure", async () => { - process.env.OCX_DEBUG = "1"; - const secretArguments = "private-tool-arguments"; - const adapter = createOpenAIChatAdapter(provider()); - const events = await adapter.parseResponse!(new Response(JSON.stringify({ - choices: [{ message: { role: "assistant", tool_calls: [{ - id: "call_1", - function: { name: "tool", arguments: { secretArguments } }, - }] } }], - }))); - - 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"'); - expect(lines).toContain('"reason":"tool_call_function_arguments_invalid"'); - expect(lines).toContain('"valueType":"object"'); - expect(lines).not.toContain(secretArguments); - expect(lines).not.toContain("call_1"); - }); - - test("tool-call structural diagnostics stay disabled by default", async () => { - delete process.env.OCX_DEBUG; - const adapter = createOpenAIChatAdapter(provider()); - await adapter.parseResponse!(new Response(JSON.stringify({ - choices: [{ message: { role: "assistant", tool_calls: { privateArguments: "secret" } } }], - }))); - - expect(getDebugLogEntries()).toHaveLength(0); - }); - - // The diagnostic's job is to say WHICH check rejected the payload. If its precedence drifts - // from the validator's, a payload with more than one problem is reported under the wrong - // reason and sends provider-compatibility work after the wrong shape. These cases each carry - // two defects at once, so only the matching order produces the expected reason. - describe("diagnostic precedence matches the buffered validator", () => { - async function reasonFor(toolCall: unknown): Promise { - process.env.OCX_DEBUG = "1"; - const adapter = createOpenAIChatAdapter(provider()); - await adapter.parseResponse!(new Response(JSON.stringify({ - choices: [{ message: { role: "assistant", tool_calls: [toolCall] } }], - }))); - const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); - const match = /"reason":"([a-z_]+)"/.exec(lines); - return match?.[1] ?? ""; - } - - test("a bad function container outranks a bad id", async () => { - // Validator checks `!isRecord(rawToolCall.function)` before it reads `id`. - expect(await reasonFor({ id: 7, function: "not-an-object" })) - .toBe("tool_call_function_not_object"); - }); - - test("a bad id outranks a bad name", async () => { - expect(await reasonFor({ id: 7, function: { name: 9, arguments: "{}" } })) - .toBe("tool_call_id_invalid"); - }); - - test("a bad arguments type outranks a blank name", async () => { - // Both are rejected by the same validator condition; arguments is checked first there, - // so a blank name must not shadow it. - expect(await reasonFor({ id: "call_1", function: { name: " ", arguments: 5 } })) - .toBe("tool_call_function_arguments_invalid"); - }); - - test("a blank name is reported as blank, not as a type problem", async () => { - expect(await reasonFor({ id: "call_1", function: { name: " ", arguments: "{}" } })) - .toBe("tool_call_function_name_blank"); - }); - }); -}); - -describe("openai-chat stream response hardening", () => { - test("treats falsey upstream error payloads as terminal errors", async () => { - const adapter = createOpenAIChatAdapter(provider()); - for (const error of [0, ""]) { - const response = new Response([ - `data: ${JSON.stringify({ error })}\n\n`, - "data: [DONE]\n\n", - ].join("")); - - const events = await collect(adapter.parseStream(response)); - expect(events).toEqual([{ type: "error", message: "upstream error" }]); - } - }); - - test("rejects a non-array choices payload without throwing", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const response = new Response([ - 'data: {"choices":{},"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: "error", - message: "upstream response contained invalid choices", - usage: { inputTokens: 7, outputTokens: 2 }, - }]); - }); - - test("malformed SSE data is terminal even when followed by [DONE]", async () => { - const adapter = createOpenAIChatAdapter(provider()); - const response = new Response([ - 'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n', - "data: {not-json}\n\n", - "data: [DONE]\n\n", - ].join("")); - - const events = await collect(adapter.parseStream(response)); - - expect(events.at(-1)).toEqual({ type: "error", message: "malformed upstream SSE data frame" }); - 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, 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 } }], - 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: "error", - status: 502, - errorType: "upstream_error", - message, - usage: { inputTokens: 7, outputTokens: 2 }, - }]); - } - }); - - test("debug mode classifies streaming tool-call structure without retaining values", async () => { - process.env.OCX_DEBUG = "1"; - const privateName = "private-tool-name"; - const adapter = createOpenAIChatAdapter(provider()); - const response = new Response([ - `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ - privateName, - privateArguments: "private arguments", - }, null] } }] })}\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_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"'); - expect(lines).toContain('"callIndex":1'); - expect(lines).toContain('"valueType":"null"'); - expect(lines).not.toContain(privateName); - expect(lines).not.toContain("private arguments"); - }); - - test("debug mode skips accepted null padding and blames the real malformed delta (#1731)", async () => { - process.env.OCX_DEBUG = "1"; - const adapter = createOpenAIChatAdapter(provider()); - const response = new Response([ - `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [ - { index: 0, id: null, function: { name: null, arguments: null } }, - null, - ] } }] })}\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_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. - expect(lines).toContain('"reason":"tool_call_not_object"'); - expect(lines).toContain('"callIndex":1'); - expect(lines).not.toContain('"tool_call_function_name_invalid"'); - }); -}); - -describe("openai-chat credential hardening", () => { - test("key mode rejects a blank credential", () => { - const adapter = createOpenAIChatAdapter(provider({ apiKey: " " })); - - expect(() => adapter.buildRequest(parsed())).toThrow( - "openai-chat requires a non-empty credential (authMode: key)", - ); - }); - - test("OAuth mode rejects a blank credential", () => { - const adapter = createOpenAIChatAdapter(provider({ authMode: "oauth", apiKey: "" })); - - expect(() => adapter.buildRequest(parsed())).toThrow( - "openai-chat requires a non-empty credential (authMode: oauth)", - ); - }); - - test("undefined auth mode remains keyless", () => { - const adapter = createOpenAIChatAdapter(provider({ authMode: undefined, apiKey: undefined })); - - expect(adapter.buildRequest(parsed()).headers).not.toHaveProperty("Authorization"); - }); - - test("a routed local provider remains keyless", () => { - const local = routedProvider("ollama"); - - expect(local.authMode).toBeUndefined(); - expect(createOpenAIChatAdapter(local).buildRequest(parsed()).headers).not.toHaveProperty("Authorization"); - }); - - test("LiteLLM's routed optional-key flag permits a keyless request", () => { - const litellm = routedProvider("litellm"); - - expect(litellm.keyOptional).toBe(true); - expect(createOpenAIChatAdapter(litellm).buildRequest(parsed()).headers).not.toHaveProperty("Authorization"); - }); - - test("LiteLLM still sends a configured bearer credential", () => { - const litellm = routedProvider("litellm", "sk-litellm"); - - expect(createOpenAIChatAdapter(litellm).buildRequest(parsed()).headers).toMatchObject({ - Authorization: "Bearer sk-litellm", - }); - }); - - test("forwards prompt_cache_key to the outbound chat body when the provider opts in", () => { - const adapter = createOpenAIChatAdapter(provider({ promptCacheKey: true })); - const req = parsed(); - req.options.promptCacheKey = "shared-prefix-v1"; - - const body = JSON.parse(adapter.buildRequest(req).body); - - expect(body.prompt_cache_key).toBe("shared-prefix-v1"); - }); - - test("does not forward prompt_cache_key when the provider has not opted in", () => { - const adapter = createOpenAIChatAdapter(provider()); - const req = parsed(); - req.options.promptCacheKey = "shared-prefix-v1"; - - const body = JSON.parse(adapter.buildRequest(req).body); - - expect(body).not.toHaveProperty("prompt_cache_key"); - }); - - test("omits prompt_cache_key from the outbound chat body when unset", () => { - const adapter = createOpenAIChatAdapter(provider({ promptCacheKey: true })); - - const body = JSON.parse(adapter.buildRequest(parsed()).body); - - expect(body).not.toHaveProperty("prompt_cache_key"); - }); - - test("preserves a caller-supplied service tier when the provider opts in", () => { - const adapter = createOpenAIChatAdapter(provider({ chatServiceTier: true })); - const req = parsed(); - req.options.serviceTier = "priority"; - - const body = JSON.parse(adapter.buildRequest(req).body); - - expect(body.service_tier).toBe("priority"); - }); - - test("an exact model capability authorizes only that Chat model", () => { - const exactOnly = provider({ modelSupportsServiceTier: { "test-model": true } }); - const authorized = parsed(); - authorized.options.serviceTier = "priority"; - expect(JSON.parse(createOpenAIChatAdapter(exactOnly).buildRequest(authorized).body).service_tier) - .toBe("priority"); - - const undeclared = parsed(); - undeclared.modelId = "other-model"; - undeclared.options.serviceTier = "priority"; - expect(JSON.parse(createOpenAIChatAdapter(exactOnly).buildRequest(undeclared).body)) - .not.toHaveProperty("service_tier"); - - const providerDenied = provider({ - supportsServiceTier: false, - modelSupportsServiceTier: { "test-model": true }, - }); - expect(JSON.parse(createOpenAIChatAdapter(providerDenied).buildRequest(authorized).body)) - .not.toHaveProperty("service_tier"); - }); - - // `service_tier` is an OpenAI-specific extension and this adapter serves 66 registry - // providers, several of which reject unknown body fields. Forwarding it by default would - // turn a caller-supplied tier into an upstream 400 on those routes, so absence of the - // opt-in must mean the field is dropped — the same contract `prompt_cache_key` uses. - test("drops a caller-supplied service tier when the provider has not opted in", () => { - for (const p of [provider(), provider({ chatServiceTier: false })]) { - const req = parsed(); - req.options.serviceTier = "priority"; - - const body = JSON.parse(createOpenAIChatAdapter(p).buildRequest(req).body); - - expect(body).not.toHaveProperty("service_tier"); - } - }); - - test("an opted-in provider without a caller tier still sends no service_tier", () => { - const body = JSON.parse(createOpenAIChatAdapter(provider({ chatServiceTier: true })).buildRequest(parsed()).body); - - expect(body).not.toHaveProperty("service_tier"); - }); - - test("canonical Kimi Coding Plan routes forward Codex prompt_cache_key", () => { - for (const [providerName, authMode] of [ - ["kimi", "oauth"], - ["kimi-code", "key"], - ] as const) { - const config: OcxConfig = { - port: 10100, - defaultProvider: providerName, - providers: { - [providerName]: { - adapter: "openai-chat", - baseUrl: "https://api.kimi.com/coding/v1", - apiKey: "test-kimi-credential", - authMode, - }, - }, - }; - const route = routeModel(config, `${providerName}/k3`); - const req = parsed(); - req.modelId = route.modelId; - req.options.promptCacheKey = "codex-kimi-session-v1"; - - expect(route.provider.promptCacheKey).toBe(true); - const body = JSON.parse(createOpenAIChatAdapter(route.provider).buildRequest(req).body); - expect(body).toMatchObject({ - model: "k3", - prompt_cache_key: "codex-kimi-session-v1", - }); - } - }); - - test("an explicit Kimi promptCacheKey false remains an opt-out", () => { - const config: OcxConfig = { - port: 10100, - defaultProvider: "kimi", - providers: { - kimi: { - adapter: "openai-chat", - baseUrl: "https://api.kimi.com/coding/v1", - apiKey: "test-kimi-credential", - authMode: "oauth", - promptCacheKey: false, - }, - }, - }; - const route = routeModel(config, "kimi/k3"); - const req = parsed(); - req.modelId = route.modelId; - req.options.promptCacheKey = "codex-kimi-session-v1"; - - expect(route.provider.promptCacheKey).toBe(false); - const body = JSON.parse(createOpenAIChatAdapter(route.provider).buildRequest(req).body); - expect(body).not.toHaveProperty("prompt_cache_key"); - }); -}); - -describe("openai-chat max output defaults", () => { - test("omits max_tokens when neither request nor provider config sets a budget", () => { - const body = JSON.parse(createOpenAIChatAdapter(provider()).buildRequest(parsed()).body); - - expect(body).not.toHaveProperty("max_tokens"); - }); - - test("uses provider defaultMaxOutputTokens when Codex omits max_output_tokens", () => { - const body = JSON.parse(createOpenAIChatAdapter(provider({ defaultMaxOutputTokens: 32_000 })).buildRequest(parsed()).body); - - expect(body.max_tokens).toBe(32_000); - }); - - test("modelMaxOutputTokens beats the provider default and supports model matching helpers", () => { - const req = parsed(); - req.modelId = "gpt-oss:120b"; - const body = JSON.parse(createOpenAIChatAdapter(provider({ - defaultMaxOutputTokens: 16_000, - modelMaxOutputTokens: { "gpt-oss": 64_000 }, - })).buildRequest(req).body); - - expect(body.max_tokens).toBe(64_000); - }); - - test("explicit request max_output_tokens beats configured defaults", () => { - const req = parsed(); - req.options.maxOutputTokens = 8_000; - const body = JSON.parse(createOpenAIChatAdapter(provider({ - defaultMaxOutputTokens: 32_000, - modelMaxOutputTokens: { "test-model": 64_000 }, - })).buildRequest(req).body); - - expect(body.max_tokens).toBe(8_000); - }); - - test("thinking-budget models size thinking_budget from the effective default budget", () => { - const body = JSON.parse(createOpenAIChatAdapter(provider({ - defaultMaxOutputTokens: 20_000, - thinkingBudgetModels: ["test-model"], - reasoningEffortMap: { high: "high" }, - })).buildRequest({ - ...parsed(), - options: { reasoning: "high" }, - }).body); - - expect(body.max_tokens).toBe(20_000); - expect(body.thinking_budget).toBe(15_000); - }); -}); - -describe("openai-chat response_format emission", () => { - const bodyOf = (req: { body?: unknown }): Record => - JSON.parse(req.body as string) as Record; - - test("maps textFormat json_object onto response_format", () => { - const req = createOpenAIChatAdapter(provider()).buildRequest({ - ...parsed(), - options: { textFormat: { type: "json_object" } }, - }); - - expect(bodyOf(req).response_format).toEqual({ type: "json_object" }); - }); - - test("re-nests textFormat json_schema as chat response_format", () => { - const req = createOpenAIChatAdapter(provider()).buildRequest({ - ...parsed(), - options: { - textFormat: { type: "json_schema", name: "answer", description: "shape", schema: { type: "object" }, strict: true }, - }, - }); - - expect(bodyOf(req).response_format).toEqual({ - type: "json_schema", - json_schema: { name: "answer", description: "shape", schema: { type: "object" }, strict: true }, - }); - }); - - test("defaults the json_schema name when the Responses form omits it", () => { - const req = createOpenAIChatAdapter(provider()).buildRequest({ - ...parsed(), - options: { textFormat: { type: "json_schema", schema: { type: "object" } } }, - }); - - expect(bodyOf(req).response_format).toEqual({ - type: "json_schema", - json_schema: { name: "response", schema: { type: "object" } }, - }); - }); - - test("omits response_format without a textFormat option", () => { - const plain = createOpenAIChatAdapter(provider()).buildRequest(parsed()); - - expect(bodyOf(plain).response_format).toBeUndefined(); - }); - - test("preserves a schema-less json_schema response_format", () => { - const schemaless = createOpenAIChatAdapter(provider()).buildRequest({ - ...parsed(), - options: { textFormat: { type: "json_schema", name: "answer" } }, - }); - - expect(bodyOf(schemaless).response_format).toEqual({ - type: "json_schema", - json_schema: { name: "answer" }, - }); - }); - - test("omits response_format only for an explicitly opted-out model", () => { - const adapter = createOpenAIChatAdapter(provider({ - noStructuredOutputModels: ["test-model"], - })); - const options: OcxParsedRequest["options"] = { - textFormat: { type: "json_schema", name: "answer", schema: { type: "object" }, strict: true }, - }; - - const optedOut = adapter.buildRequest({ ...parsed(), options }); - const supportedSibling = adapter.buildRequest({ ...parsed(), modelId: "supported-model", options }); - const colonVariant = adapter.buildRequest({ ...parsed(), modelId: "test-model:structured", options }); - - expect(bodyOf(optedOut).response_format).toBeUndefined(); - expect(bodyOf(supportedSibling).response_format).toEqual({ - type: "json_schema", - json_schema: { name: "answer", schema: { type: "object" }, strict: true }, - }); - expect(bodyOf(colonVariant).response_format).toEqual({ - type: "json_schema", - json_schema: { name: "answer", schema: { type: "object" }, strict: true }, - }); - }); -}); From 55a3bcc3cc7bbd0e91ceece8858d9e7abad164ce Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:17:22 +0200 Subject: [PATCH 10/14] restore hardening tests after tool misfire --- tests/openai-chat-hardening.test.ts | 791 ++++++++++++++++++++++++++++ 1 file changed, 791 insertions(+) diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index e69de29bb2..ab731ab35b 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -0,0 +1,791 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import { stripResponsesOnlyEncryptedMarker } from "../src/adapters/responses-tool-schema"; +import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; +import { resetDebugSettingsForTests } from "../src/lib/debug-settings"; +import { routeModel } from "../src/router"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createOpenAIChatAdapter = (...args: Parameters) => + withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); + +const previousDebug = process.env.OCX_DEBUG; + +afterEach(() => { + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); + if (previousDebug === undefined) delete process.env.OCX_DEBUG; + else process.env.OCX_DEBUG = previousDebug; +}); + +function parsed(): OcxParsedRequest { + return { + modelId: "test-model", + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + stream: false, + options: {}, + }; +} + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + ...overrides, + }; +} + +async function collect(stream: AsyncGenerator): Promise { + const events: AdapterEvent[] = []; + for await (const event of stream) events.push(event); + return events; +} + +function routedProvider(name: "litellm" | "ollama", apiKey?: string): OcxProviderConfig { + const config = { + port: 10100, + defaultProvider: name, + providers: { + [name]: { + adapter: "openai-chat", + baseUrl: name === "litellm" ? "http://localhost:4000/v1" : "http://localhost:11434/v1", + ...(name === "litellm" ? { authMode: "key" as const } : {}), + ...(apiKey !== undefined ? { apiKey } : {}), + }, + }, + } as OcxConfig; + return routeModel(config, `${name}/test-model`).provider; +} + +describe("openai-chat request hardening", () => { + test("strips Responses-only encrypted annotations without changing schema names or literal values", () => { + const parameters = { + type: "object", + properties: { + encrypted: { type: "boolean", description: "A legitimate tool argument name" }, + message: { type: "string", encrypted: true }, + nested: { + type: "object", + properties: { value: { type: "string", encrypted: false } }, + }, + literalData: { + type: "object", + const: { encrypted: true }, + default: { encrypted: false }, + enum: [{ encrypted: true }], + examples: [{ encrypted: false }], + }, + }, + patternProperties: { encrypted: { type: "string", encrypted: true } }, + $defs: { encrypted: { type: "number", encrypted: true } }, + definitions: { encrypted: { type: "integer", encrypted: false } }, + dependencies: { encrypted: ["message"], other: { type: "object", encrypted: true } }, + dependentSchemas: { encrypted: { type: "string", encrypted: true } }, + dependentRequired: { encrypted: ["message"] }, + propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string", encrypted: true } } }, + required: ["message", "encrypted"], + }; + const before = structuredClone(parameters); + const request = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + context: { + messages: [{ role: "user", content: "delegate", timestamp: 0 }], + tools: [{ + name: "spawn_agent", + namespace: "collaboration", + description: "Spawn a child agent", + parameters, + }], + }, + }); + const body = JSON.parse(request.body) as { + tools: Array<{ function: { parameters: Record } }>; + }; + + expect(body.tools[0].function.parameters).toEqual({ + type: "object", + properties: { + encrypted: { type: "boolean", description: "A legitimate tool argument name" }, + message: { type: "string" }, + nested: { + type: "object", + properties: { value: { type: "string" } }, + }, + literalData: { + type: "object", + const: { encrypted: true }, + default: { encrypted: false }, + enum: [{ encrypted: true }], + examples: [{ encrypted: false }], + }, + }, + patternProperties: { encrypted: { type: "string" } }, + $defs: { encrypted: { type: "number" } }, + definitions: { encrypted: { type: "integer" } }, + dependencies: { encrypted: ["message"], other: { type: "object" } }, + dependentSchemas: { encrypted: { type: "string" } }, + dependentRequired: { encrypted: ["message"] }, + propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string" } } }, + required: ["message", "encrypted"], + }); + expect(parameters).toEqual(before); + }); + + test("a deeply nested schema is stripped without exhausting the stack", () => { + // The schema is caller-supplied, so its depth is attacker-influenced: a recursive walk + // would take the request path down with a stack overflow instead of answering. + const depth = 50_000; + const root: Record = { type: "object", encrypted: true }; + let cursor = root; + for (let i = 0; i < depth; i++) { + const child: Record = { type: "object", encrypted: true }; + cursor.properties = { encrypted: child }; + cursor = child; + } + cursor.leaf = { type: "string", encrypted: true }; + + const stripped = stripResponsesOnlyEncryptedMarker(root) as Record; + expect(stripped.encrypted).toBeUndefined(); + let walk = stripped; + for (let i = 0; i < depth; i++) { + // Each level keeps the property literally named `encrypted` and drops the keyword. + walk = (walk.properties as Record>).encrypted; + expect(walk.encrypted).toBeUndefined(); + expect(walk.type).toBe("object"); + } + expect((walk.leaf as Record).encrypted).toBeUndefined(); + }); +}); + +describe("openai-chat non-stream response hardening", () => { + test("surfaces an upstream error envelope message", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + error: { message: "upstream quota exhausted", code: "quota_exceeded" }, + }))); + + expect(events).toEqual([{ + type: "error", + message: "upstream quota exhausted", + code: "quota_exceeded", + }]); + }); + + test("treats falsey upstream error payloads as errors", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const error of [0, ""]) { + const events = await adapter.parseResponse!(new Response(JSON.stringify({ error }))); + expect(events).toEqual([{ type: "error", message: "upstream error" }]); + } + }); + + test("rejects an empty choices array", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [] }))); + + expect(events).toEqual([{ type: "error", message: "upstream response contained no choices" }]); + }); + + test("preserves usage when an upstream response has no choices", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + }))); + + expect(events).toEqual([{ + type: "error", + message: "upstream response contained no choices", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + }); + + test("keeps ordinary and data-wrapped responses compatible for non-Cline providers", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const body of [ + { choices: [{ message: { content: "plain" } }] }, + { success: true, data: { choices: [{ message: { content: "wrapped" } }] } }, + ]) { + const events = await adapter.parseResponse!(new Response(JSON.stringify(body))); + expect(events.find(event => event.type === "error")).toBeUndefined(); + expect(events.at(-1)?.type).toBe("done"); + } + }); + + test("rejects a choice with no message", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [{}] }))); + + expect(events).toEqual([{ type: "error", message: "upstream response contained no choices" }]); + }); + + test("rejects a null choice without throwing", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [null] }))); + + 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, 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", + status: 502, + errorType: "upstream_error", + message, + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + } + }); + + test("debug mode records only the non-stream tool-call shape failure", async () => { + process.env.OCX_DEBUG = "1"; + const secretArguments = "private-tool-arguments"; + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", tool_calls: [{ + id: "call_1", + function: { name: "tool", arguments: { secretArguments } }, + }] } }], + }))); + + 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"'); + expect(lines).toContain('"reason":"tool_call_function_arguments_invalid"'); + expect(lines).toContain('"valueType":"object"'); + expect(lines).not.toContain(secretArguments); + expect(lines).not.toContain("call_1"); + }); + + test("tool-call structural diagnostics stay disabled by default", async () => { + delete process.env.OCX_DEBUG; + const adapter = createOpenAIChatAdapter(provider()); + await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", tool_calls: { privateArguments: "secret" } } }], + }))); + + expect(getDebugLogEntries()).toHaveLength(0); + }); + + // The diagnostic's job is to say WHICH check rejected the payload. If its precedence drifts + // from the validator's, a payload with more than one problem is reported under the wrong + // reason and sends provider-compatibility work after the wrong shape. These cases each carry + // two defects at once, so only the matching order produces the expected reason. + describe("diagnostic precedence matches the buffered validator", () => { + async function reasonFor(toolCall: unknown): Promise { + process.env.OCX_DEBUG = "1"; + const adapter = createOpenAIChatAdapter(provider()); + await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", tool_calls: [toolCall] } }], + }))); + const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); + const match = /"reason":"([a-z_]+)"/.exec(lines); + return match?.[1] ?? ""; + } + + test("a bad function container outranks a bad id", async () => { + // Validator checks `!isRecord(rawToolCall.function)` before it reads `id`. + expect(await reasonFor({ id: 7, function: "not-an-object" })) + .toBe("tool_call_function_not_object"); + }); + + test("a bad id outranks a bad name", async () => { + expect(await reasonFor({ id: 7, function: { name: 9, arguments: "{}" } })) + .toBe("tool_call_id_invalid"); + }); + + test("a bad arguments type outranks a blank name", async () => { + // Both are rejected by the same validator condition; arguments is checked first there, + // so a blank name must not shadow it. + expect(await reasonFor({ id: "call_1", function: { name: " ", arguments: 5 } })) + .toBe("tool_call_function_arguments_invalid"); + }); + + test("a blank name is reported as blank, not as a type problem", async () => { + expect(await reasonFor({ id: "call_1", function: { name: " ", arguments: "{}" } })) + .toBe("tool_call_function_name_blank"); + }); + }); +}); + +describe("openai-chat stream response hardening", () => { + test("treats falsey upstream error payloads as terminal errors", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const error of [0, ""]) { + const response = new Response([ + `data: ${JSON.stringify({ error })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ type: "error", message: "upstream error" }]); + } + }); + + test("rejects a non-array choices payload without throwing", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + 'data: {"choices":{},"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: "error", + message: "upstream response contained invalid choices", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + }); + + test("malformed SSE data is terminal even when followed by [DONE]", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + 'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n', + "data: {not-json}\n\n", + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + + expect(events.at(-1)).toEqual({ type: "error", message: "malformed upstream SSE data frame" }); + 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, 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 } }], + 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: "error", + status: 502, + errorType: "upstream_error", + message, + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + } + }); + + test("debug mode classifies streaming tool-call structure without retaining values", async () => { + process.env.OCX_DEBUG = "1"; + const privateName = "private-tool-name"; + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + privateName, + privateArguments: "private arguments", + }, null] } }] })}\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_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"'); + expect(lines).toContain('"callIndex":1'); + expect(lines).toContain('"valueType":"null"'); + expect(lines).not.toContain(privateName); + expect(lines).not.toContain("private arguments"); + }); + + test("debug mode skips accepted null padding and blames the real malformed delta (#1731)", async () => { + process.env.OCX_DEBUG = "1"; + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [ + { index: 0, id: null, function: { name: null, arguments: null } }, + null, + ] } }] })}\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_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. + expect(lines).toContain('"reason":"tool_call_not_object"'); + expect(lines).toContain('"callIndex":1'); + expect(lines).not.toContain('"tool_call_function_name_invalid"'); + }); +}); + +describe("openai-chat credential hardening", () => { + test("key mode rejects a blank credential", () => { + const adapter = createOpenAIChatAdapter(provider({ apiKey: " " })); + + expect(() => adapter.buildRequest(parsed())).toThrow( + "openai-chat requires a non-empty credential (authMode: key)", + ); + }); + + test("OAuth mode rejects a blank credential", () => { + const adapter = createOpenAIChatAdapter(provider({ authMode: "oauth", apiKey: "" })); + + expect(() => adapter.buildRequest(parsed())).toThrow( + "openai-chat requires a non-empty credential (authMode: oauth)", + ); + }); + + test("undefined auth mode remains keyless", () => { + const adapter = createOpenAIChatAdapter(provider({ authMode: undefined, apiKey: undefined })); + + expect(adapter.buildRequest(parsed()).headers).not.toHaveProperty("Authorization"); + }); + + test("a routed local provider remains keyless", () => { + const local = routedProvider("ollama"); + + expect(local.authMode).toBeUndefined(); + expect(createOpenAIChatAdapter(local).buildRequest(parsed()).headers).not.toHaveProperty("Authorization"); + }); + + test("LiteLLM's routed optional-key flag permits a keyless request", () => { + const litellm = routedProvider("litellm"); + + expect(litellm.keyOptional).toBe(true); + expect(createOpenAIChatAdapter(litellm).buildRequest(parsed()).headers).not.toHaveProperty("Authorization"); + }); + + test("LiteLLM still sends a configured bearer credential", () => { + const litellm = routedProvider("litellm", "sk-litellm"); + + expect(createOpenAIChatAdapter(litellm).buildRequest(parsed()).headers).toMatchObject({ + Authorization: "Bearer sk-litellm", + }); + }); + + test("forwards prompt_cache_key to the outbound chat body when the provider opts in", () => { + const adapter = createOpenAIChatAdapter(provider({ promptCacheKey: true })); + const req = parsed(); + req.options.promptCacheKey = "shared-prefix-v1"; + + const body = JSON.parse(adapter.buildRequest(req).body); + + expect(body.prompt_cache_key).toBe("shared-prefix-v1"); + }); + + test("does not forward prompt_cache_key when the provider has not opted in", () => { + const adapter = createOpenAIChatAdapter(provider()); + const req = parsed(); + req.options.promptCacheKey = "shared-prefix-v1"; + + const body = JSON.parse(adapter.buildRequest(req).body); + + expect(body).not.toHaveProperty("prompt_cache_key"); + }); + + test("omits prompt_cache_key from the outbound chat body when unset", () => { + const adapter = createOpenAIChatAdapter(provider({ promptCacheKey: true })); + + const body = JSON.parse(adapter.buildRequest(parsed()).body); + + expect(body).not.toHaveProperty("prompt_cache_key"); + }); + + test("preserves a caller-supplied service tier when the provider opts in", () => { + const adapter = createOpenAIChatAdapter(provider({ chatServiceTier: true })); + const req = parsed(); + req.options.serviceTier = "priority"; + + const body = JSON.parse(adapter.buildRequest(req).body); + + expect(body.service_tier).toBe("priority"); + }); + + test("an exact model capability authorizes only that Chat model", () => { + const exactOnly = provider({ modelSupportsServiceTier: { "test-model": true } }); + const authorized = parsed(); + authorized.options.serviceTier = "priority"; + expect(JSON.parse(createOpenAIChatAdapter(exactOnly).buildRequest(authorized).body).service_tier) + .toBe("priority"); + + const undeclared = parsed(); + undeclared.modelId = "other-model"; + undeclared.options.serviceTier = "priority"; + expect(JSON.parse(createOpenAIChatAdapter(exactOnly).buildRequest(undeclared).body)) + .not.toHaveProperty("service_tier"); + + const providerDenied = provider({ + supportsServiceTier: false, + modelSupportsServiceTier: { "test-model": true }, + }); + expect(JSON.parse(createOpenAIChatAdapter(providerDenied).buildRequest(authorized).body)) + .not.toHaveProperty("service_tier"); + }); + + // `service_tier` is an OpenAI-specific extension and this adapter serves 66 registry + // providers, several of which reject unknown body fields. Forwarding it by default would + // turn a caller-supplied tier into an upstream 400 on those routes, so absence of the + // opt-in must mean the field is dropped — the same contract `prompt_cache_key` uses. + test("drops a caller-supplied service tier when the provider has not opted in", () => { + for (const p of [provider(), provider({ chatServiceTier: false })]) { + const req = parsed(); + req.options.serviceTier = "priority"; + + const body = JSON.parse(createOpenAIChatAdapter(p).buildRequest(req).body); + + expect(body).not.toHaveProperty("service_tier"); + } + }); + + test("an opted-in provider without a caller tier still sends no service_tier", () => { + const body = JSON.parse(createOpenAIChatAdapter(provider({ chatServiceTier: true })).buildRequest(parsed()).body); + + expect(body).not.toHaveProperty("service_tier"); + }); + + test("canonical Kimi Coding Plan routes forward Codex prompt_cache_key", () => { + for (const [providerName, authMode] of [ + ["kimi", "oauth"], + ["kimi-code", "key"], + ] as const) { + const config: OcxConfig = { + port: 10100, + defaultProvider: providerName, + providers: { + [providerName]: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + apiKey: "test-kimi-credential", + authMode, + }, + }, + }; + const route = routeModel(config, `${providerName}/k3`); + const req = parsed(); + req.modelId = route.modelId; + req.options.promptCacheKey = "codex-kimi-session-v1"; + + expect(route.provider.promptCacheKey).toBe(true); + const body = JSON.parse(createOpenAIChatAdapter(route.provider).buildRequest(req).body); + expect(body).toMatchObject({ + model: "k3", + prompt_cache_key: "codex-kimi-session-v1", + }); + } + }); + + test("an explicit Kimi promptCacheKey false remains an opt-out", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "kimi", + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + apiKey: "test-kimi-credential", + authMode: "oauth", + promptCacheKey: false, + }, + }, + }; + const route = routeModel(config, "kimi/k3"); + const req = parsed(); + req.modelId = route.modelId; + req.options.promptCacheKey = "codex-kimi-session-v1"; + + expect(route.provider.promptCacheKey).toBe(false); + const body = JSON.parse(createOpenAIChatAdapter(route.provider).buildRequest(req).body); + expect(body).not.toHaveProperty("prompt_cache_key"); + }); +}); + +describe("openai-chat max output defaults", () => { + test("omits max_tokens when neither request nor provider config sets a budget", () => { + const body = JSON.parse(createOpenAIChatAdapter(provider()).buildRequest(parsed()).body); + + expect(body).not.toHaveProperty("max_tokens"); + }); + + test("uses provider defaultMaxOutputTokens when Codex omits max_output_tokens", () => { + const body = JSON.parse(createOpenAIChatAdapter(provider({ defaultMaxOutputTokens: 32_000 })).buildRequest(parsed()).body); + + expect(body.max_tokens).toBe(32_000); + }); + + test("modelMaxOutputTokens beats the provider default and supports model matching helpers", () => { + const req = parsed(); + req.modelId = "gpt-oss:120b"; + const body = JSON.parse(createOpenAIChatAdapter(provider({ + defaultMaxOutputTokens: 16_000, + modelMaxOutputTokens: { "gpt-oss": 64_000 }, + })).buildRequest(req).body); + + expect(body.max_tokens).toBe(64_000); + }); + + test("explicit request max_output_tokens beats configured defaults", () => { + const req = parsed(); + req.options.maxOutputTokens = 8_000; + const body = JSON.parse(createOpenAIChatAdapter(provider({ + defaultMaxOutputTokens: 32_000, + modelMaxOutputTokens: { "test-model": 64_000 }, + })).buildRequest(req).body); + + expect(body.max_tokens).toBe(8_000); + }); + + test("thinking-budget models size thinking_budget from the effective default budget", () => { + const body = JSON.parse(createOpenAIChatAdapter(provider({ + defaultMaxOutputTokens: 20_000, + thinkingBudgetModels: ["test-model"], + reasoningEffortMap: { high: "high" }, + })).buildRequest({ + ...parsed(), + options: { reasoning: "high" }, + }).body); + + expect(body.max_tokens).toBe(20_000); + expect(body.thinking_budget).toBe(15_000); + }); +}); + +describe("openai-chat response_format emission", () => { + const bodyOf = (req: { body?: unknown }): Record => + JSON.parse(req.body as string) as Record; + + test("maps textFormat json_object onto response_format", () => { + const req = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_object" } }, + }); + + expect(bodyOf(req).response_format).toEqual({ type: "json_object" }); + }); + + test("re-nests textFormat json_schema as chat response_format", () => { + const req = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { + textFormat: { type: "json_schema", name: "answer", description: "shape", schema: { type: "object" }, strict: true }, + }, + }); + + expect(bodyOf(req).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", description: "shape", schema: { type: "object" }, strict: true }, + }); + }); + + test("defaults the json_schema name when the Responses form omits it", () => { + const req = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", schema: { type: "object" } } }, + }); + + expect(bodyOf(req).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "response", schema: { type: "object" } }, + }); + }); + + test("omits response_format without a textFormat option", () => { + const plain = createOpenAIChatAdapter(provider()).buildRequest(parsed()); + + expect(bodyOf(plain).response_format).toBeUndefined(); + }); + + test("preserves a schema-less json_schema response_format", () => { + const schemaless = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", name: "answer" } }, + }); + + expect(bodyOf(schemaless).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer" }, + }); + }); + + test("omits response_format only for an explicitly opted-out model", () => { + const adapter = createOpenAIChatAdapter(provider({ + noStructuredOutputModels: ["test-model"], + })); + const options: OcxParsedRequest["options"] = { + textFormat: { type: "json_schema", name: "answer", schema: { type: "object" }, strict: true }, + }; + + const optedOut = adapter.buildRequest({ ...parsed(), options }); + const supportedSibling = adapter.buildRequest({ ...parsed(), modelId: "supported-model", options }); + const colonVariant = adapter.buildRequest({ ...parsed(), modelId: "test-model:structured", options }); + + expect(bodyOf(optedOut).response_format).toBeUndefined(); + expect(bodyOf(supportedSibling).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }); + expect(bodyOf(colonVariant).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }); + }); +}); From 93e11a43f3dbd746f5288e27068dd2dcf00ea24c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:57:28 +0200 Subject: [PATCH 11/14] test(openai-chat): pin privacy-safe invalid field shapes --- ...chat-invalid-tool-call-diagnostics.test.ts | 76 ++++++++++++++++--- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts index dcff2b98c2..39367c4603 100644 --- a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts +++ b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts @@ -1,5 +1,7 @@ -import { expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; +import { resetDebugSettingsForTests } from "../src/lib/debug-settings"; import type { AdapterEvent, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -13,17 +15,23 @@ const provider: OcxProviderConfig = { authMode: "key", }; +const previousDebug = process.env.OCX_DEBUG; + +afterEach(() => { + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); + if (previousDebug === undefined) delete process.env.OCX_DEBUG; + else process.env.OCX_DEBUG = previousDebug; +}); + async function collect(stream: AsyncGenerator): Promise { const events: AdapterEvent[] = []; for await (const event of stream) events.push(event); return events; } -test("object-valued streamed function.name is a 502 with value-free compatibility detail", async () => { - const privateNameValue = "must-not-reach-diagnostics"; - const privateArguments = "must-not-reach-diagnostics-either"; - const adapter = createOpenAIChatAdapter(provider); - const response = new Response( +function malformedNameResponse(name: unknown, args = "{}"): Response { + return new Response( `data: ${JSON.stringify({ choices: [{ index: 0, @@ -31,17 +39,22 @@ test("object-valued streamed function.name is a 502 with value-free compatibilit tool_calls: [{ index: 0, id: "call_1", - function: { - name: { privateNameValue }, - arguments: JSON.stringify({ value: privateArguments }), - }, + function: { name, arguments: args }, }], }, }], })}\n\n`, ); +} - const events = await collect(adapter.parseStream(response)); +test("object-valued streamed function.name is a 502 with value-free compatibility detail", async () => { + const privateNameValue = "must-not-reach-diagnostics"; + const privateArguments = "must-not-reach-diagnostics-either"; + const adapter = createOpenAIChatAdapter(provider); + const events = await collect(adapter.parseStream(malformedNameResponse( + { privateNameValue }, + JSON.stringify({ value: privateArguments }), + ))); expect(events).toEqual([{ type: "error", @@ -54,3 +67,44 @@ test("object-valued streamed function.name is a 502 with value-free compatibilit expect(serialized).not.toContain(privateArguments); expect(serialized).not.toContain("call_1"); }); + +test("provider debug fingerprints only allowlisted object structure for an invalid field", async () => { + process.env.OCX_DEBUG = "1"; + const privateNestedValue = "must-not-reach-provider-debug"; + const privateUnknownKey = "must-not-reach-provider-debug-as-a-key"; + const privateUnknownValue = "must-not-reach-provider-debug-as-a-value"; + const privateArguments = "must-not-reach-provider-debug-arguments"; + const adapter = createOpenAIChatAdapter(provider); + + const events = await collect(adapter.parseStream(malformedNameResponse({ + name: privateNestedValue, + [privateUnknownKey]: privateUnknownValue, + }, JSON.stringify({ privateArguments })))); + + expect(events[0]?.message).not.toContain("fieldShape"); + const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); + expect(lines).toContain('[ocx:openai-chat:invalid-tool-calls]'); + expect(lines).toContain('"reason":"tool_call_function_name_invalid"'); + expect(lines).toContain('"fieldShape":{"kind":"object"'); + expect(lines).toContain('"knownKeys":["name"]'); + expect(lines).toContain('"knownFieldTypes":{"name":"string"}'); + expect(lines).toContain('"hasUnknownKeys":true'); + expect(lines).not.toContain(privateNestedValue); + expect(lines).not.toContain(privateUnknownKey); + expect(lines).not.toContain(privateUnknownValue); + expect(lines).not.toContain(privateArguments); + expect(lines).not.toContain("call_1"); +}); + +test("provider debug fingerprints invalid arrays by length without retaining elements", async () => { + process.env.OCX_DEBUG = "1"; + const privateElement = "must-not-reach-array-fingerprint"; + const adapter = createOpenAIChatAdapter(provider); + + const events = await collect(adapter.parseStream(malformedNameResponse([privateElement, { privateElement }]))); + + expect(events[0]?.message).not.toContain("fieldShape"); + const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); + expect(lines).toContain('"fieldShape":{"kind":"array","length":2}'); + expect(lines).not.toContain(privateElement); +}); From ddee95b09a4073c755ce970dcc45a12e72bbca42 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:08:04 +0200 Subject: [PATCH 12/14] test(openai-chat): cover buffered invalid field fingerprints --- ...chat-invalid-tool-call-diagnostics.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts index 39367c4603..a8f9bd02cb 100644 --- a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts +++ b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts @@ -108,3 +108,45 @@ test("provider debug fingerprints invalid arrays by length without retaining ele expect(lines).toContain('"fieldShape":{"kind":"array","length":2}'); expect(lines).not.toContain(privateElement); }); + +test("provider debug fingerprints buffered invalid fields with the same privacy boundary", async () => { + process.env.OCX_DEBUG = "1"; + const privateNestedValue = "must-not-reach-buffered-provider-debug"; + const privateUnknownKey = "must-not-reach-buffered-provider-debug-as-key"; + const privateUnknownValue = "must-not-reach-buffered-provider-debug-as-value"; + const privateArguments = "must-not-reach-buffered-provider-debug-arguments"; + const adapter = createOpenAIChatAdapter(provider); + + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ + message: { + role: "assistant", + tool_calls: [{ + id: "call_buffered_private", + type: "function", + function: { + name: { + name: privateNestedValue, + [privateUnknownKey]: privateUnknownValue, + }, + arguments: JSON.stringify({ privateArguments }), + }, + }], + }, + }], + }))); + + expect(events[0]?.message).not.toContain("fieldShape"); + const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); + expect(lines).toContain('"mode":"response"'); + expect(lines).toContain('"reason":"tool_call_function_name_invalid"'); + expect(lines).toContain('"fieldShape":{"kind":"object"'); + expect(lines).toContain('"knownKeys":["name"]'); + expect(lines).toContain('"knownFieldTypes":{"name":"string"}'); + expect(lines).toContain('"hasUnknownKeys":true'); + expect(lines).not.toContain(privateNestedValue); + expect(lines).not.toContain(privateUnknownKey); + expect(lines).not.toContain(privateUnknownValue); + expect(lines).not.toContain(privateArguments); + expect(lines).not.toContain("call_buffered_private"); +}); From 8f8b39eb8347657a7249e41afadbfc787c52cdc6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:14:37 +0200 Subject: [PATCH 13/14] fix(openai-chat): fingerprint invalid fields safely --- src/adapters/openai-chat.ts | 89 +++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 41a0747055..8275a5f3de 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -343,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; + 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(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 = {}; + 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. @@ -360,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 }; } @@ -436,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 { @@ -1194,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); From 65dac3fe651ea091f4ccf924b6691929002789fc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:15:33 +0200 Subject: [PATCH 14/14] test(openai-chat): narrow invalid diagnostic errors --- ...chat-invalid-tool-call-diagnostics.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts index a8f9bd02cb..4a8704ab48 100644 --- a/tests/openai-chat-invalid-tool-call-diagnostics.test.ts +++ b/tests/openai-chat-invalid-tool-call-diagnostics.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests } from "../src/lib/debug-settings"; @@ -17,6 +17,12 @@ const provider: OcxProviderConfig = { const previousDebug = process.env.OCX_DEBUG; +beforeEach(() => { + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); + delete process.env.OCX_DEBUG; +}); + afterEach(() => { resetDebugSettingsForTests(); resetDebugLogBufferForTests(); @@ -30,6 +36,13 @@ async function collect(stream: AsyncGenerator): Promise { + const event = events.find((candidate): candidate is Extract => + candidate.type === "error"); + if (!event) throw new Error("expected an error event"); + return event; +} + function malformedNameResponse(name: unknown, args = "{}"): Response { return new Response( `data: ${JSON.stringify({ @@ -66,6 +79,7 @@ test("object-valued streamed function.name is a 502 with value-free compatibilit expect(serialized).not.toContain(privateNameValue); expect(serialized).not.toContain(privateArguments); expect(serialized).not.toContain("call_1"); + expect(getDebugLogEntries()).toEqual([]); }); test("provider debug fingerprints only allowlisted object structure for an invalid field", async () => { @@ -81,7 +95,7 @@ test("provider debug fingerprints only allowlisted object structure for an inval [privateUnknownKey]: privateUnknownValue, }, JSON.stringify({ privateArguments })))); - expect(events[0]?.message).not.toContain("fieldShape"); + expect(requireErrorEvent(events).message).not.toContain("fieldShape"); const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); expect(lines).toContain('[ocx:openai-chat:invalid-tool-calls]'); expect(lines).toContain('"reason":"tool_call_function_name_invalid"'); @@ -103,7 +117,7 @@ test("provider debug fingerprints invalid arrays by length without retaining ele const events = await collect(adapter.parseStream(malformedNameResponse([privateElement, { privateElement }]))); - expect(events[0]?.message).not.toContain("fieldShape"); + expect(requireErrorEvent(events).message).not.toContain("fieldShape"); const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); expect(lines).toContain('"fieldShape":{"kind":"array","length":2}'); expect(lines).not.toContain(privateElement); @@ -136,7 +150,7 @@ test("provider debug fingerprints buffered invalid fields with the same privacy }], }))); - expect(events[0]?.message).not.toContain("fieldShape"); + expect(requireErrorEvent(events).message).not.toContain("fieldShape"); const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); expect(lines).toContain('"mode":"response"'); expect(lines).toContain('"reason":"tool_call_function_name_invalid"');