Skip to content

Commit ab30dca

Browse files
author
郑希哲
committed
fix: preserve hosted image tool preferences
1 parent 7710185 commit ab30dca

14 files changed

Lines changed: 482 additions & 18 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ token の代わりに使えます。すべての候補は timing side channel
157157
| `modelReasoningEfforts?` | `Record<string,string[]>` | モデル別 reasoning ラベル。空配列はそのモデルの effort control を隠します。 |
158158
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | モデル別 reasoning summary capability。`false` にすると summary 対応を広告せず、`openai-responses` リクエスト前に summary-delivery フィールドを除去します。 |
159159
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | モデル別の Responses delivery enum。設定したモデルは summary 対応を維持し、既存の `stream_options.reasoning_summary_delivery` だけを書き換えます。同じモデルの summary capability を `false` にはできません。 |
160+
| `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 動作を維持します。 |
160161
| `reasoningEffortMap?` | `Record<string,string>` | プロバイダー単位の reasoning ラベル wire alias。上流が別の値を要求するときだけ使います。 |
161162
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | モデル別 reasoning ラベル wire alias。 |
162163
| `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
@@ -166,6 +166,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막
166166
| `modelReasoningEfforts?` | `Record<string,string[]>` | 모델별 reasoning 레이블. 빈 배열은 해당 모델의 effort control을 숨깁니다. |
167167
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | 모델별 reasoning summary capability. 모델 값을 `false`로 두면 summary 지원을 알리지 않고 `openai-responses` 요청 전에 summary-delivery 필드를 제거합니다. |
168168
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | 모델별 Responses delivery enum입니다. 설정된 모델은 summary 지원을 유지하며 기존 `stream_options.reasoning_summary_delivery` 값만 바꿉니다. 같은 모델의 summary capability를 `false`로 설정할 수 없습니다. |
169+
| `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 동작을 유지합니다. |
169170
| `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가 거부됩니다. |
170171
| `reasoningEffortMap?` | `Record<string,string>` | 프로바이더 단위 reasoning 레이블 wire alias. 업스트림이 다른 값을 요구할 때만 사용합니다. |
171172
| `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
@@ -279,6 +279,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10
279279
| `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. |
280280
| `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. |
281281
| `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. |
282+
| `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. |
282283
| `reasoningEffortMap?` | `Record<string,string>` | Provider-wide wire aliases for reasoning labels. Use only when the upstream expects a different value. |
283284
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | Model-specific wire aliases for reasoning labels. |
284285
| `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
@@ -186,6 +186,7 @@ x-opencodex-api-key: your-secret-token
186186
| `modelReasoningEfforts?` | `Record<string,string[]>` | Метки рассуждений для конкретных моделей. Пустой список скрывает управление уровнем рассуждений для этой модели. |
187187
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | Поддержка reasoning summary для отдельных моделей. Значение `false` отключает объявление summary и удаляет поля summary-delivery перед запросом `openai-responses`. |
188188
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | Enum доставки Responses для отдельных моделей. Настроенная модель сохраняет поддержку summary, а адаптер меняет только уже существующее `stream_options.reasoning_summary_delivery`. Нельзя одновременно отключить summary для той же модели. |
189+
| `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-поведение. |
189190
| `reasoningEffortMap?` | `Record<string,string>` | Wire-алиасы меток рассуждений на уровне провайдера. Используйте только когда вышестоящая сторона ожидает другое значение. |
190191
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | Wire-алиасы меток рассуждений для конкретных моделей. |
191192
| `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
@@ -158,6 +158,7 @@ x-opencodex-api-key: your-secret-token
158158
| `modelReasoningEfforts?` | `Record<string,string[]>` | 模型级 reasoning label。空数组会隐藏该模型的 effort 控件。 |
159159
| `modelSupportsReasoningSummaries?` | `Record<string,boolean>` | 模型级 reasoning summary 能力。设为 `false` 时不再声明 summary 支持,并在 `openai-responses` 请求前移除 summary-delivery 字段。 |
160160
| `modelReasoningSummaryDelivery?` | `Record<string,"sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | 模型级 Responses delivery enum。已配置模型保持 summary 能力,适配器只改写现有的 `stream_options.reasoning_summary_delivery`;同一模型不能同时将 summary 能力设为 `false`|
161+
| `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 行为。 |
161162
| `reasoningEffortMap?` | `Record<string,string>` | provider 级 reasoning label wire alias。只在上游需要不同值时使用。 |
162163
| `modelReasoningEffortMap?` | `Record<string,Record<string,string>>` | 模型级 reasoning label wire alias。 |
163164
| `noReasoningModels?` | `string[]` | 拒绝 reasoning/thinking 参数的模型;adapter 会为它们移除 `reasoning_effort`|

src/adapters/openai-responses.ts

Lines changed: 90 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,91 @@ 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" && 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"
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+
): unknown {
563+
// Match modelAdapters exactly: both settings must resolve the same per-model wire.
564+
const preferredTools = provider.modelPreferHostedTools?.[modelId];
565+
if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body;
566+
567+
const stripGroup = (tools: unknown[]): unknown[] => {
568+
const filtered = tools.filter(tool => !declaresImageGenClientTool(tool));
569+
return filtered.length === tools.length ? tools : filtered;
570+
};
571+
572+
let changed = false;
573+
let tools = body.tools;
574+
if (Array.isArray(body.tools)) {
575+
tools = stripGroup(body.tools);
576+
changed ||= tools !== body.tools;
577+
}
578+
579+
let input = body.input;
580+
if (Array.isArray(body.input)) {
581+
let nestedChanged = false;
582+
const mappedInput = body.input.map(item => {
583+
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
584+
const nestedTools = stripGroup(item.tools);
585+
if (nestedTools === item.tools) return item;
586+
nestedChanged = true;
587+
return { ...item, tools: nestedTools };
588+
});
589+
if (nestedChanged) {
590+
input = mappedInput;
591+
changed = true;
592+
}
593+
}
594+
595+
const hasToolChoice = Object.hasOwn(body, "tool_choice");
596+
const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice;
597+
const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice;
598+
changed ||= toolChoiceChanged;
599+
if (!changed) return body;
600+
const next: Record<string, unknown> = {
601+
...body,
602+
...(Array.isArray(body.tools) ? { tools } : {}),
603+
...(Array.isArray(body.input) ? { input } : {}),
604+
};
605+
if (toolChoiceChanged) next.tool_choice = toolChoice;
606+
return next;
607+
}
608+
535609
/**
536610
* Lower one complete Codex image-gen namespace to public Responses function tools.
537611
*
@@ -790,13 +864,9 @@ function normalizeImageGenClientTools(body: unknown): unknown {
790864
function stripUnsupportedHostedTools(body: unknown): unknown {
791865
if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
792866
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-
796867
const tools = body.tools.filter(t => {
797868
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));
869+
return !type || !isHostedToolUnsupportedForModel(model, type);
800870
});
801871
return tools.length === body.tools.length ? body : { ...body, tools };
802872
}
@@ -926,8 +996,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
926996
if (forward) {
927997
outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
928998
outBody = stripUnsupportedForwardParams(outBody);
999+
} else {
1000+
outBody = preferConfiguredHostedTools(outBody, provider, parsed.modelId);
1001+
outBody = normalizeImageGenClientTools(outBody);
9291002
}
930-
else outBody = normalizeImageGenClientTools(outBody);
9311003
if (forward || parsed._previousResponseInputExpanded === true) {
9321004
outBody = repairOversizedReplayCallIds(outBody);
9331005
}

0 commit comments

Comments
 (0)