Skip to content

Commit 012ab25

Browse files
fix: preserve hosted image tool preferences
1 parent f0867e8 commit 012ab25

16 files changed

Lines changed: 676 additions & 18 deletions

docs-site/src/content/docs/ja/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ token の代わりに使えます。すべての候補は timing side channel
172172
| `modelReasoningEfforts?` | `Record<string,string[]>` | モデル別 reasoning ラベル。空配列はそのモデルの effort control を隠します。 |
173173
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | モデル別 reasoning summary capability。`false` にすると summary 対応を広告せず、`openai-responses` リクエスト前に summary-delivery フィールドを除去します。 |
174174
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | モデル別の Responses delivery enum。設定したモデルは summary 対応を維持し、既存の `stream_options.reasoning_summary_delivery` だけを書き換えます。同じモデルの summary capability を `false` にはできません。 |
175+
| `modelPreferHostedTools?` | `Record<string,string[]>` | hosted tool namespace を予約する非 forward Responses gateway 向けの完全一致モデル opt-in。現在は `["image_generation"]` のみを受け付けます。一致したモデルは `openai-responses` wire を使い、その hosted tool をサポートする必要があります。競合するクライアント `image_gen` 宣言を除去し、呼び出し元の tool choice を維持するため selector も書き換えます。未設定のモデルは通常の alias 動作を維持します。 |
175176
| `reasoningEffortMap?` | `Record<string,string>` | プロバイダー単位の reasoning ラベル wire alias。上流が別の値を要求するときだけ使います。 |
176177
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | モデル別 reasoning ラベル wire alias。 |
177178
| `noReasoningModels?` | `string[]` | reasoning/thinking パラメータを拒否するモデル。アダプターが `reasoning_effort` を削除します。 |

docs-site/src/content/docs/ko/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막
181181
| `modelReasoningEfforts?` | `Record<string,string[]>` | 모델별 reasoning 레이블. 빈 배열은 해당 모델의 effort control을 숨깁니다. |
182182
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | 모델별 reasoning summary capability. 모델 값을 `false`로 두면 summary 지원을 알리지 않고 `openai-responses` 요청 전에 summary-delivery 필드를 제거합니다. |
183183
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | 모델별 Responses delivery enum입니다. 설정된 모델은 summary 지원을 유지하며 기존 `stream_options.reasoning_summary_delivery` 값만 바꿉니다. 같은 모델의 summary capability를 `false`로 설정할 수 없습니다. |
184+
| `modelPreferHostedTools?` | `Record<string,string[]>` | hosted tool namespace를 예약하는 non-forward Responses gateway용 정확한 모델 ID opt-in입니다. 현재 `["image_generation"]`만 허용하며, 일치하는 모델은 `openai-responses` wire를 사용하고 해당 hosted tool을 지원해야 합니다. 충돌하는 클라이언트 `image_gen` 선언을 제거하고 호출자의 tool choice를 유지하도록 selector도 다시 씁니다. 설정하지 않은 모델은 일반 alias 동작을 유지합니다. |
184185
| `modelAdapters?` | `Record<string,string>` | 여러 wire를 쓰는 모델이 한 게이트웨이에 섞여 있을 때의 모델별 wire 지정. 키는 upstream native 모델 ID이고 값은 `openai-chat` 또는 `openai-responses`만 허용합니다. `web_search` 같은 hosted tool 때문에 한 모델만 Responses API가 필요할 때 씁니다. upstream이 wire를 고정한 모델과 canonical ChatGPT forward provider에서는 override가 거부됩니다. |
185186
| `reasoningEffortMap?` | `Record<string,string>` | 프로바이더 단위 reasoning 레이블 wire alias. 업스트림이 다른 값을 요구할 때만 사용합니다. |
186187
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | 모델별 reasoning 레이블 wire alias. |

docs-site/src/content/docs/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10
298298
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | Model-specific reasoning-summary capability. Set a model to `false` to stop advertising summaries and strip summary-delivery fields before an `openai-responses` request. |
299299
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | 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. |
300300
| `modelAdapters?` | `Record<string,string>` | 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. |
301+
| `modelPreferHostedTools?` | `Record<string,string[]>` | 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. Other models retain normal alias behavior. |
301302
| `reasoningEffortMap?` | `Record<string,string>` | Provider-wide wire aliases for reasoning labels. Use only when the upstream expects a different value. |
302303
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | Model-specific wire aliases for reasoning labels. |
303304
| `noReasoningModels?` | `string[]` | Models that reject a reasoning/thinking param — the adapter drops `reasoning_effort` for them. |

docs-site/src/content/docs/ru/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ x-opencodex-api-key: your-secret-token
204204
| `modelReasoningEfforts?` | `Record<string,string[]>` | Метки рассуждений для конкретных моделей. Пустой список скрывает управление уровнем рассуждений для этой модели. |
205205
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | Поддержка reasoning summary для отдельных моделей. Значение `false` отключает объявление summary и удаляет поля summary-delivery перед запросом `openai-responses`. |
206206
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | Enum доставки Responses для отдельных моделей. Настроенная модель сохраняет поддержку summary, а адаптер меняет только уже существующее `stream_options.reasoning_summary_delivery`. Нельзя одновременно отключить summary для той же модели. |
207+
| `modelPreferHostedTools?` | `Record<string,string[]>` | Opt-in для точного model ID в non-forward Responses gateway, который резервирует namespace hosted tool. Сейчас допускается только `["image_generation"]`; совпавшая модель должна использовать wire `openai-responses` и поддерживать этот hosted tool. Прокси удаляет конфликтующие клиентские объявления `image_gen` и переписывает их selectors, сохраняя caller tool choice. Остальные модели сохраняют обычное alias-поведение. |
207208
| `reasoningEffortMap?` | `Record<string,string>` | Wire-алиасы меток рассуждений на уровне провайдера. Используйте только когда вышестоящая сторона ожидает другое значение. |
208209
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | Wire-алиасы меток рассуждений для конкретных моделей. |
209210
| `noReasoningModels?` | `string[]` | Модели, отклоняющие параметр reasoning/thinking — адаптер удаляет для них `reasoning_effort`. |

docs-site/src/content/docs/zh-cn/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ x-opencodex-api-key: your-secret-token
172172
| `modelReasoningEfforts?` | `Record<string,string[]>` | 模型级 reasoning label。空数组会隐藏该模型的 effort 控件。 |
173173
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | 模型级 reasoning summary 能力。设为 `false` 时不再声明 summary 支持,并在 `openai-responses` 请求前移除 summary-delivery 字段。 |
174174
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | 模型级 Responses delivery enum。已配置模型保持 summary 能力,适配器只改写现有的 `stream_options.reasoning_summary_delivery`;同一模型不能同时将 summary 能力设为 `false`|
175+
| `modelPreferHostedTools?` | `Record<string,string[]>` | 非 forward Responses gateway 的精确模型 ID opt-in,用于上游预留 hosted tool namespace 的情况。目前只支持 `["image_generation"]`;匹配模型必须使用 `openai-responses` wire 且支持该 hosted 工具。它会移除冲突的客户端 `image_gen` 声明,并改写其 selector 以保持调用方的 tool choice。未配置模型保持普通 alias 行为。 |
175176
| `reasoningEffortMap?` | `Record<string,string>` | provider 级 reasoning label wire alias。只在上游需要不同值时使用。 |
176177
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | 模型级 reasoning label wire alias。 |
177178
| `noReasoningModels?` | `string[]` | 拒绝 reasoning/thinking 参数的模型;adapter 会为它们移除 `reasoning_effort`|

src/adapters/openai-responses.ts

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxP
44
import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
55
import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
66
import { collectResponsesToolGroups } from "../responses/tool-groups";
7+
import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";
78
import { decodeServerSentEvents } from "../lib/sse-decoder";
89
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
910
import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
@@ -147,18 +148,6 @@ function scrubOcxCompactionItems(body: unknown): unknown {
147148
return changed ? { ...body, input } : body;
148149
}
149150

150-
/**
151-
* Hosted (OpenAI-executed) tool types that specific native slugs reject at request time. Codex
152-
* attaches these for app skills (e.g. `image_generation` for imagegen) regardless of the target
153-
* model, and the passthrough path forwards the raw body untouched — so a slug that doesn't support
154-
* the tool 400s (`Tool 'image_generation' is not supported with gpt-5.3-codex-spark.`). Each entry
155-
* maps a model-slug matcher to the hosted tool types that must be stripped before forwarding.
156-
* Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
157-
*/
158-
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
159-
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) },
160-
];
161-
162151
/**
163152
* Strip unsupported `reasoning` sub-parameters for native slugs that reject them (e.g. Spark).
164153
* codex-rs injects `reasoning.context` and `reasoning.summary` based on catalog flags; Spark's
@@ -532,6 +521,93 @@ function declaresImageGenClientTool(tool: unknown): boolean {
532521
return isImageGenClientName(tool.name);
533522
}
534523

524+
/** Rewrite client image-gen selectors to the hosted tool without widening caller restrictions. */
525+
function preferHostedImageGenToolChoice(toolChoice: unknown): unknown {
526+
if (!isPlainObject(toolChoice)) return toolChoice;
527+
if ((toolChoice.type === "function" || toolChoice.type === "custom") && typeof toolChoice.name === "string") {
528+
return isImageGenClientName(toolChoice.name) ? { type: HOSTED_IMAGE_GENERATION_TOOL } : toolChoice;
529+
}
530+
if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice;
531+
const hasHostedImageTool = toolChoice.tools.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL);
532+
let changed = false;
533+
let addedHostedImageTool = false;
534+
const tools: unknown[] = [];
535+
for (const tool of toolChoice.tools) {
536+
const isClientImageTool = isPlainObject(tool)
537+
&& (tool.type === "function" || tool.type === "custom")
538+
&& typeof tool.name === "string"
539+
&& isImageGenClientName(tool.name);
540+
if (!isClientImageTool) {
541+
tools.push(tool);
542+
continue;
543+
}
544+
changed = true;
545+
if (!hasHostedImageTool && !addedHostedImageTool) {
546+
tools.push({ type: HOSTED_IMAGE_GENERATION_TOOL });
547+
addedHostedImageTool = true;
548+
}
549+
}
550+
return changed ? { ...toolChoice, tools } : toolChoice;
551+
}
552+
553+
/**
554+
* Some Responses-compatible gateways reserve the hosted image namespace even when the request
555+
* does not explicitly declare `image_generation`. For an explicitly configured model, remove only
556+
* colliding client declarations so the gateway's hosted tool can take precedence.
557+
*/
558+
function preferConfiguredHostedTools(
559+
body: unknown,
560+
provider: OcxProviderConfig,
561+
modelId: string,
562+
selectedModelId?: string,
563+
): unknown {
564+
// A virtual model's advertised id takes precedence over its resolved wire-model id.
565+
const preferredTools = (selectedModelId ? provider.modelPreferHostedTools?.[selectedModelId] : undefined)
566+
?? provider.modelPreferHostedTools?.[modelId];
567+
if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body;
568+
569+
const stripGroup = (tools: unknown[]): unknown[] => {
570+
const filtered = tools.filter(tool => !declaresImageGenClientTool(tool));
571+
return filtered.length === tools.length ? tools : filtered;
572+
};
573+
574+
let changed = false;
575+
let tools = body.tools;
576+
if (Array.isArray(body.tools)) {
577+
tools = stripGroup(body.tools);
578+
changed ||= tools !== body.tools;
579+
}
580+
581+
let input = body.input;
582+
if (Array.isArray(body.input)) {
583+
let nestedChanged = false;
584+
const mappedInput = body.input.map(item => {
585+
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
586+
const nestedTools = stripGroup(item.tools);
587+
if (nestedTools === item.tools) return item;
588+
nestedChanged = true;
589+
return { ...item, tools: nestedTools };
590+
});
591+
if (nestedChanged) {
592+
input = mappedInput;
593+
changed = true;
594+
}
595+
}
596+
597+
const hasToolChoice = Object.hasOwn(body, "tool_choice");
598+
const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice;
599+
const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice;
600+
changed ||= toolChoiceChanged;
601+
if (!changed) return body;
602+
const next: Record<string, unknown> = {
603+
...body,
604+
...(Array.isArray(body.tools) ? { tools } : {}),
605+
...(Array.isArray(body.input) ? { input } : {}),
606+
};
607+
if (toolChoiceChanged) next.tool_choice = toolChoice;
608+
return next;
609+
}
610+
535611
/**
536612
* Lower one complete Codex image-gen namespace to public Responses function tools.
537613
*
@@ -790,13 +866,9 @@ function normalizeImageGenClientTools(body: unknown): unknown {
790866
function stripUnsupportedHostedTools(body: unknown): unknown {
791867
if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
792868
const model = typeof body.model === "string" ? body.model : "";
793-
const unsupported = UNSUPPORTED_HOSTED_TOOLS.filter(e => e.match(model));
794-
if (unsupported.length === 0) return body;
795-
796869
const tools = body.tools.filter(t => {
797870
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
798-
if (!type) return true;
799-
return !unsupported.some(e => e.tools.has(type));
871+
return !type || !isHostedToolUnsupportedForModel(model, type);
800872
});
801873
return tools.length === body.tools.length ? body : { ...body, tools };
802874
}
@@ -926,8 +998,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
926998
if (forward) {
927999
outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
9281000
outBody = stripUnsupportedForwardParams(outBody);
1001+
} else {
1002+
outBody = preferConfiguredHostedTools(
1003+
outBody,
1004+
provider,
1005+
parsed.modelId,
1006+
parsed._openAiVirtualSelectedModelId,
1007+
);
1008+
outBody = normalizeImageGenClientTools(outBody);
9291009
}
930-
else outBody = normalizeImageGenClientTools(outBody);
9311010
if (forward || parsed._previousResponseInputExpanded === true) {
9321011
outBody = repairOversizedReplayCallIds(outBody);
9331012
}

0 commit comments

Comments
 (0)