From 4411bb338e3165bcd57cb9be9c32453a09086af5 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 10 Aug 2026 12:38:08 +0000 Subject: [PATCH 1/3] fix: add per-model structured output opt-out --- .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../content/docs/reference/proxy-formats.md | 7 +- .../ru/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + src/adapters/openai-chat.ts | 32 ++++--- src/config.ts | 23 +++++ src/server/auth-cors.ts | 7 ++ src/server/management/provider-routes.ts | 15 ++++ src/types.ts | 6 ++ structure/04_transports-and-sidecars.md | 18 ++++ tests/chat-completions-endpoint.test.ts | 66 +++++++++++++- tests/management-provider-validation.test.ts | 89 +++++++++++++++++++ tests/openai-chat-hardening.test.ts | 18 ++++ 15 files changed, 267 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index e3a158329..8d8db08c0 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -93,6 +93,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noTemperatureModels?` | `string[]` |発信者指定の`temperature`を拒否するモデル。 | | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | +| `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否するモデル。該当モデルだけで structured-output フィールドを省略します。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c69156db1..e0e5df330 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -93,6 +93,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noTemperatureModels?` | `string[]` | 호출자가 지정한 `temperature`를 거부하는 모델입니다. | | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | +| `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 모델입니다. 일치하는 모델에서만 structured-output 필드를 생략합니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8b9724937..ceeb02c92 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -103,6 +103,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `noTemperatureModels?` | `string[]` | Models that reject caller-specified `temperature`. | | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | +| `noStructuredOutputModels?` | `string[]` | Models whose `openai-chat` endpoint rejects `response_format`; only matching models omit the structured-output field. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 0ae53ad02..af70c38f6 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -149,8 +149,9 @@ Structured output is part of that translation: `response_format` with `json_obje `json_schema` is forwarded to routed `openai-chat` models. On `POST /v1/responses` the equivalent request field is `text.format`: native Responses routes preserve it in the raw Responses body, and it is translated to `response_format` when the model routes to an -`openai-chat` provider. A backend without structured-output support returns its own error -instead of the proxy rejecting the request locally. +`openai-chat` provider. A model listed in the provider's `noStructuredOutputModels` omits +`response_format` on that chat wire; sibling models keep the translation. Unclassified backends +receive the field and return their own error instead of the proxy guessing their capability. Non-streaming output has `object: "chat.completion"`. Streaming output uses SSE objects with `object: "chat.completion.chunk"`, choice deltas, a terminal choice with `finish_reason`, and @@ -287,4 +288,4 @@ structurally valid Fernet runs unchanged. If an `agent_message` loses all encryp that repair, it becomes a normal user message. If a current v2 task remains genuinely encrypted but the selected routed target cannot read native ChatGPT ciphertext, opencodex fails with `unreadable_encrypted_agent_task` instead of sending unreadable bytes to that provider. See -[Sub-agent Surface](/guides/sub-agent-surface/) for the client behavior around worker tasks. \ No newline at end of file +[Sub-agent Surface](/guides/sub-agent-surface/) for the client behavior around worker tasks. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 0a258cf99..437f9adc0 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -106,6 +106,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noTemperatureModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `temperature`. | | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | +| `noStructuredOutputModels?` | `string[]` | Модели, чей endpoint `openai-chat` отклоняет `response_format`; поле structured output опускается только для совпавших моделей. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 11c782686..6a2abf249 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -93,6 +93,7 @@ selector,而不是分配一个新名称。 | `noTemperatureModels?` | `string[]` | 会拒绝调用方指定 `temperature` 的模型。 | | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | +| `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的模型;仅匹配的模型会省略 structured-output 字段。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 539299ec6..46c005969 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -813,19 +813,25 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) { body.prompt_cache_key = parsed.options.promptCacheKey; } - const textFormat = parsed.options.textFormat; - if (textFormat?.type === "json_object") { - body.response_format = { type: "json_object" }; - } else if (textFormat?.type === "json_schema") { - body.response_format = { - type: "json_schema", - json_schema: { - name: textFormat.name ?? "response", - ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), - ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), - ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), - }, - }; + // Structured-output support varies by the physical upstream model even when one + // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation + // as the default, but let an exact model opt out instead of forcing a provider-wide + // rollback that would silently return prose for siblings that support JSON Schema. + if (!modelInList(provider.noStructuredOutputModels, parsed.modelId)) { + const textFormat = parsed.options.textFormat; + if (textFormat?.type === "json_object") { + body.response_format = { type: "json_object" }; + } else if (textFormat?.type === "json_schema") { + body.response_format = { + type: "json_schema", + json_schema: { + name: textFormat.name ?? "response", + ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), + ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), + ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), + }, + }; + } } if (tools) { diff --git a/src/config.ts b/src/config.ts index 6c0e97ab1..f441fe902 100644 --- a/src/config.ts +++ b/src/config.ts @@ -629,6 +629,7 @@ const providerConfigSchema = z.object({ supportsServiceTier: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), + noStructuredOutputModels: z.array(z.string().min(1)).optional(), retryOn429: retryOn429PolicySchema.optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), responsesItemIdRepair: z.object({ @@ -820,6 +821,17 @@ export function positiveIntegerConfigError(value: unknown, field: string): strin return null; } +export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) return `${field} must be an array`; + for (const [index, entry] of value.entries()) { + if (typeof entry !== "string" || !entry.trim()) { + return `${field}.${index} must be a nonblank model id`; + } + } + return null; +} + export function booleanRecordConfigError(value: unknown, field: string): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; @@ -1408,6 +1420,17 @@ const configSchema = z.object({ message: maxOutputError, }); } + const structuredOutputOptOutError = nonBlankStringArrayConfigError( + (provider as { noStructuredOutputModels?: unknown }).noStructuredOutputModels, + "noStructuredOutputModels", + ); + if (structuredOutputOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "noStructuredOutputModels"], + message: structuredOutputOptOutError, + }); + } if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) { // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider. // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them. diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a3e9bac79..dd7742cc0 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -6,6 +6,7 @@ import { modelAdapterRecordConfigError, modelPreferHostedToolsConfigError, codexAutoStartEnabled, + nonBlankStringArrayConfigError, positiveIntegerConfigError, positiveIntegerRecordConfigError, providerBaseUrlConfigError, @@ -509,6 +510,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`; const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens"); if (maxOutputError) return `provider ${name} ${maxOutputError}`; + const structuredOutputOptOutError = nonBlankStringArrayConfigError( + raw.noStructuredOutputModels, + "noStructuredOutputModels", + ); + if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const openRouterError = openRouterRoutingConfigError(typed); if (openRouterError) return `provider ${name} ${openRouterError}`; if (typed.authMode === "local") { @@ -589,6 +595,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "noTemperatureModels", "noTopPModels", "noPenaltyModels", + "noStructuredOutputModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", "requiresReasoningPlaceholderModels", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 9fcc07498..2cff5b3c6 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -8,6 +8,7 @@ import { hasOwnProvider, isValidProviderName, multiAgentGuidanceEnabled, + nonBlankStringArrayConfigError, providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, @@ -205,6 +206,19 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "noStructuredOutputModels")) { + const value = rawBody.noStructuredOutputModels; + if (value === null) { + delete next.noStructuredOutputModels; + } else { + const error = nonBlankStringArrayConfigError(value, "noStructuredOutputModels"); + if (error) return { error }; + const models = [...new Set((value as string[]).map(model => model.trim()))]; + if (models.length > 0) next.noStructuredOutputModels = models; + else delete next.noStructuredOutputModels; + } + touched = true; + } // headers is the one object-valued field in the mask. PATCH semantics merge it // shallowly into the existing block so a single fingerprint header can be added @@ -291,6 +305,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise = {}): OcxConfig { return { port: 0, defaultProvider: "mock", providers: { - mock: { adapter: "openai-chat", baseUrl, apiKey: "k", allowPrivateNetwork: true }, + mock: { + adapter: "openai-chat", + baseUrl, + apiKey: "k", + allowPrivateNetwork: true, + ...providerOverrides, + }, }, } as OcxConfig; } @@ -578,6 +584,60 @@ test("POST /v1/responses carries text.format onto the routed chat wire", async ( } }); +test("POST /v1/chat/completions honors the per-model response_format opt-out", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + noStructuredOutputModels: ["test-model"], + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: true, + messages: [{ role: "user", content: "hi" }], + response_format: { type: "json_schema", json_schema: { name: "answer", schema: { type: "object" } } }, + }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.response_format).toBeUndefined(); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("POST /v1/responses honors the per-model response_format opt-out", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + noStructuredOutputModels: ["test-model"], + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + text: { format: { type: "json_schema", name: "answer", schema: { type: "object" } } }, + }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + expect(captured[0]!.response_format).toBeUndefined(); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("POST /v1/chat/completions direct mode forwards caller Authorization", async () => { const seen: Array<{ authorization: string | null }> = []; const upstream = Bun.serve({ diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index ea990d91c..700209fbb 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -125,6 +125,33 @@ afterEach(() => { }); describe("provider management validation", () => { + test("validates and exposes structured-output model opt-outs", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + noStructuredOutputModels: ["deepseek-v4-flash"], + }; + expect(providerManagementConfigError("relay", provider)).toBeNull(); + for (const noStructuredOutputModels of [ + "deepseek-v4-flash", + [""], + [" "], + [42], + ]) { + expect(providerManagementConfigError("relay", { + ...provider, + noStructuredOutputModels, + })).toContain("noStructuredOutputModels"); + } + + const dto = safeConfigDTO({ + port: 10100, + defaultProvider: "relay", + providers: { relay: provider }, + } as OcxConfig) as { providers: Record }; + expect(dto.providers.relay?.noStructuredOutputModels).toEqual(["deepseek-v4-flash"]); + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat", @@ -1063,6 +1090,68 @@ describe("provider management validation", () => { } }); + test("provider PATCH persists and clears structured-output model opt-outs", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const createRes = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "structured-output-toggle", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + liveModels: false, + models: ["deepseek-v4-flash"], + }, + }), + }); + expect(createRes.status).toBe(200); + + const invalid = await fetch(new URL("/api/providers?name=structured-output-toggle", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ noStructuredOutputModels: "deepseek-v4-flash" }), + }); + expect(invalid.status).toBe(400); + + const patchRes = await fetch(new URL("/api/providers?name=structured-output-toggle", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + noStructuredOutputModels: [" deepseek-v4-flash ", "deepseek-v4-flash"], + }), + }); + expect(patchRes.status).toBe(200); + + const providers = await fetch(new URL("/api/providers", server.url)).then(response => response.json()) as Array<{ + name: string; + noStructuredOutputModels?: string[]; + }>; + expect(providers.find(provider => provider.name === "structured-output-toggle")?.noStructuredOutputModels) + .toEqual(["deepseek-v4-flash"]); + + const clearRes = await fetch(new URL("/api/providers?name=structured-output-toggle", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ noStructuredOutputModels: null }), + }); + expect(clearRes.status).toBe(200); + + const saved = await fetch(new URL("/api/config", server.url)).then(response => response.json()) as { + providers: Record; + }; + expect(saved.providers["structured-output-toggle"].noStructuredOutputModels).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + test("provider management rejects sensitive or injectable provider headers", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 83d0b923b..ebe84c88b 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -436,4 +436,22 @@ describe("openai-chat response_format emission", () => { json_schema: { name: "answer" }, }); }); + + test("omits response_format only for an explicitly opted-out model", () => { + const adapter = createOpenAIChatAdapter(provider({ + noStructuredOutputModels: ["test-model"], + })); + const options: OcxParsedRequest["options"] = { + textFormat: { type: "json_schema", name: "answer", schema: { type: "object" }, strict: true }, + }; + + const optedOut = adapter.buildRequest({ ...parsed(), options }); + const supportedSibling = adapter.buildRequest({ ...parsed(), modelId: "supported-model", options }); + + expect(bodyOf(optedOut).response_format).toBeUndefined(); + expect(bodyOf(supportedSibling).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }); + }); }); From 3d477436b59f1da63533efa1887c5c1245726d21 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 11 Aug 2026 04:45:17 +0000 Subject: [PATCH 2/3] fix: honor exact structured-output opt-outs --- src/adapters/openai-chat.ts | 2 +- src/config.ts | 13 ++++++++++++- src/server/management/provider-routes.ts | 3 ++- tests/management-provider-validation.test.ts | 20 ++++++++++++++++++++ tests/openai-chat-hardening.test.ts | 5 +++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 46c005969..383544fb4 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -817,7 +817,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation // as the default, but let an exact model opt out instead of forcing a provider-wide // rollback that would silently return prose for siblings that support JSON Schema. - if (!modelInList(provider.noStructuredOutputModels, parsed.modelId)) { + if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) { const textFormat = parsed.options.textFormat; if (textFormat?.type === "json_object") { body.response_format = { type: "json_object" }; diff --git a/src/config.ts b/src/config.ts index f441fe902..5d8e8585c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -629,7 +629,9 @@ const providerConfigSchema = z.object({ supportsServiceTier: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), - noStructuredOutputModels: z.array(z.string().min(1)).optional(), + noStructuredOutputModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), retryOn429: retryOn429PolicySchema.optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), responsesItemIdRepair: z.object({ @@ -832,6 +834,15 @@ export function nonBlankStringArrayConfigError(value: unknown, field: string): s return null; } +/** + * Keep hand-edited config and management writes on one canonical model-id list. + * Validation happens separately so an all-whitespace value is rejected rather than + * normalized into a model id that can never match at runtime. + */ +export function normalizeNonBlankStringArray(value: readonly string[]): string[] { + return [...new Set(value.map(entry => entry.trim()))]; +} + export function booleanRecordConfigError(value: unknown, field: string): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 2cff5b3c6..76682c29b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -9,6 +9,7 @@ import { isValidProviderName, multiAgentGuidanceEnabled, nonBlankStringArrayConfigError, + normalizeNonBlankStringArray, providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, @@ -213,7 +214,7 @@ function applyProviderPatchFields( } else { const error = nonBlankStringArrayConfigError(value, "noStructuredOutputModels"); if (error) return { error }; - const models = [...new Set((value as string[]).map(model => model.trim()))]; + const models = normalizeNonBlankStringArray(value as string[]); if (models.length > 0) next.noStructuredOutputModels = models; else delete next.noStructuredOutputModels; } diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 700209fbb..4bf9f8421 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -152,6 +152,26 @@ describe("provider management validation", () => { expect(dto.providers.relay?.noStructuredOutputModels).toEqual(["deepseek-v4-flash"]); }); + test("normalizes hand-edited structured-output model opt-outs at load", () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + writeFileSync(join(TEST_DIR, "config.json"), JSON.stringify({ + ...config("127.0.0.1"), + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + noStructuredOutputModels: [" deepseek-v4-flash ", "deepseek-v4-flash", " other-model "], + }, + }, + })); + + expect(loadConfig().providers.relay?.noStructuredOutputModels) + .toEqual(["deepseek-v4-flash", "other-model"]); + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat", diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index ebe84c88b..95900b3e1 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -447,11 +447,16 @@ describe("openai-chat response_format emission", () => { const optedOut = adapter.buildRequest({ ...parsed(), options }); const supportedSibling = adapter.buildRequest({ ...parsed(), modelId: "supported-model", options }); + const colonVariant = adapter.buildRequest({ ...parsed(), modelId: "test-model:structured", options }); expect(bodyOf(optedOut).response_format).toBeUndefined(); expect(bodyOf(supportedSibling).response_format).toEqual({ type: "json_schema", json_schema: { name: "answer", schema: { type: "object" }, strict: true }, }); + expect(bodyOf(colonVariant).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }); }); }); From a607411453d1a568e6cff93e6bb3c55520b11260 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 11 Aug 2026 05:36:04 +0000 Subject: [PATCH 3/3] docs(config): clarify exact structured-output opt-outs --- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-cn/reference/configuration/providers.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 8d8db08c0..a990b5226 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -93,7 +93,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noTemperatureModels?` | `string[]` |発信者指定の`temperature`を拒否するモデル。 | | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | -| `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否するモデル。該当モデルだけで structured-output フィールドを省略します。 | +| `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index e0e5df330..7aed7c777 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -93,7 +93,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noTemperatureModels?` | `string[]` | 호출자가 지정한 `temperature`를 거부하는 모델입니다. | | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | -| `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 모델입니다. 일치하는 모델에서만 structured-output 필드를 생략합니다. | +| `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ceeb02c92..efaef7729 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -103,7 +103,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `noTemperatureModels?` | `string[]` | Models that reject caller-specified `temperature`. | | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | -| `noStructuredOutputModels?` | `string[]` | Models whose `openai-chat` endpoint rejects `response_format`; only matching models omit the structured-output field. | +| `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 437f9adc0..b1bb26c78 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -106,7 +106,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noTemperatureModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `temperature`. | | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | -| `noStructuredOutputModels?` | `string[]` | Модели, чей endpoint `openai-chat` отклоняет `response_format`; поле structured output опускается только для совпавших моделей. | +| `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 6a2abf249..1d2a8c10c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -93,7 +93,7 @@ selector,而不是分配一个新名称。 | `noTemperatureModels?` | `string[]` | 会拒绝调用方指定 `temperature` 的模型。 | | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | -| `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的模型;仅匹配的模型会省略 structured-output 字段。 | +| `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 |