diff --git a/src/lib/codex/provider-overrides.ts b/src/lib/codex/provider-overrides.ts index 315583e12..0bf26508c 100644 --- a/src/lib/codex/provider-overrides.ts +++ b/src/lib/codex/provider-overrides.ts @@ -51,14 +51,89 @@ function normalizeImageGenerationPreference( return value === "true"; } -function isImageGenerationTool(value: unknown): value is Record { - return isPlainObject(value) && value.type === "image_generation"; +function toImageGenerationToolReference(value: unknown): Record | null { + if (!isPlainObject(value)) { + return null; + } + if (value.type === "image_generation") { + return { type: "image_generation" }; + } + if (value.type === "namespace") { + if (value.name === "image_gen") { + return { type: "namespace", name: "image_gen" }; + } + if (value.namespace === "image_gen") { + return { type: "namespace", namespace: "image_gen" }; + } + } + return null; +} + +function isImageGenerationTool(value: unknown): boolean { + return toImageGenerationToolReference(value) !== null; } function hasImageGenerationTool(value: unknown): boolean { return Array.isArray(value) && value.some((tool) => isImageGenerationTool(tool)); } +function hasInputImageGenerationTool(value: unknown): boolean { + return ( + Array.isArray(value) && + value.some( + (item) => + isPlainObject(item) && + item.type === "additional_tools" && + hasImageGenerationTool(item.tools) + ) + ); +} + +function findImageGenerationToolReference( + request: Record +): Record | null { + if (Array.isArray(request.tools)) { + for (const tool of request.tools) { + const reference = toImageGenerationToolReference(tool); + if (reference) { + return reference; + } + } + } + + if (!Array.isArray(request.input)) { + return null; + } + for (const item of request.input) { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + continue; + } + for (const tool of item.tools) { + const reference = toImageGenerationToolReference(tool); + if (reference) { + return reference; + } + } + } + return null; +} + +function hasAvailableTool(request: Record): boolean { + if (Array.isArray(request.tools) && request.tools.length > 0) { + return true; + } + return ( + Array.isArray(request.input) && + request.input.some( + (item) => + isPlainObject(item) && + item.type === "additional_tools" && + Array.isArray(item.tools) && + item.tools.length > 0 + ) + ); +} + function applyImageGenerationToolPreference( request: Record, ensureCloned: () => Record, @@ -70,7 +145,10 @@ function applyImageGenerationToolPreference( const existingTools = Array.isArray(request.tools) ? request.tools : null; if (enabled) { - if (existingTools?.some((tool) => isImageGenerationTool(tool))) { + if ( + existingTools?.some((tool) => isImageGenerationTool(tool)) || + hasInputImageGenerationTool(request.input) + ) { return; } const target = ensureCloned(); @@ -97,16 +175,75 @@ function applyImageGenerationToolPreference( } } +function applyInputImageGenerationToolPreference( + request: Record, + ensureCloned: () => Record, + enabled: boolean | null +): void { + if (enabled !== false || !Array.isArray(request.input)) { + return; + } + + const nextInput: unknown[] = []; + let changed = false; + for (const item of request.input) { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { + nextInput.push(item); + continue; + } + + const nextTools = item.tools.filter((tool) => !isImageGenerationTool(tool)); + if (nextTools.length === item.tools.length) { + nextInput.push(item); + continue; + } + + changed = true; + if (nextTools.length > 0) { + nextInput.push({ ...item, tools: nextTools }); + } + } + + if (changed) { + ensureCloned().input = nextInput; + } +} + +function isImageGenerationToolChoice(value: unknown): boolean { + if (typeof value === "string") { + return value === "image_generation"; + } + if (!isPlainObject(value)) { + return false; + } + if (isImageGenerationTool(value)) { + return true; + } + return isImageGenerationTool(value.tool); +} + function summarizeImageGenerationToolChoice(value: unknown): string | null { if (typeof value === "string") { return value; } - if (!isPlainObject(value) || typeof value.type !== "string") { + if (!isPlainObject(value)) { + return null; + } + if (isImageGenerationTool(value.tool)) { + return "tool:image_generation"; + } + if (typeof value.type !== "string") { return null; } if (value.type === "image_generation") { return "image_generation"; } + if ( + value.type === "namespace" && + (value.name === "image_gen" || value.namespace === "image_gen") + ) { + return "namespace:image_gen"; + } if (value.type !== "allowed_tools") { return value.type; } @@ -127,7 +264,7 @@ function applyImageGenerationToolChoicePreference( ensureCloned: () => Record, enabled: boolean | null, context: { - hadImageGenerationTool: boolean; + imageGenerationToolReference: Record | null; hasAvailableTools: boolean; } ): void { @@ -146,7 +283,10 @@ function applyImageGenerationToolChoicePreference( const target = ensureCloned(); target.tool_choice = { ...toolChoice, - tools: [...toolChoice.tools, { type: "image_generation" }], + tools: [ + ...toolChoice.tools, + context.imageGenerationToolReference ?? { type: "image_generation" }, + ], }; return; } @@ -163,18 +303,18 @@ function applyImageGenerationToolChoicePreference( return; } - if (!isPlainObject(toolChoice)) { - return; - } - - if (toolChoice.type === "image_generation") { + if (isImageGenerationToolChoice(toolChoice)) { const target = ensureCloned(); // 只剩非图像工具时改成 none,避免回退到 auto 后放宽客户端原本的工具限制。 target.tool_choice = "none"; return; } - if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) { + if ( + !isPlainObject(toolChoice) || + toolChoice.type !== "allowed_tools" || + !Array.isArray(toolChoice.tools) + ) { return; } @@ -200,7 +340,7 @@ function applyImageGenerationToolChoicePreference( * - 偏好值为 null/undefined/"inherit" 表示“遵循客户端” * - 覆写仅影响以下字段: * - parallel_tool_calls - * - tools / tool_choice 中与 image_generation 相关的能力声明 + * - tools / input.additional_tools / tool_choice 中与 image_generation 相关的能力声明 * - reasoning.effort / reasoning.summary * - service_tier * - text.verbosity @@ -231,12 +371,14 @@ export function applyCodexProviderOverrides( const imageGeneration = normalizeImageGenerationPreference( provider.codexImageGenerationPreference ); - const hadImageGenerationTool = hasImageGenerationTool(output.tools); - applyImageGenerationToolPreference(output, ensureCloned, imageGeneration); - applyImageGenerationToolChoicePreference(output, ensureCloned, imageGeneration, { - hadImageGenerationTool, - hasAvailableTools: Array.isArray(output.tools) && output.tools.length > 0, - }); + if (imageGeneration !== null) { + applyImageGenerationToolPreference(output, ensureCloned, imageGeneration); + applyInputImageGenerationToolPreference(output, ensureCloned, imageGeneration); + applyImageGenerationToolChoicePreference(output, ensureCloned, imageGeneration, { + imageGenerationToolReference: findImageGenerationToolReference(output), + hasAvailableTools: hasAvailableTool(output), + }); + } const reasoningEffort = normalizeStringPreference(provider.codexReasoningEffortPreference); const reasoningSummary = normalizeStringPreference(provider.codexReasoningSummaryPreference); @@ -289,8 +431,6 @@ export function applyCodexProviderOverridesWithAudit( const serviceTier = normalizeStringPreference(provider.codexServiceTierPreference); const beforeServiceTier = toAuditValue(request.service_tier); - const beforeImageGeneration = hasImageGenerationTool(request.tools); - const hit = parallelToolCalls !== null || imageGeneration !== null || @@ -304,6 +444,8 @@ export function applyCodexProviderOverridesWithAudit( return { request, audit: null }; } + const beforeImageGeneration = hasImageGenerationTool(request.tools); + const beforeInputImageGeneration = hasInputImageGenerationTool(request.input); const beforeParallelToolCalls = toAuditValue(request.parallel_tool_calls); const beforeReasoning = isPlainObject(request.reasoning) ? request.reasoning : null; const beforeReasoningEffort = toAuditValue(beforeReasoning?.effort); @@ -318,6 +460,7 @@ export function applyCodexProviderOverridesWithAudit( const afterParallelToolCalls = toAuditValue(nextRequest.parallel_tool_calls); const afterImageGeneration = hasImageGenerationTool(nextRequest.tools); + const afterInputImageGeneration = hasInputImageGenerationTool(nextRequest.input); const afterReasoning = isPlainObject(nextRequest.reasoning) ? nextRequest.reasoning : null; const afterReasoningEffort = toAuditValue(afterReasoning?.effort); const afterReasoningSummary = toAuditValue(afterReasoning?.summary); @@ -338,6 +481,12 @@ export function applyCodexProviderOverridesWithAudit( after: afterImageGeneration, changed: !Object.is(beforeImageGeneration, afterImageGeneration), }, + { + path: "input.additional_tools.image_generation", + before: beforeInputImageGeneration, + after: afterInputImageGeneration, + changed: !Object.is(beforeInputImageGeneration, afterInputImageGeneration), + }, { path: "reasoning.effort", before: beforeReasoningEffort, diff --git a/tests/api/api-openapi-spec.test.ts b/tests/api/api-openapi-spec.test.ts index a3b32ab0b..de0564424 100644 --- a/tests/api/api-openapi-spec.test.ts +++ b/tests/api/api-openapi-spec.test.ts @@ -232,9 +232,10 @@ describe("OpenAPI 规范验证", () => { expect(publicStatusOperation?.responses?.["200"]).toBeDefined(); expect(publicStatusOperation?.responses?.["400"]).toBeDefined(); expect(publicStatusOperation?.responses?.["503"]).toBeDefined(); - expect(publicStatusOperation?.parameters).toBeDefined(); + const publicStatusParameters = publicStatusOperation?.parameters; + expect(publicStatusParameters).toBeDefined(); - const parameterNames = (publicStatusOperation?.parameters as Array<{ name?: string }>).map( + const parameterNames = (publicStatusParameters as Array<{ name?: string }>).map( (parameter) => parameter.name ); expect(parameterNames).toEqual( diff --git a/tests/unit/proxy/codex-provider-overrides.test.ts b/tests/unit/proxy/codex-provider-overrides.test.ts index 18bbbc341..f015a7dde 100644 --- a/tests/unit/proxy/codex-provider-overrides.test.ts +++ b/tests/unit/proxy/codex-provider-overrides.test.ts @@ -74,6 +74,39 @@ describe("Codex 供应商级参数覆写", () => { expect(input).toEqual(snapshot); }); + it("当 image_generation 偏好为 inherit 时,不应扫描工具声明", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "inherit", + }; + let toolsReadCount = 0; + let inputReadCount = 0; + const input: Record = { model: "gpt-5.5" }; + Object.defineProperties(input, { + tools: { + enumerable: true, + get: () => { + toolsReadCount += 1; + return [{ type: "namespace", name: "image_gen" }]; + }, + }, + input: { + enumerable: true, + get: () => { + inputReadCount += 1; + return []; + }, + }, + }); + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request).toBe(input); + expect(result.audit).toBeNull(); + expect(toolsReadCount).toBe(0); + expect(inputReadCount).toBe(0); + }); + it("当强制 parallel_tool_calls 时,应覆写为对应布尔值", () => { const provider = { providerType: "codex", @@ -161,6 +194,101 @@ describe("Codex 供应商级参数覆写", () => { }); }); + it("当强制 image_generation=true 且请求已声明 image_gen namespace 时,不应重复注入图像工具", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "true", + }; + + const input: Record = { + model: "gpt-5.5", + input: [], + tools: [ + { + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen" }], + }, + ], + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output).toBe(input); + expect(output.tools).toEqual(input.tools); + }); + + it("当强制 image_generation=true 且 Responses Lite 已声明 image_gen namespace 时,不应重复注入顶层工具", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "true", + }; + + const input: Record = { + model: "gpt-5.5", + input: [ + { + type: "additional_tools", + tools: [{ type: "namespace", name: "image_gen" }], + }, + ], + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output).toBe(input); + expect(output.tools).toBeUndefined(); + }); + + it.each([ + [ + "顶层 tools", + { + tools: [{ type: "namespace", name: "image_gen" }], + input: [], + }, + ], + [ + "Responses Lite", + { + input: [ + { + type: "additional_tools", + tools: [{ type: "namespace", name: "image_gen" }], + }, + ], + }, + ], + ])("当强制 image_generation=true 且%s已声明 namespace 时,allowed_tools 应使用同形引用", (_, request) => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "true", + }; + const input: Record = { + model: "gpt-5.5", + ...request, + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "lookup_weather" }], + }, + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output.tool_choice).toEqual({ + type: "allowed_tools", + mode: "auto", + tools: [ + { type: "function", name: "lookup_weather" }, + { type: "namespace", name: "image_gen" }, + ], + }); + expect(output.tools).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "image_generation" })]) + ); + }); + it("当强制 image_generation=false 时,应从 tools 中移除对应工具", () => { const provider = { providerType: "codex", @@ -182,6 +310,219 @@ describe("Codex 供应商级参数覆写", () => { ]); }); + it("当强制 image_generation=false 时,应完整移除 namespace 和 Responses Lite 图片工具声明", () => { + const provider = { + id: 90, + name: "codex-provider", + providerType: "codex", + codexImageGenerationPreference: "false", + }; + + const imageNamespace = { + type: "namespace", + name: "image_gen", + tools: [{ type: "function", name: "imagegen" }], + }; + const codeNamespace = { + type: "namespace", + name: "code_tools", + tools: [{ type: "function", name: "run" }], + }; + const input: Record = { + model: "gpt-5.5", + tools: [{ type: "function", name: "shell" }, imageNamespace, codeNamespace], + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + { type: "additional_tools", tools: [imageNamespace] }, + { type: "additional_tools", tools: [imageNamespace, codeNamespace] }, + ], + tool_choice: { type: "namespace", name: "image_gen" }, + }; + const snapshot = structuredClone(input); + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request.tools).toEqual([{ type: "function", name: "shell" }, codeNamespace]); + expect(result.request.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + { type: "additional_tools", tools: [codeNamespace] }, + ]); + expect(result.request.tool_choice).toBe("none"); + expect(input).toEqual(snapshot); + + expect( + result.audit?.changes.find((change) => change.path === "tools.image_generation") + ).toEqual({ + path: "tools.image_generation", + before: true, + after: false, + changed: true, + }); + expect( + result.audit?.changes.find( + (change) => change.path === "input.additional_tools.image_generation" + ) + ).toEqual({ + path: "input.additional_tools.image_generation", + before: true, + after: false, + changed: true, + }); + }); + + it("当强制 image_generation=false 且只有 Responses Lite 图片工具时,应独立清理并正确审计", () => { + const provider = { + id: 90, + name: "codex-provider", + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const input: Record = { + model: "gpt-5.5", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + { type: "image_generation_call", id: "ig_123", result: "preserve" }, + { + type: "additional_tools", + tools: [{ type: "namespace", name: "image_gen" }], + }, + ], + include: ["image_generation_call.results"], + tool_choice: "auto", + }; + const snapshot = structuredClone(input); + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + { type: "image_generation_call", id: "ig_123", result: "preserve" }, + ]); + expect(result.request.include).toEqual(["image_generation_call.results"]); + expect(result.request.tool_choice).toBeUndefined(); + expect(input).toEqual(snapshot); + expect( + result.audit?.changes.find((change) => change.path === "tools.image_generation") + ).toEqual({ + path: "tools.image_generation", + before: false, + after: false, + changed: false, + }); + expect( + result.audit?.changes.find( + (change) => change.path === "input.additional_tools.image_generation" + ) + ).toEqual({ + path: "input.additional_tools.image_generation", + before: true, + after: false, + changed: true, + }); + }); + + it.each([ + ["字符串", "image_generation", "image_generation"], + ["namespace 字段", { type: "namespace", namespace: "image_gen" }, "namespace:image_gen"], + ["嵌套 tool", { tool: { type: "namespace", name: "image_gen" } }, "tool:image_generation"], + ])("当强制 image_generation=false 时,应移除%s形式的 tool_choice 并记录审计", (_, toolChoice, auditValue) => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const input: Record = { + model: "gpt-5.5", + input: [], + tool_choice: toolChoice, + }; + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request.tool_choice).toBeUndefined(); + expect(result.audit?.changes.find((change) => change.path === "tool_choice")).toEqual({ + path: "tool_choice", + before: auditValue, + after: null, + changed: true, + }); + }); + + it("不应把名为 image_generation 的普通函数选择误判为内置图片工具", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const input: Record = { + model: "gpt-5.5", + input: [], + tools: [ + { + type: "function", + name: "image_generation", + parameters: { type: "object", properties: {} }, + }, + ], + tool_choice: { + type: "function", + function: { name: "image_generation" }, + }, + }; + + const result = applyCodexProviderOverridesWithAudit(provider as any, input); + + expect(result.request).toBe(input); + expect(result.request.tool_choice).toEqual(input.tool_choice); + expect(result.audit?.changes.find((change) => change.path === "tool_choice")).toEqual({ + path: "tool_choice", + before: "function", + after: "function", + changed: false, + }); + }); + + it("不应递归解析多层嵌套的 tool_choice.tool", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const nestedToolChoice: Record = { + tool: { tool: { type: "image_generation" } }, + }; + const input: Record = { + model: "gpt-5.5", + input: [], + tools: [{ type: "function", name: "lookup_weather" }], + tool_choice: nestedToolChoice, + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output).toBe(input); + expect(output.tool_choice).toBe(nestedToolChoice); + }); + + it("不应把其他 namespace 中名为 imagegen 的普通函数误判为图片工具", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + const input: Record = { + model: "gpt-5.5", + input: [], + tools: [ + { + type: "namespace", + name: "media_tools", + tools: [{ type: "function", name: "imagegen" }], + }, + ], + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output).toBe(input); + }); + it("当强制 image_generation=false 且 tool_choice 直接指向 image_generation 时,应移除该选择", () => { const provider = { providerType: "codex", @@ -266,6 +607,64 @@ describe("Codex 供应商级参数覆写", () => { }); }); + it("当强制 image_generation=false 且 allowed_tools 包含 image_gen namespace 时,应剔除该项", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + + const input: Record = { + model: "gpt-5.5", + input: [], + tools: [{ type: "function", name: "lookup_weather" }], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [ + { type: "namespace", name: "image_gen" }, + { type: "function", name: "lookup_weather" }, + ], + }, + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output.tool_choice).toEqual({ + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "lookup_weather" }], + }); + }); + + it("当 allowed_tools 使用 namespace 字段声明 image_gen 时,也应剔除该项", () => { + const provider = { + providerType: "codex", + codexImageGenerationPreference: "false", + }; + + const input: Record = { + model: "gpt-5.5", + input: [], + tools: [{ type: "function", name: "lookup_weather" }], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [ + { type: "namespace", namespace: "image_gen" }, + { type: "function", name: "lookup_weather" }, + ], + }, + }; + + const output = applyCodexProviderOverrides(provider as any, input); + + expect(output.tool_choice).toEqual({ + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "lookup_weather" }], + }); + }); + it("当强制 image_generation=false 且 allowed_tools 仅白名单图像工具时,应收口为 none", () => { const provider = { providerType: "codex",