diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 695b6a055..5065dca6d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,6 +1,6 @@ import type { AdapterRequest, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; -import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; +import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; @@ -612,12 +612,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record toolAllowedByChoice(t, allowed)) - : parsed.context.tools; + const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice)); if (tools.length === 0) return undefined; const xaiTarget = isXaiSchemaTarget(provider); const formatted = tools.flatMap(t => { diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 9de6d65f4..e09b23fe2 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -1,8 +1,6 @@ import { - isAllowedToolChoice, namespacedToolName, - toolAllowedByChoice, - toolChoiceAliases, + toolChoiceToolPredicate, type OcxRequestOptions, type OcxTool, type OcxProviderConfig, @@ -18,13 +16,6 @@ function uniqueNames(names: readonly string[]): string[] { return [...new Set(names.filter(name => name.trim().length > 0))]; } -function toolChoiceAllows(tool: Pick, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean { - if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true; - if (toolChoice === "none") return false; - if (isAllowedToolChoice(toolChoice)) return toolAllowedByChoice(tool, new Set(toolChoice.allowedTools)); - return toolChoiceAliases(tool).includes(toolChoice.name); -} - function isOpenAIOrChatGPTHost(hostname: string): boolean { return hostname === "openai.com" || hostname.endsWith(".openai.com") @@ -65,7 +56,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools( toWireName: (tool: Pick) => string = tool => namespacedToolName(tool.namespace, tool.name), ): string | undefined { const visibleNames = tools - ?.filter(tool => toolChoiceAllows(tool, toolChoice)) + ?.filter(toolChoiceToolPredicate(toolChoice)) .map(toWireName); return buildNonOpenAIToolCatalogNudgeFromNames(visibleNames); } diff --git a/src/images/loop.ts b/src/images/loop.ts index 5a2efd715..948f42252 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -15,7 +15,7 @@ import { existsSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { createAdapterEventQueue } from "../adapters/run-turn-queue"; import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; -import { namespacedToolName } from "../types"; +import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { clearableDeadline, idleDeadline } from "../lib/abort"; @@ -662,7 +662,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise(); const freeform = new Set(); const toolSearch = new Set(); + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); for (const t of parsed.context.tools ?? []) { + if (!toolAllowed(t)) continue; if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); if (t.freeform) freeform.add(t.name); if (t.toolSearch) toolSearch.add(t.name); diff --git a/src/images/plan.ts b/src/images/plan.ts index b706b5756..b8780d0fb 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -1,4 +1,5 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +import { toolChoiceToolPredicate } from "../types"; import type { ImageBridgePlan, VideoBridgePlan } from "./types"; import { resolveEnvValue } from "../config"; import { getProviderRegistryEntry } from "../providers/registry"; @@ -46,6 +47,12 @@ export async function planImageBridge( ): Promise { if (config.images?.bridgeEnabled !== true) return undefined; if (!parsed._imageGeneration) return undefined; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); + const toolNames = new Set( + [...parsed._imageGeneration.toolNames, IMAGE_GEN_TOOL_NAME] + .filter(name => toolAllowed({ name })), + ); + if (toolNames.size === 0) return undefined; // Don't intercept for OpenAI native passthrough const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); if (host === "api.openai.com") return undefined; @@ -57,9 +64,7 @@ export async function planImageBridge( const registryEntry = getProviderRegistryEntry("xai"); const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); // The synthetic tool injected into the conversation is named IMAGE_GEN_TOOL_NAME, - // which is what the model will actually call. Merge it with any original hosted tool names. - const toolNames = new Set(parsed._imageGeneration.toolNames); - toolNames.add(IMAGE_GEN_TOOL_NAME); + // which is what the model will actually call. toolNames also retains authorized hosted aliases. const original = parsed._imageGeneration.originalTool; const hostedSize = typeof original?.size === "string" ? original.size : undefined; const hostedQuality = typeof original?.quality === "string" ? original.quality : undefined; @@ -95,16 +100,6 @@ export async function planVideoBridge( routedProvider: OcxProviderConfig, ): Promise { if (config.images?.videoBridgeEnabled !== true) return undefined; - // Don't intercept for OpenAI native passthrough - const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); - if (host === "api.openai.com") return undefined; - const found = findXaiProvider(config); - if (!found) return undefined; - const token = resolveXaiImageApiKey(found.provider); - if (!token) return undefined; - // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. - const registryEntry = getProviderRegistryEntry("xai"); - const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); const toolNames = new Set(); toolNames.add(VIDEO_GEN_TOOL_NAME); // Collect any existing function tools whose name matches a video_gen alias @@ -118,6 +113,21 @@ export async function planVideoBridge( toolNames.add(fnName); } } + const toolAllowed = toolChoiceToolPredicate(parsed.options?.toolChoice); + for (const name of toolNames) { + if (!toolAllowed({ name })) toolNames.delete(name); + } + if (toolNames.size === 0) return undefined; + // Don't intercept for OpenAI native passthrough + const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); + if (host === "api.openai.com") return undefined; + const found = findXaiProvider(config); + if (!found) return undefined; + const token = resolveXaiImageApiKey(found.provider); + if (!token) return undefined; + // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. + const registryEntry = getProviderRegistryEntry("xai"); + const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs); const keepRaw = config.images?.artifactsKeepCount; const artifactsKeepCount = diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 459da3973..0b28267ce 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -26,7 +26,7 @@ import { } from "../../combos"; import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; -import { modelInList, namespacedToolName } from "../../types"; +import { modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types"; import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types"; import { forceRefreshOAuthAccessSnapshot, @@ -108,7 +108,10 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato const toolNsMap = new Map(); const freeformToolNames = new Set(); const toolSearchToolNames = new Set(); + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); for (const t of parsed.context.tools ?? []) { + // Upstream output is untrusted: only restore calls for tools the caller authorized. + if (!toolAllowed(t)) continue; if (t.namespace) { const wireName = namespacedToolName(t.namespace, t.name); budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" }); diff --git a/src/types.ts b/src/types.ts index b2b0c6d9e..fb45843a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -223,6 +223,19 @@ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is return typeof value === "object" && value !== null && "allowedTools" in value; } +/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ +export function toolChoiceToolPredicate( + choice: OcxToolChoice | undefined, +): (tool: Pick) => boolean { + if (!choice || choice === "auto" || choice === "required") return () => true; + if (choice === "none") return () => false; + if (isAllowedToolChoice(choice)) { + const allowed = new Set(choice.allowedTools); + return tool => toolAllowedByChoice(tool, allowed); + } + return tool => toolChoiceAliases(tool).includes(choice.name); +} + export interface OcxRequestOptions { maxOutputTokens?: number; temperature?: number; diff --git a/src/web-search/index.ts b/src/web-search/index.ts index ec460b89c..e902828bd 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -1,12 +1,13 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; -import { modelInList } from "../types"; +import { modelInList, toolChoiceToolPredicate } from "../types"; import type { SidecarSettings } from "./executor"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import { getAccountSet } from "../oauth/store"; import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout"; +import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; export { runWithWebSearch } from "./loop"; -export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; +export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor"; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; @@ -146,6 +147,7 @@ export function planWebSearch( openAiSidecar?: ResolvedOpenAiForwardSidecar, ): SidecarPlan | undefined { if (!parsed._webSearch || isPassthrough) return undefined; + if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; const cfg = config.webSearchSidecar ?? {}; if (cfg.enabled === false) return undefined; const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS; diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 5349b7863..e7cc19d88 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,6 +1,6 @@ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base"; import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; -import { namespacedToolName } from "../types"; +import { namespacedToolName, toolChoiceToolPredicate } from "../types"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; @@ -693,7 +693,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise(); const freeform = new Set(); const toolSearch = new Set(); + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); for (const t of parsed.context.tools ?? []) { + if (!toolAllowed(t)) continue; if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); if (t.freeform) freeform.add(t.name); if (t.toolSearch) toolSearch.add(t.name); diff --git a/tests/images/plan.test.ts b/tests/images/plan.test.ts index a25f21998..dfff91063 100644 --- a/tests/images/plan.test.ts +++ b/tests/images/plan.test.ts @@ -92,6 +92,34 @@ describe("planImageBridge", () => { expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1"); }); + test("tool_choice cannot arm an excluded image sidecar", async () => { + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, { bridgeEnabled: true }); + const parsed = makeParsed(true); + + parsed.options.toolChoice = "none"; + expect(await planImageBridge(cfg, parsed, routed)).toBeUndefined(); + parsed.options.toolChoice = { name: "read_file" }; + expect(await planImageBridge(cfg, parsed, routed)).toBeUndefined(); + parsed.options.toolChoice = { allowedTools: ["read_file"], mode: "required" }; + expect(await planImageBridge(cfg, parsed, routed)).toBeUndefined(); + + parsed.options.toolChoice = { name: "image_gen" }; + expect(await planImageBridge(cfg, parsed, routed)).toBeDefined(); + + parsed._imageGeneration?.toolNames.add("generate_image"); + parsed.options.toolChoice = { name: "image_gen" }; + const canonicalPlan = await planImageBridge(cfg, parsed, routed); + expect(canonicalPlan).toBeDefined(); + expect(canonicalPlan!.toolNames.has("image_gen")).toBe(true); + expect(canonicalPlan!.toolNames.has("generate_image")).toBe(false); + + parsed.options.toolChoice = { name: "generate_image" }; + const aliasPlan = await planImageBridge(cfg, parsed, routed); + expect(aliasPlan).toBeDefined(); + expect(aliasPlan!.toolNames.has("image_gen")).toBe(false); + expect(aliasPlan!.toolNames.has("generate_image")).toBe(true); + }); + test("xAI provider with OAuth only (no API key) → undefined (API-key-only bridge)", async () => { tokenResult = "fake-oauth-123"; const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai" } }, { bridgeEnabled: true }); diff --git a/tests/reasoning-effort.test.ts b/tests/reasoning-effort.test.ts index c8767c31f..93da648fd 100644 --- a/tests/reasoning-effort.test.ts +++ b/tests/reasoning-effort.test.ts @@ -329,7 +329,28 @@ describe("provider-specific reasoning effort mapping", () => { expect(body).not.toHaveProperty("tool_choice"); }); - test("OpenAI-compatible chat keeps tool_choice when tools are present", () => { + test("OpenAI-compatible chat omits tools and tool_choice when tool_choice is none", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.neuralwatt.com/v1", + }; + + const req = createOpenAIChatAdapter(provider).buildRequest({ + modelId: "glm-5.2", + context: { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [{ name: "read_secret", description: "Read", parameters: { type: "object" } }], + }, + stream: false, + options: { toolChoice: "none" }, + }); + const body = JSON.parse(req.body as string) as Record; + + expect(body).not.toHaveProperty("tools"); + expect(body).not.toHaveProperty("tool_choice"); + }); + + test("OpenAI-compatible chat advertises only the named tool when the provider downgrades the selector", () => { const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.moonshot.ai/v1", @@ -340,14 +361,20 @@ describe("provider-specific reasoning effort mapping", () => { modelId: "kimi-k2.7-code", context: { messages: [{ role: "user", content: "hello", timestamp: 0 }], - tools: [{ name: "run_tests", description: "Run tests", parameters: { type: "object", properties: {} } }], + tools: [ + { name: "run_tests", description: "Run tests", parameters: { type: "object", properties: {} } }, + { name: "read_secret", description: "Read", parameters: { type: "object", properties: {} } }, + ], }, stream: false, options: { toolChoice: { name: "run_tests" } }, }); - const body = JSON.parse(req.body as string) as Record; + const body = JSON.parse(req.body as string) as { + tools: Array<{ function: { name: string } }>; + tool_choice: string; + }; - expect(body).toHaveProperty("tools"); + expect(body.tools.map(tool => tool.function.name)).toEqual(["run_tests"]); expect(body.tool_choice).toBe("auto"); }); @@ -411,18 +438,30 @@ describe("provider-specific reasoning effort mapping", () => { modelId: "umans-kimi-k2.7", context: { messages: [{ role: "user", content: "run it", timestamp: 0 }], - tools: [{ - namespace: "functions", - name: "exec_command", - description: "Run a command", - parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, - }], + tools: [ + { + namespace: "functions", + name: "exec_command", + description: "Run a command", + parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, + }, + { + namespace: "mcp__secrets", + name: "read_secret", + description: "Read", + parameters: { type: "object" }, + }, + ], }, stream: false, options: { toolChoice: { name: "functions.exec_command" } }, }); - const body = JSON.parse(req.body as string) as { tool_choice: { function: { name: string } } }; + const body = JSON.parse(req.body as string) as { + tools: Array<{ function: { name: string } }>; + tool_choice: { function: { name: string } }; + }; + expect(body.tools.map(tool => tool.function.name)).toEqual(["functions__exec_command"]); expect(body.tool_choice.function.name).toBe("functions__exec_command"); }); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index a6d3da5ab..5b35e8b10 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { parseRequest } from "../src/responses/parser"; +import { buildToolBridgeMaps } from "../src/server/responses"; describe("Responses parser", () => { test("normalizes function tool schemas to an object root without corrupting valid schemas (#745)", () => { @@ -93,6 +94,57 @@ describe("Responses parser", () => { expect(parsed.options.toolChoice).toEqual({ allowedTools: ["web_search"], mode: "required" }); }); + test("restores only namespace, freeform, and tool-search calls allowed by tool_choice", () => { + const parsed = parseRequest({ + model: "umans/umans-kimi-k2.7", + input: "use the safe tool", + tools: [ + { + type: "namespace", + name: "mcp__tools", + tools: [ + { type: "function", name: "safe", parameters: { type: "object" } }, + { type: "function", name: "secret", parameters: { type: "object" } }, + ], + }, + { type: "custom", name: "apply_patch", description: "Apply" }, + { type: "tool_search" }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "mcp__tools.safe" }, + { type: "custom", name: "apply_patch" }, + ], + }, + }); + + let maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap]).toEqual([ + ["mcp__tools__safe", { namespace: "mcp__tools", name: "safe" }], + ]); + expect([...maps.freeformToolNames]).toEqual(["apply_patch"]); + expect([...maps.toolSearchToolNames]).toEqual([]); + + parsed.options.toolChoice = { allowedTools: ["mcp__tools__safe"], mode: "required" }; + maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap.keys()]).toEqual(["mcp__tools__safe"]); + expect([...maps.freeformToolNames]).toEqual([]); + + parsed.options.toolChoice = { name: "tool_search" }; + maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap]).toEqual([]); + expect([...maps.freeformToolNames]).toEqual([]); + expect([...maps.toolSearchToolNames]).toEqual(["tool_search"]); + + parsed.options.toolChoice = "none"; + maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap]).toEqual([]); + expect([...maps.freeformToolNames]).toEqual([]); + expect([...maps.toolSearchToolNames]).toEqual([]); + }); + test("maps hosted allowed_tools entries to their synthetic routed tool names", () => { const parsed = parseRequest({ model: "umans/umans-kimi-k2.7", diff --git a/tests/videos/plan-video.test.ts b/tests/videos/plan-video.test.ts index 84b6127b0..30b43d435 100644 --- a/tests/videos/plan-video.test.ts +++ b/tests/videos/plan-video.test.ts @@ -47,6 +47,31 @@ describe("planVideoBridge", () => { expect(plan!.toolNames.has(VIDEO_GEN_TOOL_NAME)).toBe(true); }); + test("tool_choice cannot arm an excluded video sidecar", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig); + const parsed = makeParsed(); + + parsed.options = { toolChoice: "none" }; + expect(await planVideoBridge(config, parsed, makeProvider("api.anthropic.com"))).toBeUndefined(); + parsed.options = { toolChoice: { name: "read_file" } }; + expect(await planVideoBridge(config, parsed, makeProvider("api.anthropic.com"))).toBeUndefined(); + parsed.options = { toolChoice: { allowedTools: ["read_file"], mode: "required" } }; + expect(await planVideoBridge(config, parsed, makeProvider("api.anthropic.com"))).toBeUndefined(); + + parsed.options = { toolChoice: { name: VIDEO_GEN_TOOL_NAME } }; + parsed.context.tools = [{ name: "generate_video", description: "Generate", parameters: {} }]; + const canonicalPlan = await planVideoBridge(config, parsed, makeProvider("api.anthropic.com")); + expect(canonicalPlan).toBeDefined(); + expect(canonicalPlan!.toolNames.has(VIDEO_GEN_TOOL_NAME)).toBe(true); + expect(canonicalPlan!.toolNames.has("generate_video")).toBe(false); + + parsed.options = { toolChoice: { name: "generate_video" } }; + const aliasPlan = await planVideoBridge(config, parsed, makeProvider("api.anthropic.com")); + expect(aliasPlan).toBeDefined(); + expect(aliasPlan!.toolNames.has(VIDEO_GEN_TOOL_NAME)).toBe(false); + expect(aliasPlan!.toolNames.has("generate_video")).toBe(true); + }); + test("returns undefined for OpenAI native passthrough", async () => { const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig); const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.openai.com")); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index b34a68ab6..8050f0c1f 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -315,6 +315,30 @@ describe("web-search sidecar planning", () => { expect(plan?.settings.model).toBe("gpt-5.6-luna"); }); + test("planWebSearch never arms a sidecar excluded by tool_choice", () => { + const parsed = parsedWithWebSearch(); + const sidecar = { + providerName: "openai" as const, + provider: forwardProvider, + accountMode: "direct" as const, + authContext: { kind: "main" as const, accountId: null }, + headers: new Headers({ authorization: "Bearer chatgpt" }), + }; + const plan = () => planWebSearch(config(), parsed, false, routedProvider, "model", sidecar); + + parsed.options.toolChoice = "none"; + expect(plan()).toBeUndefined(); + parsed.options.toolChoice = { name: "read_file" }; + expect(plan()).toBeUndefined(); + parsed.options.toolChoice = { allowedTools: ["read_file"], mode: "required" }; + expect(plan()).toBeUndefined(); + + parsed.options.toolChoice = { name: "web_search" }; + expect(plan()).toBeDefined(); + parsed.options.toolChoice = { allowedTools: ["web_search"], mode: "required" }; + expect(plan()).toBeDefined(); + }); + test("planWebSearch activates for pool-selected headers even when raw inbound auth would be main", () => { const parsed = parsedWithWebSearch(); const selectedHeaders = headersForCodexAuthContext(