From 1aba0e4b359d835012edd617589b3ac72cff529a Mon Sep 17 00:00:00 2001 From: Eleven-is-cool Date: Tue, 28 Jul 2026 20:23:13 +0800 Subject: [PATCH] fix: preserve hosted image tool preferences --- .../docs/ja/reference/configuration.md | 1 + .../docs/ko/reference/configuration.md | 1 + .../content/docs/reference/configuration.md | 1 + .../docs/ru/reference/configuration.md | 1 + .../docs/zh-cn/reference/configuration.md | 1 + src/adapters/openai-responses.ts | 136 ++++++- src/config.ts | 71 ++++ src/providers/openai-virtual-models.ts | 1 + src/responses/hosted-tool-policy.ts | 9 + src/server/auth-cors.ts | 8 + src/types.ts | 7 + structure/04_transports-and-sidecars.md | 11 + tests/config.test.ts | 152 ++++++++ tests/management-provider-validation.test.ts | 69 ++++ tests/openai-api-virtual-models.test.ts | 13 + tests/openai-responses-passthrough.test.ts | 355 ++++++++++++++++++ 16 files changed, 819 insertions(+), 18 deletions(-) create mode 100644 src/responses/hosted-tool-policy.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index f98785556..e02a893ed 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -172,6 +172,7 @@ token の代わりに使えます。すべての候補は timing side channel | `modelReasoningEfforts?` | `Record` | モデル別 reasoning ラベル。空配列はそのモデルの effort control を隠します。 | | `modelSupportsReasoningSummaries?` | `Record` | モデル別 reasoning summary capability。`false` にすると summary 対応を広告せず、`openai-responses` リクエスト前に summary-delivery フィールドを除去します。 | | `modelReasoningSummaryDelivery?` | `Record` | モデル別の Responses delivery enum。設定したモデルは summary 対応を維持し、既存の `stream_options.reasoning_summary_delivery` だけを書き換えます。同じモデルの summary capability を `false` にはできません。 | +| `modelPreferHostedTools?` | `Record` | hosted tool namespace を予約する非 forward Responses gateway 向けの完全一致モデル opt-in。現在は `["image_generation"]` のみを受け付けます。一致したモデルは `openai-responses` wire を使い、その hosted tool をサポートする必要があります。競合するクライアント `image_gen` 宣言を除去し、呼び出し元の tool choice を維持するため selector も書き換えます。OpenAI API の仮想 `-pro` モデルでは、まず選択した公開 ID に一致させ、解決後のベース wire-model ID をフォールバックとして使用します。`modelAdapters` は公開 ID、次にベース ID の順に解決し、後者の結果が最終 wire を決めます。未設定のモデルは通常の alias 動作を維持します。 | | `reasoningEffortMap?` | `Record` | プロバイダー単位の reasoning ラベル wire alias。上流が別の値を要求するときだけ使います。 | | `modelReasoningEffortMap?` | `Record>` | モデル別 reasoning ラベル wire alias。 | | `noReasoningModels?` | `string[]` | reasoning/thinking パラメータを拒否するモデル。アダプターが `reasoning_effort` を削除します。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index c3fa6f50c..a3a574536 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -181,6 +181,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막 | `modelReasoningEfforts?` | `Record` | 모델별 reasoning 레이블. 빈 배열은 해당 모델의 effort control을 숨깁니다. | | `modelSupportsReasoningSummaries?` | `Record` | 모델별 reasoning summary capability. 모델 값을 `false`로 두면 summary 지원을 알리지 않고 `openai-responses` 요청 전에 summary-delivery 필드를 제거합니다. | | `modelReasoningSummaryDelivery?` | `Record` | 모델별 Responses delivery enum입니다. 설정된 모델은 summary 지원을 유지하며 기존 `stream_options.reasoning_summary_delivery` 값만 바꿉니다. 같은 모델의 summary capability를 `false`로 설정할 수 없습니다. | +| `modelPreferHostedTools?` | `Record` | hosted tool namespace를 예약하는 non-forward Responses gateway용 정확한 모델 ID opt-in입니다. 현재 `["image_generation"]`만 허용하며, 일치하는 모델은 `openai-responses` wire를 사용하고 해당 hosted tool을 지원해야 합니다. 충돌하는 클라이언트 `image_gen` 선언을 제거하고 호출자의 tool choice를 유지하도록 selector도 다시 씁니다. OpenAI API 가상 `-pro` 모델은 선택한 공개 ID를 먼저 일치시키고, 해석된 기본 wire-model ID를 대체값으로 사용합니다. `modelAdapters`는 공개 ID를 먼저, 그 다음 기본 ID를 해석하며, 두 번째 결과가 최종 wire를 결정합니다. 설정하지 않은 모델은 일반 alias 동작을 유지합니다. | | `modelAdapters?` | `Record` | 여러 wire를 쓰는 모델이 한 게이트웨이에 섞여 있을 때의 모델별 wire 지정. 키는 upstream native 모델 ID이고 값은 `openai-chat` 또는 `openai-responses`만 허용합니다. `web_search` 같은 hosted tool 때문에 한 모델만 Responses API가 필요할 때 씁니다. upstream이 wire를 고정한 모델과 canonical ChatGPT forward provider에서는 override가 거부됩니다. | | `reasoningEffortMap?` | `Record` | 프로바이더 단위 reasoning 레이블 wire alias. 업스트림이 다른 값을 요구할 때만 사용합니다. | | `modelReasoningEffortMap?` | `Record>` | 모델별 reasoning 레이블 wire alias. | diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 700cfd5d7..82afea8ca 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -298,6 +298,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | `modelSupportsReasoningSummaries?` | `Record` | Model-specific reasoning-summary capability. Set a model to `false` to stop advertising summaries and strip summary-delivery fields before an `openai-responses` request. | | `modelReasoningSummaryDelivery?` | `Record` | Model-specific Responses delivery enum. A configured model stays summary-capable and only an existing `stream_options.reasoning_summary_delivery` is rewritten; do not combine it with a `false` summary capability for the same model. | | `modelAdapters?` | `Record` | Per-model wire override for a gateway that fronts models speaking different wires. Keys are upstream native model ids; values must be `openai-chat` or `openai-responses`. Useful when one model needs the Responses API for hosted tools such as `web_search` while its siblings are fine on chat completions. Models the upstream pins to a single wire, and the canonical ChatGPT forward provider, reject overrides. | +| `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Use only when the upstream expects a different value. | | `modelReasoningEffortMap?` | `Record>` | Model-specific wire aliases for reasoning labels. | | `noReasoningModels?` | `string[]` | Models that reject a reasoning/thinking param — the adapter drops `reasoning_effort` for them. | diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index d90d44766..7d5be0438 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -204,6 +204,7 @@ x-opencodex-api-key: your-secret-token | `modelReasoningEfforts?` | `Record` | Метки рассуждений для конкретных моделей. Пустой список скрывает управление уровнем рассуждений для этой модели. | | `modelSupportsReasoningSummaries?` | `Record` | Поддержка reasoning summary для отдельных моделей. Значение `false` отключает объявление summary и удаляет поля summary-delivery перед запросом `openai-responses`. | | `modelReasoningSummaryDelivery?` | `Record` | Enum доставки Responses для отдельных моделей. Настроенная модель сохраняет поддержку summary, а адаптер меняет только уже существующее `stream_options.reasoning_summary_delivery`. Нельзя одновременно отключить summary для той же модели. | +| `modelPreferHostedTools?` | `Record` | Opt-in для точного model ID в non-forward Responses gateway, который резервирует namespace hosted tool. Сейчас допускается только `["image_generation"]`; совпавшая модель должна использовать wire `openai-responses` и поддерживать этот hosted tool. Прокси удаляет конфликтующие клиентские объявления `image_gen` и переписывает их selectors, сохраняя caller tool choice. Для виртуальных моделей OpenAI API `-pro` сначала сопоставляется выбранный публичный ID, а затем в качестве fallback используется ID базовой wire-модели. `modelAdapters` сначала разрешается по публичному ID, затем по базовому ID; второй результат определяет итоговый wire. Остальные модели сохраняют обычное alias-поведение. | | `reasoningEffortMap?` | `Record` | Wire-алиасы меток рассуждений на уровне провайдера. Используйте только когда вышестоящая сторона ожидает другое значение. | | `modelReasoningEffortMap?` | `Record>` | Wire-алиасы меток рассуждений для конкретных моделей. | | `noReasoningModels?` | `string[]` | Модели, отклоняющие параметр reasoning/thinking — адаптер удаляет для них `reasoning_effort`. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 0c0e2a5e5..5ff4a2850 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -172,6 +172,7 @@ x-opencodex-api-key: your-secret-token | `modelReasoningEfforts?` | `Record` | 模型级 reasoning label。空数组会隐藏该模型的 effort 控件。 | | `modelSupportsReasoningSummaries?` | `Record` | 模型级 reasoning summary 能力。设为 `false` 时不再声明 summary 支持,并在 `openai-responses` 请求前移除 summary-delivery 字段。 | | `modelReasoningSummaryDelivery?` | `Record` | 模型级 Responses delivery enum。已配置模型保持 summary 能力,适配器只改写现有的 `stream_options.reasoning_summary_delivery`;同一模型不能同时将 summary 能力设为 `false`。 | +| `modelPreferHostedTools?` | `Record` | 非 forward Responses gateway 的精确模型 ID opt-in,用于上游预留 hosted tool namespace 的情况。目前只支持 `["image_generation"]`;匹配模型必须使用 `openai-responses` wire 且支持该 hosted 工具。它会移除冲突的客户端 `image_gen` 声明,并改写其 selector 以保持调用方的 tool choice。对于 OpenAI API 的虚拟 `-pro` 模型,先匹配所选公开 ID,未命中时才使用解析出的基础 wire-model ID 作为回退。`modelAdapters` 会先按公开 ID、再按基础 ID 解析;后一次结果决定最终 wire。未配置模型保持普通 alias 行为。 | | `reasoningEffortMap?` | `Record` | provider 级 reasoning label wire alias。只在上游需要不同值时使用。 | | `modelReasoningEffortMap?` | `Record>` | 模型级 reasoning label wire alias。 | | `noReasoningModels?` | `string[]` | 拒绝 reasoning/thinking 参数的模型;adapter 会为它们移除 `reasoning_effort`。 | diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b03561c2d..4a8f71f56 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -4,6 +4,7 @@ import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxP import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction"; import { collectResponsesToolGroups } from "../responses/tool-groups"; +import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; @@ -147,18 +148,6 @@ function scrubOcxCompactionItems(body: unknown): unknown { return changed ? { ...body, input } : body; } -/** - * Hosted (OpenAI-executed) tool types that specific native slugs reject at request time. Codex - * attaches these for app skills (e.g. `image_generation` for imagegen) regardless of the target - * model, and the passthrough path forwards the raw body untouched — so a slug that doesn't support - * the tool 400s (`Tool 'image_generation' is not supported with gpt-5.3-codex-spark.`). Each entry - * maps a model-slug matcher to the hosted tool types that must be stripped before forwarding. - * Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`). - */ -const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet }> = [ - { match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) }, -]; - /** * Strip unsupported `reasoning` sub-parameters for native slugs that reject them (e.g. Spark). * codex-rs injects `reasoning.context` and `reasoning.summary` based on catalog flags; Spark's @@ -532,6 +521,114 @@ function declaresImageGenClientTool(tool: unknown): boolean { return isImageGenClientName(tool.name); } +/** Rewrite client image-gen selectors to the hosted tool without widening caller restrictions. */ +function preferHostedImageGenToolChoice(toolChoice: unknown): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + if ((toolChoice.type === "function" || toolChoice.type === "custom") && typeof toolChoice.name === "string") { + return isImageGenClientName(toolChoice.name) ? { type: HOSTED_IMAGE_GENERATION_TOOL } : toolChoice; + } + if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; + const hasHostedImageTool = toolChoice.tools.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); + let changed = false; + let addedHostedImageTool = false; + const tools: unknown[] = []; + for (const tool of toolChoice.tools) { + const isClientImageTool = isPlainObject(tool) + && (tool.type === "function" || tool.type === "custom") + && typeof tool.name === "string" + && isImageGenClientName(tool.name); + if (!isClientImageTool) { + tools.push(tool); + continue; + } + changed = true; + if (!hasHostedImageTool && !addedHostedImageTool) { + tools.push({ type: HOSTED_IMAGE_GENERATION_TOOL }); + addedHostedImageTool = true; + } + } + return changed ? { ...toolChoice, tools } : toolChoice; +} + +/** + * Some Responses-compatible gateways reserve the hosted image namespace even when the request + * does not explicitly declare `image_generation`. For an explicitly configured model, remove only + * colliding client declarations so the gateway's hosted tool can take precedence. + */ +function preferConfiguredHostedTools( + body: unknown, + provider: OcxProviderConfig, + modelId: string, + selectedModelId?: string, +): unknown { + // A virtual model's advertised id takes precedence over its resolved wire-model id. + const preferredTools = (selectedModelId ? provider.modelPreferHostedTools?.[selectedModelId] : undefined) + ?? provider.modelPreferHostedTools?.[modelId]; + if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body; + + const stripGroup = (tools: unknown[]): unknown[] => { + const filtered = tools.filter(tool => !declaresImageGenClientTool(tool)); + return filtered.length === tools.length ? tools : filtered; + }; + + let changed = false; + let tools = body.tools; + let strippedTopLevelImageGenTool = false; + if (Array.isArray(body.tools)) { + tools = stripGroup(body.tools); + strippedTopLevelImageGenTool = tools !== body.tools; + changed ||= strippedTopLevelImageGenTool; + } + + let input = body.input; + let strippedAdditionalToolsIndex = -1; + if (Array.isArray(body.input)) { + let nestedChanged = false; + const mappedInput = body.input.map((item, index) => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const nestedTools = stripGroup(item.tools); + if (nestedTools === item.tools) return item; + if (strippedAdditionalToolsIndex === -1) strippedAdditionalToolsIndex = index; + nestedChanged = true; + return { ...item, tools: nestedTools }; + }); + if (nestedChanged) { + input = mappedInput; + changed = true; + } + } + + const hasToolChoice = Object.hasOwn(body, "tool_choice"); + const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice; + const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice; + const hasHostedImageGenTool = (toolGroup: unknown): boolean => Array.isArray(toolGroup) + && toolGroup.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); + const hasHostedImageGenDeclaration = hasHostedImageGenTool(tools) + || (Array.isArray(input) && input.some(item => isPlainObject(item) + && item.type === "additional_tools" + && hasHostedImageGenTool(item.tools))); + if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndex >= 0) && !hasHostedImageGenDeclaration) { + if (strippedTopLevelImageGenTool && Array.isArray(tools)) { + tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }]; + } else if (strippedAdditionalToolsIndex >= 0 && Array.isArray(input)) { + input = input.map((item, index) => index === strippedAdditionalToolsIndex + && isPlainObject(item) + && Array.isArray(item.tools) + ? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] } + : item); + } + } + changed ||= toolChoiceChanged; + if (!changed) return body; + const next: Record = { + ...body, + ...(Array.isArray(body.tools) ? { tools } : {}), + ...(Array.isArray(body.input) ? { input } : {}), + }; + if (toolChoiceChanged) next.tool_choice = toolChoice; + return next; +} + /** * Lower one complete Codex image-gen namespace to public Responses function tools. * @@ -790,13 +887,9 @@ function normalizeImageGenClientTools(body: unknown): unknown { function stripUnsupportedHostedTools(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.tools)) return body; const model = typeof body.model === "string" ? body.model : ""; - const unsupported = UNSUPPORTED_HOSTED_TOOLS.filter(e => e.match(model)); - if (unsupported.length === 0) return body; - const tools = body.tools.filter(t => { const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined; - if (!type) return true; - return !unsupported.some(e => e.tools.has(type)); + return !type || !isHostedToolUnsupportedForModel(model, type); }); return tools.length === body.tools.length ? body : { ...body, tools }; } @@ -926,8 +1019,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (forward) { outBody = repairOrphanedInputItems(outBody, unexpandedMiss); outBody = stripUnsupportedForwardParams(outBody); + } else { + outBody = preferConfiguredHostedTools( + outBody, + provider, + parsed.modelId, + parsed._openAiVirtualSelectedModelId, + ); + outBody = normalizeImageGenClientTools(outBody); } - else outBody = normalizeImageGenClientTools(outBody); if (forward || parsed._previousResponseInputExpanded === true) { outBody = repairOversizedReplayCallIds(outBody); } diff --git a/src/config.ts b/src/config.ts index a30ee879b..7fb04eda5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,13 +18,17 @@ import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, OPENAI_PROVIDER_TIER_VERSION, + pinnedWireAdapter, REASONING_SUMMARY_DELIVERY_VALUES, type OcxConfig, type OcxProviderConfig, } from "./types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { getProviderRegistryEntry } from "./providers/registry"; +import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; +import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"; let _atomicSeq = 0; @@ -497,6 +501,60 @@ export function reasoningSummaryDeliveryRecordConfigError( return null; } +const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]); + +export function modelPreferHostedToolsConfigError( + value: unknown, + field: string, + providerName: string, + provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown }, +): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; + const entries = Object.entries(value); + const registry = getProviderRegistryEntry(providerName); + if (entries.length > 0 && (registry?.authKind === "forward" || (!registry && provider.authMode === "forward"))) { + return `${field} is not supported on forward-auth Responses providers`; + } + const requestedWireFor = (modelId: string): unknown => provider.modelAdapters + && typeof provider.modelAdapters === "object" + && !Array.isArray(provider.modelAdapters) + ? (provider.modelAdapters as Record)[modelId] + : undefined; + const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => { + const pinned = pinnedWireAdapter(providerName, modelId); + if (pinned) return pinned; + const requestedWire = requestedWireFor(modelId); + return typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire) + ? requestedWire + : currentWire; + }; + for (const [key, entry] of entries) { + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (!Array.isArray(entry)) return `${field}.${key} must be an array`; + if (entry.length === 0) return `${field}.${key} must include image_generation`; + for (const tool of entry) { + if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) { + return `${field}.${key} supports only image_generation`; + } + if (isHostedToolUnsupportedForModel(key, tool)) { + return `${field}.${key} cannot prefer ${tool}: the model does not support it`; + } + } + let effectiveWire = resolveEffectiveWire(key, registry?.adapter ?? provider.adapter); + const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId; + if (virtualWireModel && virtualWireModel !== key) { + effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire); + } + if (effectiveWire !== "openai-responses") { + return `${field}.${key} requires the openai-responses wire`; + } + } + return null; +} + /** * Validate a provider's per-model wire override map (#404). * @@ -731,6 +789,19 @@ const configSchema = z.object({ message: modelAdaptersError, }); } + const preferHostedToolsError = modelPreferHostedToolsConfigError( + (provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools, + "modelPreferHostedTools", + name, + provider, + ); + if (preferHostedToolsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", name, "modelPreferHostedTools"], + message: preferHostedToolsError, + }); + } const maxInputError = positiveIntegerRecordConfigError( (provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens, "modelMaxInputTokens", diff --git a/src/providers/openai-virtual-models.ts b/src/providers/openai-virtual-models.ts index 791f9124b..ea05b4d3d 100644 --- a/src/providers/openai-virtual-models.ts +++ b/src/providers/openai-virtual-models.ts @@ -62,6 +62,7 @@ export function applyOpenAiVirtualModel( logCtx.resolvedModel = resolution.wireModelId; route.modelId = resolution.wireModelId; parsed.modelId = resolution.wireModelId; + parsed._openAiVirtualSelectedModelId = resolution.selectedModelId; if (parsed._rawBody && typeof parsed._rawBody === "object" && !Array.isArray(parsed._rawBody)) { const raw = parsed._rawBody as Record; diff --git a/src/responses/hosted-tool-policy.ts b/src/responses/hosted-tool-policy.ts new file mode 100644 index 000000000..17607a873 --- /dev/null +++ b/src/responses/hosted-tool-policy.ts @@ -0,0 +1,9 @@ +/** Hosted tools rejected by specific native model slugs. */ +const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet }> = [ + { match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) }, +]; + +/** True when forwarding this hosted tool to the model would be rejected upstream. */ +export function isHostedToolUnsupportedForModel(modelId: string, tool: string): boolean { + return UNSUPPORTED_HOSTED_TOOLS.some(entry => entry.match(modelId) && entry.tools.has(tool)); +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index b067f7130..0040843e9 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -4,6 +4,7 @@ import { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + modelPreferHostedToolsConfigError, codexAutoStartEnabled, positiveIntegerConfigError, positiveIntegerRecordConfigError, @@ -259,6 +260,13 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (reasoningSummaryDeliveryError) return `provider ${name} ${reasoningSummaryDeliveryError}`; const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed); if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`; + const preferHostedToolsError = modelPreferHostedToolsConfigError( + raw.modelPreferHostedTools, + "modelPreferHostedTools", + name, + typed, + ); + if (preferHostedToolsError) return `provider ${name} ${preferHostedToolsError}`; const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens"); if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`; const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens"); diff --git a/src/types.ts b/src/types.ts index 12f5f1722..f894dbd80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import type { KiroOAuthMetadata } from "./oauth/types"; export interface OcxParsedRequest { modelId: string; + /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */ + _openAiVirtualSelectedModelId?: string; previousResponseId?: string; context: OcxContext; stream: boolean; @@ -995,6 +997,11 @@ export interface OcxProviderConfig { * Presence also advertises reasoning-summary support for that routed model. */ modelReasoningSummaryDelivery?: Record; + /** + * Exact-model hosted tools that win collisions with Codex client tool declarations. + * Use for non-forward Responses gateways that reserve a hosted tool namespace server-side. + */ + modelPreferHostedTools?: Record; /** Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values. */ reasoningEffortMap?: Record; /** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */ diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 77c447eb5..12a6d2d6d 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -72,6 +72,17 @@ hosted `image_generation` and deduplicates aliases in stable container order. Em namespaces do not remove the hosted fallback. Discovery and normalization span both top-level `body.tools` and Codex Desktop Responses Lite `input[].type = "additional_tools"` containers. +For a model explicitly listed in `modelPreferHostedTools`, a non-forward Responses provider may opt +to remove colliding client `image_gen` declarations before this normalization and rewrite their +selectors to hosted `image_generation`, so a provider-reserved hosted tool takes precedence without +loosening a caller's tool-choice restriction. The opt-in is intentionally model-scoped: the default +alias path remains safest for ordinary public Responses endpoints. + +For OpenAI API virtual `-pro` models, preference lookup checks the selected public ID first and +uses the resolved base wire-model ID as a fallback. `modelAdapters` resolves the public ID first and +the base ID second; the second pass selects the final adapter, and configuration validation mirrors +both steps. + Client-facing API-key responses perform the inverse mapping: JSON output and SSE function-call items restore `{ namespace: "image_gen", name: "" }` so Codex can dispatch the local extension. When item-id repair is also enabled, both transforms compose in one SSE parse/stringify diff --git a/tests/config.test.ts b/tests/config.test.ts index d60a1c519..5f4863877 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -667,6 +667,20 @@ describe("opencodex config defaults", () => { }); expect(readConfigDiagnostics().error).toBeNull(); + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelAdapters: { "provider-image-model": "openai-responses" }, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().error).toBeNull(); + for (const provider of [ { adapter: "openai-chat", baseUrl: "https://gateway.example/v1", authMode: "key", apiKeyTransport: "bearer" }, { adapter: "anthropic", baseUrl: "https://gateway.example/v1", authMode: "oauth", apiKeyTransport: "bearer" }, @@ -800,6 +814,144 @@ describe("opencodex config defaults", () => { expect(readConfigDiagnostics().error).toContain("conflicts with modelSupportsReasoningSummaries=false"); }); + test("modelPreferHostedTools accepts only supported hosted-tool arrays", () => { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().error).toBeNull(); + + // Registry providers route through their registry wire, not this persisted adapter. + writeConfig({ + port: 12345, + providers: { + "openai-apikey": { + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "openai-apikey", + }); + expect(readConfigDiagnostics().error).toBeNull(); + + writeConfig({ + port: 12345, + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + modelAdapters: { "gpt-5.6-sol": "openai-chat" }, + modelPreferHostedTools: { "gpt-5.6-sol-pro": ["image_generation"] }, + }, + }, + defaultProvider: "openai-apikey", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("requires the openai-responses wire"); + + for (const modelPreferHostedTools of [ + [], + { "": ["image_generation"] }, + { model: [] }, + { model: "image_generation" }, + { model: ["web_search"] }, + ]) { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelPreferHostedTools, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("modelPreferHostedTools"); + } + + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("requires the openai-responses wire"); + + writeConfig({ + port: 12345, + providers: { + openrouter: { + adapter: "openai-responses", + baseUrl: "https://openrouter.ai/api/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "openrouter", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("requires the openai-responses wire"); + + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelAdapters: { "provider-image-model": "openai-chat" }, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("requires the openai-responses wire"); + + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelPreferHostedTools: { "gpt-5.3-codex-spark": ["image_generation"] }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("does not support"); + + writeConfig({ + port: 12345, + providers: { + openai: { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }, + }, + defaultProvider: "openai", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("not supported on forward-auth"); + }); + test("modelAdapters accepts only allowed wires on eligible providers (#404)", () => { writeConfig({ port: 12345, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 4f9b15abd..262cb4db6 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -27,6 +27,7 @@ import { startServer, } from "../src/server"; import { handleManagementAPI } from "../src/server/management-api"; +import { providerManagementConfigError } from "../src/server/auth-cors"; import { clearModelCache, markProviderDiscoveryFailed } from "../src/codex/model-cache"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; @@ -117,6 +118,74 @@ afterEach(() => { }); describe("provider management validation", () => { + test("provider management validates model hosted-tool preferences", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }; + expect(providerManagementConfigError("custom", provider)).toBeNull(); + expect(providerManagementConfigError("custom", { + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + modelAdapters: { "provider-image-model": "openai-responses" }, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + })).toBeNull(); + expect(providerManagementConfigError("openai-apikey", { + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + })).toBeNull(); + expect(providerManagementConfigError("openai-apikey", { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + modelAdapters: { "gpt-5.6-sol": "openai-chat" }, + modelPreferHostedTools: { "gpt-5.6-sol-pro": ["image_generation"] }, + })).toContain("requires the openai-responses wire"); + + for (const modelPreferHostedTools of [ + [], + { "": ["image_generation"] }, + { model: [] }, + { model: "image_generation" }, + { model: ["web_search"] }, + ]) { + expect(providerManagementConfigError("custom", { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + modelPreferHostedTools, + })).toContain("modelPreferHostedTools"); + } + + expect(providerManagementConfigError("custom", { + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + })).toContain("requires the openai-responses wire"); + expect(providerManagementConfigError("openrouter", { + adapter: "openai-responses", + baseUrl: "https://openrouter.ai/api/v1", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + })).toContain("requires the openai-responses wire"); + expect(providerManagementConfigError("custom", { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + modelAdapters: { "provider-image-model": "openai-chat" }, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + })).toContain("requires the openai-responses wire"); + expect(providerManagementConfigError("custom", { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + modelPreferHostedTools: { "gpt-5.3-codex-spark": ["image_generation"] }, + })).toContain("does not support"); + expect(providerManagementConfigError("custom-forward", { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + })).toContain("not supported on forward-auth"); + }); + test("provider discovery status is additive and omitted before an attempt", async () => { markProviderDiscoveryFailed("auth-broken", { reason: "http", httpStatus: 401 }); try { diff --git a/tests/openai-api-virtual-models.test.ts b/tests/openai-api-virtual-models.test.ts index 4476036bf..3b98b0763 100644 --- a/tests/openai-api-virtual-models.test.ts +++ b/tests/openai-api-virtual-models.test.ts @@ -10,6 +10,7 @@ import { validateOpenAiVirtualModelDefinition, } from "../src/providers/openai-virtual-models"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import { usageLogPath } from "../src/usage/log"; @@ -64,6 +65,17 @@ describe("OpenAI API virtual model resolution", () => { }); describe("applyOpenAiVirtualModel", () => { + test("resolves a base-model wire override after rewriting a Pro alias", () => { + const selectedWire = resolveWireProtocolOverride("openai-apikey", "gpt-5.6-sol-pro", { + adapter: "openai-responses", + modelAdapters: { "gpt-5.6-sol": "openai-chat" }, + } as any); + const wireModel = resolveWireProtocolOverride("openai-apikey", "gpt-5.6-sol", selectedWire); + + expect(selectedWire.adapter).toBe("openai-responses"); + expect(wireModel.adapter).toBe("openai-chat"); + }); + test("rewrites Pro request: model to base, merges reasoning.mode=pro, preserves effort", () => { const parsed = { modelId: "gpt-5.6-sol-pro", @@ -74,6 +86,7 @@ describe("applyOpenAiVirtualModel", () => { const logCtx = { model: "gpt-5.6-sol-pro", provider: "openai-apikey" } as any; applyOpenAiVirtualModel(parsed, route, logCtx); expect(parsed.modelId).toBe("gpt-5.6-sol"); + expect(parsed._openAiVirtualSelectedModelId).toBe("gpt-5.6-sol-pro"); expect(parsed._rawBody.model).toBe("gpt-5.6-sol"); expect(parsed._rawBody.reasoning).toEqual({ effort: "high", mode: "pro" }); expect(route.modelId).toBe("gpt-5.6-sol"); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 31e5a014d..f638e12b3 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -822,6 +822,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { const body = JSON.parse(request.body) as { tools: Array<{ type: string }>; input: Array<{ type: string; tools?: Array<{ type: string; name?: string }> }>; + tool_choice?: { type: string; name?: string }; }; const additionalTools = body.input.find(item => item.type === "additional_tools"); @@ -956,6 +957,360 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(body.tool_choice).toEqual({ type: "function", name: "image_gen.imagegen" }); }); + test("configured model removes an empty image_gen namespace and preserves hosted image generation", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + tools: [{ type: "image_generation" }], + input: [{ + type: "additional_tools", + role: "developer", + tools: [ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "web_search" }, + ], + }], + tool_choice: { type: "function", name: "image_gen.imagegen" }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string }>; + input: Array<{ type: string; tools?: Array<{ type: string; name?: string }> }>; + }; + const additionalTools = body.input.find(item => item.type === "additional_tools"); + + expect(body.tools).toEqual([{ type: "image_generation" }]); + expect(additionalTools?.tools).toEqual([{ type: "web_search" }]); + expect(body.tool_choice).toEqual({ type: "image_generation" }); + }); + + test("configured model rewrites a custom image-gen selector", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + input: [], + tools: [ + { type: "custom", name: "image_gen.render" }, + { type: "image_generation" }, + ], + tool_choice: { type: "custom", name: "image_gen.render" }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string }>; + tool_choice: { type: string }; + }; + + expect(body.tools).toEqual([{ type: "image_generation" }]); + expect(body.tool_choice).toEqual({ type: "image_generation" }); + }); + + test("configured model retains a hosted declaration for a wrapper-only selector", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + input: [], + tools: [{ type: "namespace", name: "image_gen", tools: [] }], + tool_choice: { type: "function", name: "image_gen.imagegen" }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string }>; + tool_choice: { type: string }; + }; + + expect(body.tools).toEqual([{ type: "image_generation" }]); + expect(body.tool_choice).toEqual({ type: "image_generation" }); + }); + + test("configured model retains hosted image generation without a forced selector", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + + for (const toolChoice of ["auto", "none", undefined] as const) { + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + input: [], + tools: [{ type: "namespace", name: "image_gen", tools: [] }], + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string }>; + tool_choice?: string; + }; + + expect(body.tools).toEqual([{ type: "image_generation" }]); + expect(body.tool_choice).toBe(toolChoice); + } + }); + + test("configured model retains a hosted declaration in wrapper-only additional tools", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + tools: [], + input: [{ + type: "additional_tools", + role: "developer", + tools: [{ type: "namespace", name: "image_gen", tools: [] }], + }], + tool_choice: { type: "function", name: "image_gen.imagegen" }, + }, + }, meta); + const body = JSON.parse(request.body) as { + input: Array<{ type: string; tools?: Array<{ type: string }> }>; + tool_choice: { type: string }; + }; + const additionalTools = body.input.find(item => item.type === "additional_tools"); + + expect(additionalTools?.tools).toEqual([{ type: "image_generation" }]); + expect(body.tool_choice).toEqual({ type: "image_generation" }); + }); + + test("configured model restores hosted image generation in unforced additional tools", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + tools: [], + input: [{ + type: "additional_tools", + role: "developer", + tools: [{ type: "namespace", name: "image_gen", tools: [] }], + }], + tool_choice: "auto", + }, + }, meta); + const body = JSON.parse(request.body) as { + input: Array<{ type: string; tools?: Array<{ type: string }> }>; + tool_choice: string; + }; + const additionalTools = body.input.find(item => item.type === "additional_tools"); + + expect(additionalTools?.tools).toEqual([{ type: "image_generation" }]); + expect(body.tool_choice).toBe("auto"); + }); + + test("configured model retains unrelated allowed-tools selector entries", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + input: [], + tools: [ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "image_generation" }, + { type: "function", name: "exec_command", parameters: {} }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "image_gen.imagegen" }, + { type: "function", name: "exec_command" }, + ], + }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string; name?: string }>; + tool_choice?: { type: string; mode: string; tools: Array<{ type: string; name?: string }> }; + }; + + expect(body.tools).toEqual([ + { type: "image_generation" }, + { type: "function", name: "exec_command", parameters: {} }, + ]); + expect(body.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [ + { type: "image_generation" }, + { type: "function", name: "exec_command" }, + ], + }); + }); + + test("configured model rewrites custom image-gen entries in an allowed-tools selector", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model", + input: [], + tools: [ + { type: "custom", name: "image_gen.render" }, + { type: "image_generation" }, + { type: "custom", name: "exec_command" }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "custom", name: "image_gen.render" }, + { type: "custom", name: "exec_command" }, + ], + }, + }, + }, meta); + const body = JSON.parse(request.body) as { + tools: Array<{ type: string; name?: string }>; + tool_choice: { type: string; mode: string; tools: Array<{ type: string; name?: string }> }; + }; + + expect(body.tools).toEqual([ + { type: "image_generation" }, + { type: "custom", name: "exec_command" }, + ]); + expect(body.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [ + { type: "image_generation" }, + { type: "custom", name: "exec_command" }, + ], + }); + }); + + test("hosted-tool preference stays scoped to its configured model", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "other-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "other-model", + input: [], + tools: [ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "image_generation" }, + ], + }, + }, meta); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools).toEqual([ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "image_generation" }, + ]); + }); + + test("hosted-tool preference uses the exact model id", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "provider-image-model:variant", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-image-model:variant", + input: [], + tools: [ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "image_generation" }, + ], + }, + }, meta); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools).toEqual([ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "image_generation" }, + ]); + }); + + test("hosted-tool preference honors an OpenAI virtual model's selected id", () => { + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "gpt-5.6-sol-pro": ["image_generation"] }, + }); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + _openAiVirtualSelectedModelId: "gpt-5.6-sol-pro", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [], + tools: [ + { type: "namespace", name: "image_gen", tools: [] }, + { type: "image_generation" }, + ], + }, + }, meta); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "image_generation" }]); + }); + test("keyed platform preserves hosted image_generation for replay-only image-gen calls", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({