From 922dc5ed7dfed51ab9e9e611368c8bc3d2eddf35 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 14:01:57 +0800 Subject: [PATCH 01/14] feat(adapters): annotate present-but-empty tool outputs (DeepSeek default) --- src/adapters/openai-chat.ts | 18 ++- src/adapters/openai-responses.ts | 38 +++++ src/config.ts | 1 + src/providers/derive.ts | 6 + src/providers/registry.ts | 9 ++ src/router.ts | 4 + src/types/provider.ts | 8 + tests/empty-tool-output-annotation.test.ts | 163 +++++++++++++++++++++ 8 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 tests/empty-tool-output-annotation.test.ts diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 07f2b3f7c2..436e990156 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -589,14 +589,24 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { } } +/** Wire text used when a present-but-empty tool result must stay visible to the model. */ +const EMPTY_TOOL_OUTPUT_ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64. */ -function toolResultTextForWire(content: string | OcxContentPart[]): string { - if (typeof content === "string") return content; +function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string { + // An empty content array is a present-but-empty result; `contentPartsToText` would + // otherwise fall back to the "[image]" marker and hide the emptiness from the model. + if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION; + if (typeof content === "string") { + if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION; + return content; + } const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); if (text) { const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length; @@ -784,7 +794,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon out.push({ role: "tool", tool_call_id: toolCallId, - content: toolResultTextForWire(msg.content), + content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), }); pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); pendingToolCalls.splice(matchIdx, 1); @@ -829,7 +839,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon out.push({ role: "tool", tool_call_id: toolCallId, - content: toolResultTextForWire(msg.content), + content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), }); pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); flushToolResultImages(); diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 2ef888ac1d..440fc25c19 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -821,6 +821,41 @@ function toolOutputText(output: unknown): string { }).filter(Boolean).join("\n"); } +/** Wire text used when a present-but-empty tool output must stay visible to the model. */ +const EMPTY_TOOL_OUTPUT_ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + +/** True when a Responses tool output item is present but carries no usable content. */ +function isToolOutputEmpty(output: unknown): boolean { + if (typeof output === "string") return output.trim() === ""; + if (Array.isArray(output)) { + return output.every(part => { + if (!isPlainObject(part)) return true; + if (typeof part.text === "string" && part.text.trim() !== "") return false; + if (part.type === "refusal" && typeof part.refusal === "string" && part.refusal.trim() !== "") return false; + return true; + }); + } + return output === undefined || output === null; +} + +/** + * Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic + * missing-result placeholders are non-empty and pass through untouched. No-op unless + * the provider opts in (`annotateEmptyToolOutputs`). + */ +function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown { + if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item; + if (!isToolOutputEmpty(item.output)) return item; + changed = true; + return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION }; + }); + return changed ? { ...body, input } : body; +} + /** * Repair a forward-mode input array whose continuation context was lost. When the replay * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped @@ -2011,6 +2046,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (forward || stateless) { outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); } + if (provider.annotateEmptyToolOutputs === true) { + outBody = annotateEmptyResponsesToolOutputs(outBody, true); + } if (provider.requiresAdjacentResponsesToolResults === true) { outBody = normalizeResponsesToolResultAdjacency(outBody); } diff --git a/src/config.ts b/src/config.ts index 7f373e57c5..41ff4d202a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -501,6 +501,7 @@ const providerConfigSchema = z.object({ responsesPath: z.string().min(1).optional(), statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), + annotateEmptyToolOutputs: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index de5c5821e1..8c753d1496 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -254,6 +254,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults } : {}), + ...(entry.annotateEmptyToolOutputs !== undefined + ? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs } + : {}), ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), @@ -501,6 +504,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) { prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults; } + if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) { + prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs; + } // Registry-only metadata (never seeded into saved config): backfill straight from // the entry so an explicit user value stays distinguishable from the default. if (prov.fastWire === undefined && entry.fastWire !== undefined) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index ad4f809448..2c0497113d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -215,6 +215,11 @@ export interface ProviderRegistryEntry { * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * When enabled, tool results that are present but empty are annotated on the wire. + * Seeded/backfilled like other fixed wire capabilities. + */ + annotateEmptyToolOutputs?: boolean; /** * Registry default for the provider's `service_tier` support; see * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never @@ -1784,6 +1789,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // context splits a call from its result (#1292); parallel calls remain one // reasoning-bearing assistant batch rather than being split per pair (#1477). requiresAdjacentResponsesToolResults: true, + // DeepSeek exec tool results can be present-but-empty (a script that ran without + // calling text(...)); annotate them so routed models do not silently accept an + // empty result or re-issue the same call. + annotateEmptyToolOutputs: true, /* [Decision Log] - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. diff --git a/src/router.ts b/src/router.ts index 81c86b618b..55a83e1a69 100644 --- a/src/router.ts +++ b/src/router.ts @@ -354,6 +354,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } : {}), + ...(provider.annotateEmptyToolOutputs === undefined + && registryEntry.annotateEmptyToolOutputs !== undefined + ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs } + : {}), ...(provider.fastWire === undefined && registryEntry.fastWire !== undefined ? { fastWire: cloneFastWire(registryEntry.fastWire), diff --git a/src/types/provider.ts b/src/types/provider.ts index fa5da0415a..6e7f4aa1da 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -206,6 +206,14 @@ export interface OcxProviderConfig { * preserved after it, and parallel calls stay together with the reasoning turn that produced them. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * When enabled, a tool result that is present but empty (no usable text or content + * part) is rewritten to an explicit annotation before it reaches the upstream wire, + * so models do not silently accept an empty result or re-issue the same call + * Non-empty results and missing-result placeholders stay byte-identical. + * Seeded true for DeepSeek; absent keeps legacy behavior for every other provider. + */ + annotateEmptyToolOutputs?: boolean; /** * Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire. * This pure tri-state feeds catalog publication, routing eligibility, compatibility diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts new file mode 100644 index 0000000000..0c0cdffaa4 --- /dev/null +++ b/tests/empty-tool-output-annotation.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + +describe("annotateEmptyToolOutputs (DeepSeek default ON)", () => { + test("deepseek registry seed defaults the option to true", () => { + const seed = providerConfigSeed(getProviderRegistryEntry("deepseek")!); + expect(seed.annotateEmptyToolOutputs).toBe(true); + }); + + test("non-deepseek registry seed leaves the option unset", () => { + const seed = providerConfigSeed(getProviderRegistryEntry("cerebras")!); + expect(seed.annotateEmptyToolOutputs).toBeUndefined(); + }); +}); + +describe("openai-chat empty tool output annotation", () => { + function wire(provider: OcxProviderConfig, messages: OcxMessage[]): Array> { + const parsed: OcxParsedRequest = { + modelId: "test-model", + context: { messages }, + stream: false, + options: {}, + }; + const req = createOpenAIChatAdapter(provider).buildRequest(parsed) as { body: string }; + return (JSON.parse(req.body) as { messages: Array> }).messages; + } + + function toolCallTurn(emptyResult: string | unknown[]): OcxMessage[] { + return [ + { role: "user", content: "hi", timestamp: 0 }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "exec_command", arguments: {} }], + timestamp: 0, + }, + { role: "toolResult", toolCallId: "call_1", toolName: "exec_command", content: emptyResult as never, isError: false, timestamp: 0 }, + ]; + } + + const providerWithFlag: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + annotateEmptyToolOutputs: true, + }; + + const providerWithoutFlag: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + }; + + test("empty string result is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn("")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("whitespace-only result is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn(" \n ")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("empty content array is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn([])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("non-empty result stays byte-identical when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn("real output")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe("real output"); + }); + + test("empty result stays empty when the option is absent (legacy behavior)", () => { + const messages = wire(providerWithoutFlag, toolCallTurn("")); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(""); + }); +}); + +describe("openai-responses empty tool output annotation", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + async function drive(config: OcxConfig, input: unknown[]): Promise<{ body: Record }> { + const requests: Array<{ body: Record }> = []; + globalThis.fetch = (async (inputUrl: RequestInfo | URL, init?: RequestInit) => { + requests.push({ body: JSON.parse(String(init?.body ?? "{}")) as Record }); + return Response.json({ id: "resp_test", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test/model", input, stream: true }), + }), + config, + { model: "", provider: "" }, + ); + return requests[0] ?? { body: {} }; + } + + function responsesConfig(annotate: boolean | undefined): OcxConfig { + return { + port: 0, + defaultProvider: "test", + providers: { + test: { + adapter: "openai-responses", + baseUrl: "https://example.test", + apiKey: "sk-test", + authMode: "key", + ...(annotate === undefined ? {} : { annotateEmptyToolOutputs: annotate }), + }, + }, + } as unknown as OcxConfig; + } + + test("empty function_call_output is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_1", output: "" }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call_output", call_id: "call_1" }); + expect(input[0].output).toBe(ANNOTATION); + }); + + test("empty custom_tool_call_output is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "custom_tool_call_output", call_id: "call_2", output: " " }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); + + test("non-empty output stays byte-identical when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_3", output: "ok" }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe("ok"); + }); + + test("empty output stays empty when the option is absent", async () => { + const { body } = await drive(responsesConfig(undefined), [ + { type: "function_call_output", call_id: "call_4", output: "" }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(""); + }); +}); From f6f4e320904127f61672a742880a1d57a973e195 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 14:42:47 +0800 Subject: [PATCH 02/14] fix(adapters): annotate whitespace-only text-part arrays on the chat wire --- src/adapters/openai-chat.ts | 6 ++++++ tests/empty-tool-output-annotation.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 436e990156..23229750fe 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -608,6 +608,12 @@ function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty return content; } const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); + // A whitespace-only text-part array is the array twin of a blank string: the + // Responses adapter treats it as empty, so the Chat adapter must annotate it too + // instead of forwarding whitespace the model silently accepts (CodeRabbit). + if (annotateEmpty && content.every(part => part.type === "text") && text.trim() === "") { + return EMPTY_TOOL_OUTPUT_ANNOTATION; + } if (text) { const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length; return `${text}${"[image]".repeat(untransportableImages)}`; diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 0c0cdffaa4..3c57254b55 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -77,6 +77,27 @@ describe("openai-chat empty tool output annotation", () => { expect(tool?.content).toBe(ANNOTATION); }); + test("whitespace-only text-part array is annotated when enabled", () => { + const messages = wire(providerWithFlag, toolCallTurn([{ type: "text", text: " \n " }])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("whitespace-only text-part array stays unchanged when the option is absent", () => { + const messages = wire(providerWithoutFlag, toolCallTurn([{ type: "text", text: " " }])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(" "); + }); + + test("image parts with whitespace text are not treated as empty", () => { + const messages = wire(providerWithFlag, toolCallTurn([ + { type: "text", text: " " }, + { type: "image", imageUrl: "data:image/png;base64,AAAA" }, + ])); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).not.toBe(ANNOTATION); + }); + test("non-empty result stays byte-identical when enabled", () => { const messages = wire(providerWithFlag, toolCallTurn("real output")); const tool = messages.find(m => m.role === "tool"); From 9900e74516098e3f95d51933ed16b927f996c4e6 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 14:50:21 +0800 Subject: [PATCH 03/14] test(adapters): cover orphaned empty tool results on the chat wire --- tests/empty-tool-output-annotation.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 3c57254b55..99fa8091e9 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -109,8 +109,25 @@ describe("openai-chat empty tool output annotation", () => { const tool = messages.find(m => m.role === "tool"); expect(tool?.content).toBe(""); }); + + test("orphaned empty result is annotated when enabled", () => { + const messages = wire(providerWithFlag, [ + { role: "toolResult", toolCallId: "call_orphan", toolName: "exec_command", content: "", isError: false, timestamp: 0 }, + ]); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(ANNOTATION); + }); + + test("orphaned empty result stays empty when the option is absent", () => { + const messages = wire(providerWithoutFlag, [ + { role: "toolResult", toolCallId: "call_orphan", toolName: "exec_command", content: "", isError: false, timestamp: 0 }, + ]); + const tool = messages.find(m => m.role === "tool"); + expect(tool?.content).toBe(""); + }); }); + describe("openai-responses empty tool output annotation", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); From 8f766a23b075fa32b4cae350198655603d63c0ef Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 22 Aug 2026 15:15:25 +0800 Subject: [PATCH 04/14] docs(types): end the annotateEmptyToolOutputs comment sentence --- src/types/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/provider.ts b/src/types/provider.ts index 6e7f4aa1da..d64e7feb54 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -209,7 +209,7 @@ export interface OcxProviderConfig { /** * When enabled, a tool result that is present but empty (no usable text or content * part) is rewritten to an explicit annotation before it reaches the upstream wire, - * so models do not silently accept an empty result or re-issue the same call + * so models do not silently accept an empty result or re-issue the same call. * Non-empty results and missing-result placeholders stay byte-identical. * Seeded true for DeepSeek; absent keeps legacy behavior for every other provider. */ From 4126c43d104274148f314f516246492039850d66 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 23 Aug 2026 08:22:45 +0800 Subject: [PATCH 05/14] fix(adapters): never annotate non-text Responses tool outputs; shared emptiness contract; auth-cors boolean guard --- src/adapters/empty-tool-output-annotation.ts | 40 ++++++++++++ src/adapters/openai-chat.ts | 14 ++--- src/adapters/openai-responses.ts | 16 ++--- src/server/auth-cors.ts | 3 + tests/empty-tool-output-annotation.test.ts | 64 ++++++++++++++++++++ 5 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 src/adapters/empty-tool-output-annotation.ts diff --git a/src/adapters/empty-tool-output-annotation.ts b/src/adapters/empty-tool-output-annotation.ts new file mode 100644 index 0000000000..1798c26b7b --- /dev/null +++ b/src/adapters/empty-tool-output-annotation.ts @@ -0,0 +1,40 @@ +/** + * Shared wire text and emptiness contract for present-but-empty tool outputs. + * + * Both the OpenAI Chat and Responses adapters use this module so the two wires + * cannot drift again: only a pure text/refusal part array whose joined content + * trims empty is "present but empty". Image, file, encrypted-content and any + * other non-text part is real output and is never replaced by the annotation. + */ + +/** Wire text used when a present-but-empty tool output must stay visible to the model. */ +export const EMPTY_TOOL_OUTPUT_ANNOTATION = + "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * True when every part is text/refusal and the joined text/refusal content trims + * empty. An empty array is the array twin of a blank string. Any image, file, + * encrypted-content or other non-text part makes the array non-empty so the + * model still receives the real payload. + */ +export function isWhitespaceOnlyTextPartArray(parts: readonly unknown[]): boolean { + if (parts.length === 0) return true; + let joined = ""; + for (const part of parts) { + if (!isPlainObject(part)) return false; + if (part.type === "text" && typeof part.text === "string") { + joined += part.text; + continue; + } + if (part.type === "refusal" && typeof part.refusal === "string") { + joined += part.refusal; + continue; + } + return false; + } + return joined.trim() === ""; +} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 23229750fe..997593d891 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -8,6 +8,7 @@ import { isDebugEnabled } from "../lib/debug-settings"; import { isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { contentPartsToText } from "./image"; +import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { identifyRoutedModel } from "./identity"; import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; @@ -589,10 +590,6 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { } } -/** Wire text used when a present-but-empty tool result must stay visible to the model. */ -const EMPTY_TOOL_OUTPUT_ANNOTATION = - "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; - /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of @@ -608,10 +605,11 @@ function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty return content; } const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); - // A whitespace-only text-part array is the array twin of a blank string: the - // Responses adapter treats it as empty, so the Chat adapter must annotate it too - // instead of forwarding whitespace the model silently accepts (CodeRabbit). - if (annotateEmpty && content.every(part => part.type === "text") && text.trim() === "") { + // A whitespace-only text-part array is the array twin of a blank string; the + // shared emptiness contract (same module as the Responses adapter) annotates it + // instead of forwarding whitespace the model silently accepts. Image parts and + // any other non-text part keep the array non-empty. + if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) { return EMPTY_TOOL_OUTPUT_ANNOTATION; } if (text) { diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 440fc25c19..cef51abcbf 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -21,6 +21,7 @@ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-com import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; +import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { isXaiSchemaTarget, normalizeXaiToolParameters, @@ -821,20 +822,15 @@ function toolOutputText(output: unknown): string { }).filter(Boolean).join("\n"); } -/** Wire text used when a present-but-empty tool output must stay visible to the model. */ -const EMPTY_TOOL_OUTPUT_ANNOTATION = - "[ocx] empty tool output: the tool ran but produced no stdout or return value; do not treat this as success, failure, or user-provided input."; - /** True when a Responses tool output item is present but carries no usable content. */ function isToolOutputEmpty(output: unknown): boolean { if (typeof output === "string") return output.trim() === ""; if (Array.isArray(output)) { - return output.every(part => { - if (!isPlainObject(part)) return true; - if (typeof part.text === "string" && part.text.trim() !== "") return false; - if (part.type === "refusal" && typeof part.refusal === "string" && part.refusal.trim() !== "") return false; - return true; - }); + // Mirror the Chat wire rule through the shared contract: only a pure + // text/refusal part array whose joined content trims empty is annotated. + // input_image, encrypted_content, input_file and any other non-text part is + // real output and must never be replaced. + return isWhitespaceOnlyTextPartArray(output); } return output === undefined || output === null; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 9e5bcbb3d3..1aae06bb30 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -649,6 +649,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (raw.xaiResponsesXSearch !== undefined && typeof raw.xaiResponsesXSearch !== "boolean") { return `provider ${name} xaiResponsesXSearch must be a boolean`; } + if (raw.annotateEmptyToolOutputs !== undefined && typeof raw.annotateEmptyToolOutputs !== "boolean") { + return `provider ${name} annotateEmptyToolOutputs must be a boolean`; + } const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens"); if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`; const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens"); diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 99fa8091e9..d6ba7311fb 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -198,4 +198,68 @@ describe("openai-responses empty tool output annotation", () => { const input = body.input as Array>; expect(input[0].output).toBe(""); }); + + test("whitespace-only text-part array is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_5", output: [{ type: "text", text: " \n " }] }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); + + test("image-only output is never replaced when enabled", async () => { + const output = [{ type: "input_image", image_url: { url: "data:image/png;base64,AAAA" } }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_6", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("image plus whitespace text is never replaced when enabled", async () => { + const output = [ + { type: "text", text: " " }, + { type: "input_image", image_url: { url: "data:image/png;base64,AAAA" } }, + ]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_7", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("encrypted_content output is never replaced when enabled", async () => { + const output = [{ type: "encrypted_content", data: "opaque-blob" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_8", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("file_id-only output is never replaced when enabled", async () => { + const output = [{ type: "input_file", file_id: "file_123" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "custom_tool_call_output", call_id: "call_9", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("non-empty refusal output is never replaced when enabled", async () => { + const output = [{ type: "refusal", refusal: "I cannot do that" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_10", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + + test("whitespace-only refusal output is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_11", output: [{ type: "refusal", refusal: " " }] }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); }); From cadb99c57fbc680ea41b25e63e6781910f5f5955 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 24 Aug 2026 14:18:06 +0800 Subject: [PATCH 06/14] fix(adapters): treat Responses input_text/output_text parts as wire text for the emptiness contract --- src/adapters/empty-tool-output-annotation.ts | 5 +++- tests/empty-tool-output-annotation.test.ts | 26 +++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/adapters/empty-tool-output-annotation.ts b/src/adapters/empty-tool-output-annotation.ts index 1798c26b7b..9eb287e396 100644 --- a/src/adapters/empty-tool-output-annotation.ts +++ b/src/adapters/empty-tool-output-annotation.ts @@ -15,6 +15,9 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Part types that carry wire text on either adapter (Chat uses `text`, Responses uses `input_text`/`output_text`). */ +const TEXT_PART_TYPES = new Set(["text", "input_text", "output_text"]); + /** * True when every part is text/refusal and the joined text/refusal content trims * empty. An empty array is the array twin of a blank string. Any image, file, @@ -26,7 +29,7 @@ export function isWhitespaceOnlyTextPartArray(parts: readonly unknown[]): boolea let joined = ""; for (const part of parts) { if (!isPlainObject(part)) return false; - if (part.type === "text" && typeof part.text === "string") { + if (typeof part.type === "string" && TEXT_PART_TYPES.has(part.type) && typeof part.text === "string") { joined += part.text; continue; } diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index d6ba7311fb..5bfadecf25 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -97,7 +97,6 @@ describe("openai-chat empty tool output annotation", () => { const tool = messages.find(m => m.role === "tool"); expect(tool?.content).not.toBe(ANNOTATION); }); - test("non-empty result stays byte-identical when enabled", () => { const messages = wire(providerWithFlag, toolCallTurn("real output")); const tool = messages.find(m => m.role === "tool"); @@ -207,6 +206,31 @@ describe("openai-responses empty tool output annotation", () => { expect(input[0].output).toBe(ANNOTATION); }); + test("whitespace-only input_text part array is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_5b", output: [{ type: "input_text", text: " \n" }] }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); + + test("whitespace-only output_text part array is annotated when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "custom_tool_call_output", call_id: "call_5c", output: [{ type: "output_text", text: "\t" }] }, + ]); + const input = body.input as Array>; + expect(input[0].output).toBe(ANNOTATION); + }); + + test("non-empty input_text part array is never replaced when enabled", async () => { + const output = [{ type: "input_text", text: "real tool text" }]; + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_5d", output }, + ]); + const input = body.input as Array>; + expect(input[0].output).toEqual(output); + }); + test("image-only output is never replaced when enabled", async () => { const output = [{ type: "input_image", image_url: { url: "data:image/png;base64,AAAA" } }]; const { body } = await drive(responsesConfig(true), [ From 0045408683b974aa0d6a0f725bfdb9939b830608 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 24 Aug 2026 15:11:44 +0800 Subject: [PATCH 07/14] fix(adapters): annotate empty tool outputs before stateless orphan repair --- src/adapters/openai-responses.ts | 6 +++--- ...ses-stateless-dangling-call-repair.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index cef51abcbf..639ca396d3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2039,12 +2039,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // pair from its own storage either, so it needs the same repair the forward // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. - if (forward || stateless) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); - } if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } + if (forward || stateless) { + outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); + } if (provider.requiresAdjacentResponsesToolResults === true) { outBody = normalizeResponsesToolResultAdjacency(outBody); } diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts index ede2a8d119..e6ed8a265c 100644 --- a/tests/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -184,4 +184,23 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { expect(input[0]).toMatchObject({ type: "message", role: "user" }); expect(JSON.stringify(input[0])).toContain("orphan result"); }); + + test("annotates an empty orphan output before repairing it into a user message (regression)", async () => { + const { body } = await drive([ + { type: "function_call_output", call_id: "call_unknown", output: "" }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "message", role: "user" }); + expect(JSON.stringify(input[0])).toContain("[ocx] empty tool output"); + }); + + test("preserves synthetic missing-result placeholders when annotation is enabled (regression)", async () => { + const { body } = await drive([ + { type: "function_call", id: "fc_dangling", call_id: "call_dangling", name: "exec_command", arguments: "{}" }, + ]); + const input = body.input as Array>; + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_dangling" }); + expect(String((input[1] as { output: unknown }).output)).toContain("no tool result was recorded"); + expect(JSON.stringify(input[1])).not.toContain("[ocx] empty tool output"); + }); }); From d089c94635fc8dfbe983fa4448abd07266e0dcbc Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 24 Aug 2026 21:15:04 +0800 Subject: [PATCH 08/14] fix(adapters): never annotate missing/null tool outputs; pin DeepSeek backfill --- src/adapters/openai-responses.ts | 6 +++- src/types/provider.ts | 2 ++ tests/empty-tool-output-annotation.test.ts | 35 +++++++++++++++++++ tests/management-provider-validation.test.ts | 15 ++++++++ ...ses-stateless-dangling-call-repair.test.ts | 20 +++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 639ca396d3..0412650a80 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -832,7 +832,11 @@ function isToolOutputEmpty(output: unknown): boolean { // real output and must never be replaced. return isWhitespaceOnlyTextPartArray(output); } - return output === undefined || output === null; + // A missing or null `output` is not a present-but-empty result: it is an + // incomplete payload. Leave it untouched so the upstream contract fails + // closed, and the orphan repair can surface it honestly instead of claiming + // the tool ran with no output. + return false; } /** diff --git a/src/types/provider.ts b/src/types/provider.ts index d64e7feb54..a0b8679929 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -212,6 +212,8 @@ export interface OcxProviderConfig { * so models do not silently accept an empty result or re-issue the same call. * Non-empty results and missing-result placeholders stay byte-identical. * Seeded true for DeepSeek; absent keeps legacy behavior for every other provider. + * Only the OpenAI-family adapters (openai-chat / openai-responses) read this option; + * other adapters ignore it. */ annotateEmptyToolOutputs?: boolean; /** diff --git a/tests/empty-tool-output-annotation.test.ts b/tests/empty-tool-output-annotation.test.ts index 5bfadecf25..2d97e15730 100644 --- a/tests/empty-tool-output-annotation.test.ts +++ b/tests/empty-tool-output-annotation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routedProviderConfig } from "../src/router"; import { handleResponses } from "../src/server/responses/core"; import type { OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -20,6 +21,24 @@ describe("annotateEmptyToolOutputs (DeepSeek default ON)", () => { }); }); +describe("annotateEmptyToolOutputs runtime backfill (DeepSeek)", () => { + const deepseekSavedConfig: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-test", + authMode: "key", + }; + + test("a saved deepseek provider without the flag is backfilled true at routing", () => { + expect(routedProviderConfig("deepseek", deepseekSavedConfig).annotateEmptyToolOutputs).toBe(true); + }); + + test("an explicit false survives routing and is never overridden", () => { + const routed = routedProviderConfig("deepseek", { ...deepseekSavedConfig, annotateEmptyToolOutputs: false }); + expect(routed.annotateEmptyToolOutputs).toBe(false); + }); +}); + describe("openai-chat empty tool output annotation", () => { function wire(provider: OcxProviderConfig, messages: OcxMessage[]): Array> { const parsed: OcxParsedRequest = { @@ -286,4 +305,20 @@ describe("openai-responses empty tool output annotation", () => { const input = body.input as Array>; expect(input[0].output).toBe(ANNOTATION); }); + + test("null output is never replaced when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "function_call_output", call_id: "call_12", output: null }, + ]); + const input = body.input as Array>; + expect(input[0]).toEqual({ type: "function_call_output", call_id: "call_12", output: null }); + }); + + test("missing output key is never replaced when enabled", async () => { + const { body } = await drive(responsesConfig(true), [ + { type: "custom_tool_call_output", call_id: "call_13" }, + ]); + const input = body.input as Array>; + expect(input[0]).toEqual({ type: "custom_tool_call_output", call_id: "call_13" }); + }); }); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index c839c5b6fa..4d1af7c295 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -324,6 +324,21 @@ describe("provider management validation", () => { .toEqual(["deepseek-v4-flash", "other-model"]); }); + test("provider management validates annotateEmptyToolOutputs as boolean", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + annotateEmptyToolOutputs: true, + }; + expect(providerManagementConfigError("relay", provider)).toBeNull(); + for (const annotateEmptyToolOutputs of ["yes", 42, {}, []]) { + expect(providerManagementConfigError("relay", { + ...provider, + annotateEmptyToolOutputs, + })).toContain("annotateEmptyToolOutputs"); + } + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat", diff --git a/tests/responses-stateless-dangling-call-repair.test.ts b/tests/responses-stateless-dangling-call-repair.test.ts index e6ed8a265c..bad70e0935 100644 --- a/tests/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses-stateless-dangling-call-repair.test.ts @@ -194,6 +194,26 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { expect(JSON.stringify(input[0])).toContain("[ocx] empty tool output"); }); + test("never claims a tool ran when an orphan output is null (regression)", async () => { + const { body } = await drive([ + { type: "function_call_output", call_id: "call_unknown", output: null }, + ]); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "message", role: "user" }); + expect(JSON.stringify(input[0])).not.toContain("[ocx] empty tool output"); + expect(JSON.stringify(input[0])).not.toContain("no tool result was recorded"); + }); + + test("leaves a paired null output untouched when annotation is enabled (regression)", async () => { + const { body } = await drive([ + { type: "function_call", id: "fc_1", call_id: "call_null", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_null", output: null }, + ]); + const input = body.input as Array>; + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_null", output: null }); + expect(JSON.stringify(body)).not.toContain("[ocx] empty tool output"); + }); + test("preserves synthetic missing-result placeholders when annotation is enabled (regression)", async () => { const { body } = await drive([ { type: "function_call", id: "fc_dangling", call_id: "call_dangling", name: "exec_command", arguments: "{}" }, From 9c7c0c1a8a02f4cac13febb1ec6351b9ac7f6566 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Tue, 25 Aug 2026 13:00:36 +0800 Subject: [PATCH 09/14] fix(management): validate annotateEmptyToolOutputs off the auth surface --- src/config/provider-validation.ts | 10 ++++++ src/server/auth-cors.ts | 3 -- src/server/management/provider-routes.ts | 16 ++++++--- tests/management-provider-validation.test.ts | 34 ++++++++++++++++++-- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 8a068d271b..fa9da90484 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -123,6 +123,16 @@ export function booleanRecordConfigError(value: unknown, field: string): string return null; } +/** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */ +export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null { + const raw = provider as Record | null | undefined; + const value = raw === null || raw === undefined ? undefined : raw.annotateEmptyToolOutputs; + if (value !== undefined && typeof value !== "boolean") { + return `provider ${name} annotateEmptyToolOutputs must be a boolean`; + } + return null; +} + export function reasoningSummaryDeliveryRecordConfigError( value: unknown, supportsReasoningSummaries: unknown, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 1aae06bb30..9e5bcbb3d3 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -649,9 +649,6 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (raw.xaiResponsesXSearch !== undefined && typeof raw.xaiResponsesXSearch !== "boolean") { return `provider ${name} xaiResponsesXSearch must be a boolean`; } - if (raw.annotateEmptyToolOutputs !== undefined && typeof raw.annotateEmptyToolOutputs !== "boolean") { - return `provider ${name} annotateEmptyToolOutputs must be a boolean`; - } const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens"); if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`; const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens"); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index d542306885..63235efcfb 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -77,6 +77,7 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { providerServiceTierConfigError } from "./provider-capability-config"; +import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -412,7 +413,8 @@ function canonicalOpenAiBudgetPatchError( } const applied = applyProviderPatchFields("openai", seed, rawBody, keys, config); if ("error" in applied) return applied.error; - return providerManagementConfigError("openai", applied.next); + return providerManagementConfigError("openai", applied.next) + ?? providerEmptyToolOutputConfigError("openai", applied.next); } export async function handleProviderRoutes(ctx: ManagementContext): Promise { @@ -484,7 +486,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { baseUrl: "https://relay.example/v1", annotateEmptyToolOutputs: true, }; - expect(providerManagementConfigError("relay", provider)).toBeNull(); + expect(providerEmptyToolOutputConfigError("relay", provider)).toBeNull(); for (const annotateEmptyToolOutputs of ["yes", 42, {}, []]) { - expect(providerManagementConfigError("relay", { + expect(providerEmptyToolOutputConfigError("relay", { ...provider, annotateEmptyToolOutputs, })).toContain("annotateEmptyToolOutputs"); } }); + test("provider POST rejects a non-boolean annotateEmptyToolOutputs at the management boundary", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + annotateEmptyToolOutputs: "yes", + }, + }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: expect.stringContaining("annotateEmptyToolOutputs"), + }); + } finally { + await server.stop(true); + } + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat", From 5017f8f38a1e7cb58c54cd3bc25f9c007cc0833a Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Tue, 25 Aug 2026 13:34:21 +0800 Subject: [PATCH 10/14] fix(management): redact provider name in annotation error; support PATCH field --- src/config/provider-validation.ts | 3 +- src/server/management/provider-routes.ts | 11 ++++ tests/management-provider-validation.test.ts | 54 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index fa9da90484..6508a745f8 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -1,4 +1,5 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { redactSecretString } from "../lib/redact"; import { modelRecordValue } from "../reasoning-effort"; import { isWirePinnedModel, @@ -128,7 +129,7 @@ export function providerEmptyToolOutputConfigError(name: string, provider: unkno const raw = provider as Record | null | undefined; const value = raw === null || raw === undefined ? undefined : raw.annotateEmptyToolOutputs; if (value !== undefined && typeof value !== "boolean") { - return `provider ${name} annotateEmptyToolOutputs must be a boolean`; + return `provider ${JSON.stringify(redactSecretString(name))} annotateEmptyToolOutputs must be a boolean`; } return null; } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 63235efcfb..f2a489bbae 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -188,6 +188,17 @@ function applyProviderPatchFields( next.liveModels = rawBody.liveModels; touched = true; } + if (Object.hasOwn(rawBody, "annotateEmptyToolOutputs")) { + const value = rawBody.annotateEmptyToolOutputs; + if (value === null) { + delete next.annotateEmptyToolOutputs; + } else if (typeof value === "boolean") { + next.annotateEmptyToolOutputs = value; + } else { + return { error: "annotateEmptyToolOutputs must be a boolean or null" }; + } + touched = true; + } if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) { if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" }; if (typeof rawBody.xaiResponsesOptIn !== "boolean") { diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index f0629e4abf..fa07d7dfff 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -369,6 +369,60 @@ describe("provider management validation", () => { } }); + test("provider PATCH sets, clears, and rejects annotateEmptyToolOutputs", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { adapter: "openai-chat", baseUrl: "https://relay.example/v1" }, + }), + }); + expect(create.status).toBe(200); + + const reject = await fetch(new URL("/api/providers?name=relay", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotateEmptyToolOutputs: "yes" }), + }); + expect(reject.status).toBe(400); + expect(await reject.json()).toMatchObject({ error: "annotateEmptyToolOutputs must be a boolean or null" }); + + const enable = await fetch(new URL("/api/providers?name=relay", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotateEmptyToolOutputs: true }), + }); + expect(enable.status).toBe(200); + expect(loadConfig().providers.relay?.annotateEmptyToolOutputs).toBe(true); + + const disable = await fetch(new URL("/api/providers?name=relay", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotateEmptyToolOutputs: false }), + }); + expect(disable.status).toBe(200); + expect(loadConfig().providers.relay?.annotateEmptyToolOutputs).toBe(false); + + const clear = await fetch(new URL("/api/providers?name=relay", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotateEmptyToolOutputs: null }), + }); + expect(clear.status).toBe(200); + expect(loadConfig().providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + } finally { + await server.stop(true); + } + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat", From d98c9fbf59d086aa0e1112b32f91f5f69e500d64 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 30 Aug 2026 12:57:16 +0800 Subject: [PATCH 11/14] fix(regression): discard whitespace-only proxy; drop retired ollama cloud model --- src/config.ts | 2 +- src/providers/registry.ts | 4 ++-- tests/codex-catalog.test.ts | 8 ++++++++ tests/proxy-env.test.ts | 8 ++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 41ff4d202a..8f7b2cae70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3162,7 +3162,7 @@ export function applyProxyEnv(config: OcxConfig): void { // malformed values with a privacy-safe warning instead: they cannot express a routing // intent, and refusing to start is a worse answer than starting without them. const rawProxy = config.proxy; - const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; + const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy)?.trim() : undefined; if (!proxy) { if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); return; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 2c0497113d..6ef6dd7b4e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2573,8 +2573,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "ollama-native", authKind: "key", dashboardUrl: "https://ollama.com/settings/keys", - // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. - models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], + // Live IDs verified 2026-07-10; qwen3-coder:480b retired 2026-07-15 and was removed. + models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], defaultModel: "glm-5.3", // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 3235b993b9..492009020f 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -6029,3 +6029,11 @@ describe("Codex 0.151 catalog contract fields", () => { }); }); }); + +describe("ollama cloud fallback catalog", () => { + test("no longer exposes the retired qwen3-coder:480b", () => { + const ollama = PROVIDER_REGISTRY.find(e => e.id === "ollama-cloud"); + expect(ollama).toBeDefined(); + expect(ollama!.models).not.toContain("qwen3-coder:480b"); + }); +}); diff --git a/tests/proxy-env.test.ts b/tests/proxy-env.test.ts index bf7ccaa467..819fb9d796 100644 --- a/tests/proxy-env.test.ts +++ b/tests/proxy-env.test.ts @@ -74,6 +74,14 @@ describe("applyProxyEnv with values the schema does not constrain", () => { expect(process.env.HTTPS_PROXY).toBeUndefined(); }); + // The discard warning is once-per-process and an earlier test already consumes it, so + // assert the observable contract only: whitespace must not leak into the proxy env vars. + test("a whitespace-only proxy is discarded and keeps direct egress", () => { + applyProxyEnv(configWithRawProxy(" ")); + expect(process.env.HTTP_PROXY).toBeUndefined(); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + }); + test("a non-string noProxy does not throw and keeps loopback exclusions", () => { expect(() => applyProxyEnv(configWithRawProxy("http://proxy.corp:8080", 42))).not.toThrow(); expect(process.env.NO_PROXY).toBe("localhost,127.0.0.1,::1,[::1]"); From 6db068f02d8ca2324913f8bd7a11dd5bcfb2cd56 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 30 Aug 2026 13:10:49 +0800 Subject: [PATCH 12/14] fix(management): preserve explicit annotateEmptyToolOutputs opt-out on provider overwrite --- src/server/management/provider-routes.ts | 7 +++ tests/management-provider-validation.test.ts | 46 ++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index f2a489bbae..f59ee63d21 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -600,6 +600,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("provider POST overwrite preserves an explicit annotateEmptyToolOutputs false when the payload omits it", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "deepseek", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-test", + annotateEmptyToolOutputs: false, + }, + }), + }); + expect(create.status).toBe(200); + expect(loadConfig().providers.deepseek?.annotateEmptyToolOutputs).toBe(false); + + // The dashboard add/edit form does not send this field, and registry enrichment + // backfills DeepSeek to true — an unrelated overwrite must not silently flip the + // operator's explicit opt-out back on. + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "deepseek", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-test", + }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers.deepseek?.annotateEmptyToolOutputs).toBe(false); + } finally { + await server.stop(true); + } + }); + // #1409: the add/edit form's payload type has no member for contextWindow or // modelContextWindows, so an overwrite arrives without them. Registry enrichment then fills // the absent fields from the seed and the stored row loses the user's values — for From 95d7f5443c93518a5a5eeb11f3fcfc7e50d9ac25 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 30 Aug 2026 13:13:57 +0800 Subject: [PATCH 13/14] fix(config): canonicalize POST null annotation; resolve whitespace-padded proxy refs --- src/config.ts | 5 +++- src/server/management/provider-routes.ts | 9 +++++++ tests/management-provider-validation.test.ts | 27 ++++++++++++++++++++ tests/proxy-env.test.ts | 7 +++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 8f7b2cae70..53fbc8912e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3162,7 +3162,10 @@ export function applyProxyEnv(config: OcxConfig): void { // malformed values with a privacy-safe warning instead: they cannot express a routing // intent, and refusing to start is a worse answer than starting without them. const rawProxy = config.proxy; - const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy)?.trim() : undefined; + // Trim before resolution so a whitespace-padded environment reference (e.g. + // " ${VAR} ") still resolves, then trim the resolved value so a whitespace-only + // env value falls back to direct egress instead of leaking into HTTP(S)_PROXY. + const proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy.trim())?.trim() : undefined; if (!proxy) { if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); return; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index f59ee63d21..4322a8026e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -557,6 +557,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + if (Object.hasOwn(providerPayload, "annotateEmptyToolOutputs") && providerPayload.annotateEmptyToolOutputs === null) { + delete providerPayload.annotateEmptyToolOutputs; + } + } const providerError = providerManagementConfigError(name, body.provider) ?? providerEmptyToolOutputConfigError(name, body.provider); if (providerError) return jsonResponse({ error: providerError }, 400); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index e2d4d0b6d6..c4aae2c379 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -369,6 +369,33 @@ describe("provider management validation", () => { } }); + test("provider POST canonicalizes annotateEmptyToolOutputs null to an omitted field", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + annotateEmptyToolOutputs: null, + }, + }), + }); + expect(create.status).toBe(200); + expect(loadConfig().providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + } finally { + await server.stop(true); + } + }); + test("provider PATCH sets, clears, and rejects annotateEmptyToolOutputs", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/proxy-env.test.ts b/tests/proxy-env.test.ts index 819fb9d796..77a23ba530 100644 --- a/tests/proxy-env.test.ts +++ b/tests/proxy-env.test.ts @@ -82,6 +82,13 @@ describe("applyProxyEnv with values the schema does not constrain", () => { expect(process.env.HTTPS_PROXY).toBeUndefined(); }); + test("a whitespace-padded environment reference still resolves before assignment", () => { + process.env.OCX_TEST_PROXY_REF = "http://proxy.corp:8080"; + applyProxyEnv(configWithRawProxy(" ${OCX_TEST_PROXY_REF} ")); + expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); + expect(process.env.HTTPS_PROXY).toBe("http://proxy.corp:8080"); + }); + test("a non-string noProxy does not throw and keeps loopback exclusions", () => { expect(() => applyProxyEnv(configWithRawProxy("http://proxy.corp:8080", 42))).not.toThrow(); expect(process.env.NO_PROXY).toBe("localhost,127.0.0.1,::1,[::1]"); From 182d881db37a9d054385ec5b5f0d7f30afd9a721 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 30 Aug 2026 14:02:39 +0800 Subject: [PATCH 14/14] fix(management): treat POST null annotation as an explicit clear --- src/server/management/provider-routes.ts | 9 +- tests/management-provider-validation.test.ts | 101 ++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 4322a8026e..63e49198e9 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -557,6 +557,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise, "annotateEmptyToolOutputs"); // Canonicalize a POST null exactly like PATCH's "clear" before validation: the // dashboard's form clears the opt-out with null, and rejecting it here would // force a two-step edit for something PATCH already accepts as a delete. @@ -609,7 +617,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }), }); expect(create.status).toBe(200); - expect(loadConfig().providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + const persisted = loadConfig(); + expect(persisted.providers.relay).toBeDefined(); + expect(persisted.providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(onDisk.providers.relay).toBeDefined(); + expect(onDisk.providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + } finally { + await server.stop(true); + } + }); + + test("provider POST null clears an existing explicit annotateEmptyToolOutputs override", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + annotateEmptyToolOutputs: true, + }, + }), + }); + expect(create.status).toBe(200); + expect(loadConfig().providers.relay?.annotateEmptyToolOutputs).toBe(true); + + // null means "clear the override", not "leave it untouched". + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + annotateEmptyToolOutputs: null, + }, + }), + }); + expect(overwrite.status).toBe(200); + const persisted = loadConfig(); + expect(persisted.providers.relay).toBeDefined(); + expect(persisted.providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(onDisk.providers.relay).not.toHaveProperty("annotateEmptyToolOutputs"); + } finally { + await server.stop(true); + } + }); + + test("provider POST null on DeepSeek restores the registry default after enrichment", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "deepseek", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-test", + annotateEmptyToolOutputs: false, + }, + }), + }); + expect(create.status).toBe(200); + expect(loadConfig().providers.deepseek?.annotateEmptyToolOutputs).toBe(false); + + // Clearing the opt-out must let registry enrichment re-enable annotation. + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "deepseek", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-test", + annotateEmptyToolOutputs: null, + }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers.deepseek?.annotateEmptyToolOutputs).toBe(true); } finally { await server.stop(true); }