From 2ad7761d051d87882d8344616deaefbe8cd2ef3a Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 08:34:00 +0000 Subject: [PATCH 1/6] feat(providers): add Responses terminal repair escape hatch for custom providers (closes #1809) --- src/providers/registry.ts | 43 +++++++++++++- src/types/provider.ts | 14 +++++ tests/deepseek-inbound-wire.test.ts | 87 +++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 9fda85ba63..c721564729 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2877,15 +2877,52 @@ export function providerModelResponsesUpstreamStreaming( return entry.modelResponsesUpstreamStreaming[modelId.trim().toLowerCase()]; } -/** Resolve a registry-only terminal-repair policy for native Responses streams. */ +/** + * Resolve terminal-repair policy for native Responses streams (supports registry presets + * and custom-provider configuration overrides, issue #1809). + */ export function providerModelResponsesTerminalRepair( id: string, - provider: Pick & Partial>, + provider: Pick & Partial>, modelId: string, ): ResponsesTerminalRepairPolicy | undefined { + const modelKey = modelId.trim().toLowerCase(); + const effectiveAdapter = provider.modelAdapters?.[modelId] ?? provider.modelAdapters?.[modelKey] ?? provider.adapter; + + // Custom provider opt-in: effective wire must be openai-responses + if (effectiveAdapter === "openai-responses") { + // 1. Check explicit modelResponsesCompatibility + const compat = provider.modelResponsesCompatibility?.[modelId] ?? provider.modelResponsesCompatibility?.[modelKey]; + if (compat === "terminal-repair") { + const raw = provider.modelResponsesTerminalRepair?.[modelId] ?? provider.modelResponsesTerminalRepair?.[modelKey]; + const grace = typeof raw === "number" ? raw : (typeof raw === "object" && raw ? raw.graceMs : 500); + const graceMs = Math.floor(grace ?? 500); + return { graceMs: Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 500 }; + } + + // 2. Check explicit modelResponsesTerminalRepair + const rawModel = provider.modelResponsesTerminalRepair?.[modelId] ?? provider.modelResponsesTerminalRepair?.[modelKey]; + if (rawModel !== undefined) { + const grace = typeof rawModel === "number" ? rawModel : (typeof rawModel === "object" && rawModel ? rawModel.graceMs : undefined); + const graceMs = Math.floor(grace ?? 0); + if (Number.isFinite(graceMs) && graceMs > 0) return { graceMs }; + } + + // 3. Check provider-level responsesTerminalRepair + if (provider.responsesTerminalRepair !== undefined) { + if (provider.responsesTerminalRepair === "terminal-repair") return { graceMs: 500 }; + const grace = typeof provider.responsesTerminalRepair === "number" + ? provider.responsesTerminalRepair + : (typeof provider.responsesTerminalRepair === "object" && provider.responsesTerminalRepair ? provider.responsesTerminalRepair.graceMs : undefined); + const graceMs = Math.floor(grace ?? 0); + if (Number.isFinite(graceMs) && graceMs > 0) return { graceMs }; + } + } + + // Fall back to registry-defined policy const entry = getProviderRegistryEntry(id); if (!entry?.modelResponsesTerminalRepair || !providerMatchesRegistryTransport(id, provider)) return undefined; - const policy = entry.modelResponsesTerminalRepair[modelId.trim().toLowerCase()]; + const policy = entry.modelResponsesTerminalRepair[modelKey]; const graceMs = Math.floor(policy?.graceMs ?? 0); if (!Number.isFinite(graceMs) || graceMs <= 0) return undefined; return { graceMs }; diff --git a/src/types/provider.ts b/src/types/provider.ts index 3dfca58ddc..9edb67f25e 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -204,6 +204,20 @@ export interface OcxProviderConfig { * `ocxr1` envelopes are still stripped because no upstream can decrypt them. */ preserveResponsesReasoningContent?: boolean; + /** + * Optional per-model Responses compatibility escape hatch for custom providers (issue #1809). + * Keyed by the upstream model id (case-insensitive). + * "terminal-repair" opts into the bounded terminal repair state machine (default 500ms grace). + */ + modelResponsesCompatibility?: Record; + /** + * Explicit per-model terminal-repair grace period for native Responses streams (in ms). + */ + modelResponsesTerminalRepair?: Record; + /** + * Provider-level default terminal-repair grace period for native Responses streams. + */ + responsesTerminalRepair?: { graceMs: number } | number | "terminal-repair"; /** * Explicit opt-in for a relay that genuinely fronts OpenAI and can decode native * compaction blobs. Absent or false degrades foreign blobs to an opaque note. diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 1f298bacce..ef6a568266 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -1014,4 +1014,91 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(input.some(item => item.call_id === "call_orphan")).toBe(false); expect(input.some(item => item.type === "message")).toBe(true); }); + + describe("Custom provider Responses terminal repair escape hatch (#1809)", () => { + test("custom provider opts into default 500ms terminal repair via modelResponsesCompatibility", () => { + const customProv = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesCompatibility: { + "my-model": "terminal-repair" as const, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "my-model")).toEqual({ graceMs: 500 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "MY-MODEL")).toEqual({ graceMs: 500 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "other-model")).toBeUndefined(); + }); + + test("custom provider specifies explicit graceMs via modelResponsesTerminalRepair", () => { + const customProv = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesTerminalRepair: { + "model-num": 1500, + "model-obj": { graceMs: 2000 }, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "model-num")).toEqual({ graceMs: 1500 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "model-obj")).toEqual({ graceMs: 2000 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "unconfigured")).toBeUndefined(); + }); + + test("custom provider specifies provider-level responsesTerminalRepair", () => { + const customProvString = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + responsesTerminalRepair: "terminal-repair" as const, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", customProvString, "any-model")).toEqual({ graceMs: 500 }); + + const customProvNumber = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + responsesTerminalRepair: 750, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", customProvNumber, "any-model")).toEqual({ graceMs: 750 }); + }); + + test("rejects repair for non-responses wires even when compatibility is set", () => { + const chatProv = { + adapter: "openai-chat", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesCompatibility: { + "my-model": "terminal-repair" as const, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", chatProv, "my-model")).toBeUndefined(); + }); + + test("respects per-model modelAdapters overrides", () => { + const hybridProv = { + adapter: "openai-chat", + baseUrl: "https://custom-gateway.test/v1", + modelAdapters: { + "responses-model": "openai-responses", + }, + modelResponsesCompatibility: { + "responses-model": "terminal-repair" as const, + "chat-model": "terminal-repair" as const, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", hybridProv, "responses-model")).toEqual({ graceMs: 500 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", hybridProv, "chat-model")).toBeUndefined(); + }); + + test("fails closed on non-positive or invalid grace values", () => { + const invalidProv = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesTerminalRepair: { + "zero-grace": 0, + "neg-grace": -500, + "nan-grace": NaN, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", invalidProv, "zero-grace")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", invalidProv, "neg-grace")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", invalidProv, "nan-grace")).toBeUndefined(); + }); + }); }); From 7457afdbf3b312463264107b34439e8d1bcbe80b Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 08:44:02 +0000 Subject: [PATCH 2/6] fix(providers): normalize configured model keys in terminal repair lookup (#1809) --- src/providers/registry.ts | 19 +++++++++++++++---- tests/deepseek-inbound-wire.test.ts | 9 ++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c721564729..0a5ca05381 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2877,6 +2877,17 @@ export function providerModelResponsesUpstreamStreaming( return entry.modelResponsesUpstreamStreaming[modelId.trim().toLowerCase()]; } +function lookupCaseInsensitive(map: Record | undefined, key: string): T | undefined { + if (!map) return undefined; + if (Object.prototype.hasOwnProperty.call(map, key)) return map[key]; + const lowerKey = key.trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(map, lowerKey)) return map[lowerKey]; + for (const [k, v] of Object.entries(map)) { + if (k.trim().toLowerCase() === lowerKey) return v; + } + return undefined; +} + /** * Resolve terminal-repair policy for native Responses streams (supports registry presets * and custom-provider configuration overrides, issue #1809). @@ -2887,21 +2898,21 @@ export function providerModelResponsesTerminalRepair( modelId: string, ): ResponsesTerminalRepairPolicy | undefined { const modelKey = modelId.trim().toLowerCase(); - const effectiveAdapter = provider.modelAdapters?.[modelId] ?? provider.modelAdapters?.[modelKey] ?? provider.adapter; + const effectiveAdapter = lookupCaseInsensitive(provider.modelAdapters, modelId) ?? provider.adapter; // Custom provider opt-in: effective wire must be openai-responses if (effectiveAdapter === "openai-responses") { // 1. Check explicit modelResponsesCompatibility - const compat = provider.modelResponsesCompatibility?.[modelId] ?? provider.modelResponsesCompatibility?.[modelKey]; + const compat = lookupCaseInsensitive(provider.modelResponsesCompatibility, modelId); if (compat === "terminal-repair") { - const raw = provider.modelResponsesTerminalRepair?.[modelId] ?? provider.modelResponsesTerminalRepair?.[modelKey]; + const raw = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId); const grace = typeof raw === "number" ? raw : (typeof raw === "object" && raw ? raw.graceMs : 500); const graceMs = Math.floor(grace ?? 500); return { graceMs: Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 500 }; } // 2. Check explicit modelResponsesTerminalRepair - const rawModel = provider.modelResponsesTerminalRepair?.[modelId] ?? provider.modelResponsesTerminalRepair?.[modelKey]; + const rawModel = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId); if (rawModel !== undefined) { const grace = typeof rawModel === "number" ? rawModel : (typeof rawModel === "object" && rawModel ? rawModel.graceMs : undefined); const graceMs = Math.floor(grace ?? 0); diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index ef6a568266..f26a86332d 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -1021,11 +1021,12 @@ describe("stateless Responses upstreams get no stateful parameters", () => { adapter: "openai-responses", baseUrl: "https://custom-gateway.test/v1", modelResponsesCompatibility: { - "my-model": "terminal-repair" as const, + "My-Model": "terminal-repair" as const, }, }; expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "my-model")).toEqual({ graceMs: 500 }); expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "MY-MODEL")).toEqual({ graceMs: 500 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "My-Model")).toEqual({ graceMs: 500 }); expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "other-model")).toBeUndefined(); }); @@ -1034,12 +1035,14 @@ describe("stateless Responses upstreams get no stateful parameters", () => { adapter: "openai-responses", baseUrl: "https://custom-gateway.test/v1", modelResponsesTerminalRepair: { - "model-num": 1500, - "model-obj": { graceMs: 2000 }, + "Model-Num": 1500, + "Model-Obj": { graceMs: 2000 }, }, }; expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "model-num")).toEqual({ graceMs: 1500 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "MODEL-NUM")).toEqual({ graceMs: 1500 }); expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "model-obj")).toEqual({ graceMs: 2000 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "MODEL-OBJ")).toEqual({ graceMs: 2000 }); expect(providerModelResponsesTerminalRepair("custom-gateway", customProv, "unconfigured")).toBeUndefined(); }); From e741b65b09442f24190e997ff30ee8de3cce893a Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 09:51:24 +0000 Subject: [PATCH 3/6] fix(providers): fail closed on invalid explicit grace values in compatibility mode (#1809) --- src/providers/registry.ts | 10 +++++++--- tests/deepseek-inbound-wire.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 0a5ca05381..dfbe74b516 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2906,9 +2906,13 @@ export function providerModelResponsesTerminalRepair( const compat = lookupCaseInsensitive(provider.modelResponsesCompatibility, modelId); if (compat === "terminal-repair") { const raw = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId); - const grace = typeof raw === "number" ? raw : (typeof raw === "object" && raw ? raw.graceMs : 500); - const graceMs = Math.floor(grace ?? 500); - return { graceMs: Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 500 }; + if (raw !== undefined) { + const grace = typeof raw === "number" ? raw : (typeof raw === "object" && raw ? raw.graceMs : undefined); + const graceMs = Math.floor(grace ?? 0); + if (!Number.isFinite(graceMs) || graceMs <= 0) return undefined; + return { graceMs }; + } + return { graceMs: 500 }; } // 2. Check explicit modelResponsesTerminalRepair diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index f26a86332d..a92b1ba8a4 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -1098,10 +1098,26 @@ describe("stateless Responses upstreams get no stateful parameters", () => { "neg-grace": -500, "nan-grace": NaN, }, + modelResponsesCompatibility: { + "compat-zero": "terminal-repair" as const, + "compat-neg": "terminal-repair" as const, + "compat-nan": "terminal-repair" as const, + }, + }; + const invalidCompatProv = { + ...invalidProv, + modelResponsesTerminalRepair: { + "compat-zero": 0, + "compat-neg": -500, + "compat-nan": NaN, + }, }; expect(providerModelResponsesTerminalRepair("custom-gateway", invalidProv, "zero-grace")).toBeUndefined(); expect(providerModelResponsesTerminalRepair("custom-gateway", invalidProv, "neg-grace")).toBeUndefined(); expect(providerModelResponsesTerminalRepair("custom-gateway", invalidProv, "nan-grace")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", invalidCompatProv, "compat-zero")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", invalidCompatProv, "compat-neg")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", invalidCompatProv, "compat-nan")).toBeUndefined(); }); }); }); From cdf611aa4bde72e4bdea39287890132423ebf6d9 Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 10:36:14 +0000 Subject: [PATCH 4/6] feat(providers): wire terminal repair config validation and safeConfigDTO (#1809) --- src/config.ts | 83 +++++++++++++++++++++++++++++ src/server/auth-cors.ts | 9 ++++ tests/deepseek-inbound-wire.test.ts | 42 +++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/src/config.ts b/src/config.ts index 10032fcbcf..4d7b2acf4f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -708,6 +708,59 @@ export function modelPreferHostedToolsConfigError( return null; } +/** + * Validate a provider's per-model wire override map (#404). + * + * Rejects, rather than silently ignoring, configurations the resolver would refuse: + * a value outside the allowed wires, a model the upstream pins to one wire, and any + * override on a canonical forward provider (where switching wires would drop the + * caller's forwarded credential). Silently dropping them would leave the user + * believing an override is in effect. + */ +export function modelResponsesCompatibilityConfigError( + value: unknown, + field = "modelResponsesCompatibility", +): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + for (const [key, entry] of Object.entries(value)) { + if (!key.trim() || key !== key.trim()) return `${field} keys must be nonblank trimmed model ids`; + if (entry !== "terminal-repair") { + return `${field}.${key} must be "terminal-repair"`; + } + } + return null; +} + +export function modelResponsesTerminalRepairConfigError( + value: unknown, + field = "modelResponsesTerminalRepair", +): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + for (const [key, entry] of Object.entries(value)) { + if (!key.trim() || key !== key.trim()) return `${field} keys must be nonblank trimmed model ids`; + const grace = typeof entry === "number" ? entry : (typeof entry === "object" && entry ? (entry as { graceMs?: unknown }).graceMs : null); + if (typeof grace !== "number" || !Number.isFinite(grace) || grace <= 0) { + return `${field}.${key} must be a positive number of milliseconds or { graceMs: number }`; + } + } + return null; +} + +export function responsesTerminalRepairConfigError( + value: unknown, + field = "responsesTerminalRepair", +): string | null { + if (value === undefined) return null; + if (value === "terminal-repair") return null; + const grace = typeof value === "number" ? value : (typeof value === "object" && value ? (value as { graceMs?: unknown }).graceMs : null); + if (typeof grace !== "number" || !Number.isFinite(grace) || grace <= 0) { + return `${field} must be "terminal-repair", a positive number of milliseconds, or { graceMs: number }`; + } + return null; +} + const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = @@ -1077,6 +1130,36 @@ const configSchema = z.object({ message: modelAdaptersError, }); } + const compatError = modelResponsesCompatibilityConfigError( + (provider as { modelResponsesCompatibility?: unknown }).modelResponsesCompatibility, + ); + if (compatError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelResponsesCompatibility"], + message: compatError, + }); + } + const modelRepairError = modelResponsesTerminalRepairConfigError( + (provider as { modelResponsesTerminalRepair?: unknown }).modelResponsesTerminalRepair, + ); + if (modelRepairError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelResponsesTerminalRepair"], + message: modelRepairError, + }); + } + const repairError = responsesTerminalRepairConfigError( + (provider as { responsesTerminalRepair?: unknown }).responsesTerminalRepair, + ); + if (repairError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "responsesTerminalRepair"], + message: repairError, + }); + } const preferHostedToolsError = modelPreferHostedToolsConfigError( (provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools, "modelPreferHostedTools", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2257f78923..222b093f95 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -616,6 +616,12 @@ 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 compatError = modelResponsesCompatibilityConfigError(raw.modelResponsesCompatibility); + if (compatError) return `provider ${name} ${compatError}`; + const modelRepairError = modelResponsesTerminalRepairConfigError(raw.modelResponsesTerminalRepair); + if (modelRepairError) return `provider ${name} ${modelRepairError}`; + const repairError = responsesTerminalRepairConfigError(raw.responsesTerminalRepair); + if (repairError) return `provider ${name} ${repairError}`; const preferHostedToolsError = modelPreferHostedToolsConfigError( raw.modelPreferHostedTools, "modelPreferHostedTools", @@ -711,6 +717,9 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelMaxOutputTokens", "openRouterRouting", "modelOpenRouterRouting", + "modelResponsesCompatibility", + "modelResponsesTerminalRepair", + "responsesTerminalRepair", "reasoningEfforts", "modelReasoningEfforts", "reasoningWireFormat", diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index a92b1ba8a4..2e40e03cc7 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -12,6 +12,8 @@ * assert the captured upstream URL, which is externally observable. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { safeConfigDTO, providerManagementConfigError } from "../src/server/auth-cors"; +import type { OcxConfig } from "../src/types"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry, @@ -1119,5 +1121,45 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(providerModelResponsesTerminalRepair("custom-gateway", invalidCompatProv, "compat-neg")).toBeUndefined(); expect(providerModelResponsesTerminalRepair("custom-gateway", invalidCompatProv, "compat-nan")).toBeUndefined(); }); + + test("safeConfigDTO preserves terminal-repair configuration keys", () => { + const config: OcxConfig = { + providers: { + "custom-gw": { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesCompatibility: { "my-model": "terminal-repair" }, + modelResponsesTerminalRepair: { "my-model": 1500 }, + responsesTerminalRepair: { graceMs: 800 }, + }, + }, + } as unknown as OcxConfig; + const dto = safeConfigDTO(config) as { providers: Record> }; + expect(dto.providers["custom-gw"].modelResponsesCompatibility).toEqual({ "my-model": "terminal-repair" }); + expect(dto.providers["custom-gw"].modelResponsesTerminalRepair).toEqual({ "my-model": 1500 }); + expect(dto.providers["custom-gw"].responsesTerminalRepair).toEqual({ graceMs: 800 }); + }); + + test("providerManagementConfigError validates terminal-repair configuration", () => { + expect(providerManagementConfigError("custom-gw", { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesCompatibility: { "my-model": "terminal-repair" }, + modelResponsesTerminalRepair: { "my-model": 1500 }, + responsesTerminalRepair: 800, + })).toBeNull(); + + expect(providerManagementConfigError("custom-gw", { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesCompatibility: { "my-model": "invalid" }, + })).toContain('modelResponsesCompatibility.my-model must be "terminal-repair"'); + + expect(providerManagementConfigError("custom-gw", { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + responsesTerminalRepair: -500, + })).toContain('responsesTerminalRepair must be "terminal-repair", a positive number'); + }); }); }); From 8d8d64c36b00494d0330f83bb8c460dad10b0e4f Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 11:42:27 +0000 Subject: [PATCH 5/6] fix(providers): harden custom terminal repair resolver against forward mode and key collisions (#1809) --- src/config.ts | 25 ++++++++++++- src/providers/registry.ts | 56 +++++++++++++++++++--------- tests/deepseek-inbound-wire.test.ts | 57 +++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4d7b2acf4f..880c8da5aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -720,10 +720,18 @@ export function modelPreferHostedToolsConfigError( export function modelResponsesCompatibilityConfigError( value: unknown, field = "modelResponsesCompatibility", + providerName?: string, + provider?: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown }, ): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; - for (const [key, entry] of Object.entries(value)) { + 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); + if (entries.length > 0 && provider && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) { + return `${field} is not supported on the canonical ChatGPT forward provider`; + } + for (const [key, entry] of entries) { if (!key.trim() || key !== key.trim()) return `${field} keys must be nonblank trimmed model ids`; if (entry !== "terminal-repair") { return `${field}.${key} must be "terminal-repair"`; @@ -735,10 +743,18 @@ export function modelResponsesCompatibilityConfigError( export function modelResponsesTerminalRepairConfigError( value: unknown, field = "modelResponsesTerminalRepair", + providerName?: string, + provider?: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown }, ): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; - for (const [key, entry] of Object.entries(value)) { + 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); + if (entries.length > 0 && provider && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) { + return `${field} is not supported on the canonical ChatGPT forward provider`; + } + for (const [key, entry] of entries) { if (!key.trim() || key !== key.trim()) return `${field} keys must be nonblank trimmed model ids`; const grace = typeof entry === "number" ? entry : (typeof entry === "object" && entry ? (entry as { graceMs?: unknown }).graceMs : null); if (typeof grace !== "number" || !Number.isFinite(grace) || grace <= 0) { @@ -751,8 +767,13 @@ export function modelResponsesTerminalRepairConfigError( export function responsesTerminalRepairConfigError( value: unknown, field = "responsesTerminalRepair", + providerName?: string, + provider?: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown }, ): string | null { if (value === undefined) return null; + if (provider && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) { + return `${field} is not supported on the canonical ChatGPT forward provider`; + } if (value === "terminal-repair") return null; const grace = typeof value === "number" ? value : (typeof value === "object" && value ? (value as { graceMs?: unknown }).graceMs : null); if (typeof grace !== "number" || !Number.isFinite(grace) || grace <= 0) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index dfbe74b516..06b8cedc41 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -19,6 +19,7 @@ import { } from "../adapters/cursor/discovery"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; +import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -2877,15 +2878,24 @@ export function providerModelResponsesUpstreamStreaming( return entry.modelResponsesUpstreamStreaming[modelId.trim().toLowerCase()]; } +const DEFAULT_TERMINAL_REPAIR_GRACE_MS = 500; +const MAX_TERMINAL_REPAIR_GRACE_MS = 60_000; + function lookupCaseInsensitive(map: Record | undefined, key: string): T | undefined { if (!map) return undefined; - if (Object.prototype.hasOwnProperty.call(map, key)) return map[key]; - const lowerKey = key.trim().toLowerCase(); - if (Object.prototype.hasOwnProperty.call(map, lowerKey)) return map[lowerKey]; + const target = key.trim().toLowerCase(); + if (!target) return undefined; + let matchedValue: T | undefined = undefined; + let matchCount = 0; for (const [k, v] of Object.entries(map)) { - if (k.trim().toLowerCase() === lowerKey) return v; + if (k.trim().toLowerCase() === target) { + matchedValue = v; + matchCount++; + } } - return undefined; + // If multiple keys case-fold to the same target (e.g. "My-Model" and "my-model"), reject as ambiguous + if (matchCount > 1) return undefined; + return matchedValue; } /** @@ -2897,6 +2907,11 @@ export function providerModelResponsesTerminalRepair( provider: Pick & Partial>, modelId: string, ): ResponsesTerminalRepairPolicy | undefined { + // Canonical ChatGPT forward traffic must never undergo synthetic terminal repair + if (isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) { + return undefined; + } + const modelKey = modelId.trim().toLowerCase(); const effectiveAdapter = lookupCaseInsensitive(provider.modelAdapters, modelId) ?? provider.adapter; @@ -2907,30 +2922,37 @@ export function providerModelResponsesTerminalRepair( if (compat === "terminal-repair") { const raw = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId); if (raw !== undefined) { - const grace = typeof raw === "number" ? raw : (typeof raw === "object" && raw ? raw.graceMs : undefined); - const graceMs = Math.floor(grace ?? 0); + const grace = typeof raw === "number" ? raw : (typeof raw === "object" && raw && "graceMs" in raw ? (raw as { graceMs?: unknown }).graceMs : undefined); + const graceMs = Math.floor(typeof grace === "number" ? grace : 0); if (!Number.isFinite(graceMs) || graceMs <= 0) return undefined; - return { graceMs }; + return { graceMs: Math.min(graceMs, MAX_TERMINAL_REPAIR_GRACE_MS) }; } - return { graceMs: 500 }; + return { graceMs: DEFAULT_TERMINAL_REPAIR_GRACE_MS }; } // 2. Check explicit modelResponsesTerminalRepair const rawModel = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId); if (rawModel !== undefined) { - const grace = typeof rawModel === "number" ? rawModel : (typeof rawModel === "object" && rawModel ? rawModel.graceMs : undefined); - const graceMs = Math.floor(grace ?? 0); - if (Number.isFinite(graceMs) && graceMs > 0) return { graceMs }; + const grace = typeof rawModel === "number" ? rawModel : (typeof rawModel === "object" && rawModel && "graceMs" in rawModel ? (rawModel as { graceMs?: unknown }).graceMs : undefined); + const graceMs = Math.floor(typeof grace === "number" ? grace : 0); + if (Number.isFinite(graceMs) && graceMs > 0) { + return { graceMs: Math.min(graceMs, MAX_TERMINAL_REPAIR_GRACE_MS) }; + } + // Explicit model-level setting exists but is non-positive/invalid: fail closed, do not fall back to provider default + return undefined; } // 3. Check provider-level responsesTerminalRepair if (provider.responsesTerminalRepair !== undefined) { - if (provider.responsesTerminalRepair === "terminal-repair") return { graceMs: 500 }; + if (provider.responsesTerminalRepair === "terminal-repair") return { graceMs: DEFAULT_TERMINAL_REPAIR_GRACE_MS }; const grace = typeof provider.responsesTerminalRepair === "number" ? provider.responsesTerminalRepair - : (typeof provider.responsesTerminalRepair === "object" && provider.responsesTerminalRepair ? provider.responsesTerminalRepair.graceMs : undefined); - const graceMs = Math.floor(grace ?? 0); - if (Number.isFinite(graceMs) && graceMs > 0) return { graceMs }; + : (typeof provider.responsesTerminalRepair === "object" && provider.responsesTerminalRepair && "graceMs" in provider.responsesTerminalRepair ? (provider.responsesTerminalRepair as { graceMs?: unknown }).graceMs : undefined); + const graceMs = Math.floor(typeof grace === "number" ? grace : 0); + if (Number.isFinite(graceMs) && graceMs > 0) { + return { graceMs: Math.min(graceMs, MAX_TERMINAL_REPAIR_GRACE_MS) }; + } + return undefined; } } @@ -2940,7 +2962,7 @@ export function providerModelResponsesTerminalRepair( const policy = entry.modelResponsesTerminalRepair[modelKey]; const graceMs = Math.floor(policy?.graceMs ?? 0); if (!Number.isFinite(graceMs) || graceMs <= 0) return undefined; - return { graceMs }; + return { graceMs: Math.min(graceMs, MAX_TERMINAL_REPAIR_GRACE_MS) }; } /** diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 2e40e03cc7..8dc234b5e2 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -13,6 +13,11 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { safeConfigDTO, providerManagementConfigError } from "../src/server/auth-cors"; +import { + modelResponsesCompatibilityConfigError, + modelResponsesTerminalRepairConfigError, + responsesTerminalRepairConfigError, +} from "../src/config"; import type { OcxConfig } from "../src/types"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { @@ -1095,6 +1100,7 @@ describe("stateless Responses upstreams get no stateful parameters", () => { const invalidProv = { adapter: "openai-responses", baseUrl: "https://custom-gateway.test/v1", + responsesTerminalRepair: 750, modelResponsesTerminalRepair: { "zero-grace": 0, "neg-grace": -500, @@ -1122,6 +1128,45 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(providerModelResponsesTerminalRepair("custom-gateway", invalidCompatProv, "compat-nan")).toBeUndefined(); }); + test("canonical ChatGPT forward provider never undergoes terminal repair", () => { + const canonicalOpenAi = { + adapter: "openai-responses", + authMode: "forward" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + responsesTerminalRepair: "terminal-repair" as const, + modelResponsesTerminalRepair: { "gpt-5": 1000 }, + modelResponsesCompatibility: { "gpt-5": "terminal-repair" as const }, + }; + expect(providerModelResponsesTerminalRepair("openai", canonicalOpenAi, "gpt-5")).toBeUndefined(); + }); + + test("duplicate case-folded keys fail closed on ambiguity", () => { + const conflictProv = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesTerminalRepair: { + "My-Model": 500, + "my-model": 1500, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", conflictProv, "My-Model")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", conflictProv, "my-model")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-gateway", conflictProv, "MY-MODEL")).toBeUndefined(); + }); + + test("clamps grace period to maximum 60,000 ms", () => { + const hugeProv = { + adapter: "openai-responses", + baseUrl: "https://custom-gateway.test/v1", + modelResponsesTerminalRepair: { + "huge-model": 120_000, + "max-safe": Number.MAX_SAFE_INTEGER, + }, + }; + expect(providerModelResponsesTerminalRepair("custom-gateway", hugeProv, "huge-model")).toEqual({ graceMs: 60_000 }); + expect(providerModelResponsesTerminalRepair("custom-gateway", hugeProv, "max-safe")).toEqual({ graceMs: 60_000 }); + }); + test("safeConfigDTO preserves terminal-repair configuration keys", () => { const config: OcxConfig = { providers: { @@ -1160,6 +1205,18 @@ describe("stateless Responses upstreams get no stateful parameters", () => { baseUrl: "https://custom-gateway.test/v1", responsesTerminalRepair: -500, })).toContain('responsesTerminalRepair must be "terminal-repair", a positive number'); + + const canonicalOpenAi = { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + expect(responsesTerminalRepairConfigError("terminal-repair", "responsesTerminalRepair", "openai", canonicalOpenAi)) + .toContain("responsesTerminalRepair is not supported on the canonical ChatGPT forward provider"); + expect(modelResponsesCompatibilityConfigError({ "gpt-5": "terminal-repair" }, "modelResponsesCompatibility", "openai", canonicalOpenAi)) + .toContain("modelResponsesCompatibility is not supported on the canonical ChatGPT forward provider"); + expect(modelResponsesTerminalRepairConfigError({ "gpt-5": 500 }, "modelResponsesTerminalRepair", "openai", canonicalOpenAi)) + .toContain("modelResponsesTerminalRepair is not supported on the canonical ChatGPT forward provider"); }); }); }); From a8e89960e4bb491eaeaeb8004f654674c1145c46 Mon Sep 17 00:00:00 2001 From: chilung Date: Sun, 23 Aug 2026 11:56:42 +0000 Subject: [PATCH 6/6] fix(providers): enforce terminal repair config boundary --- .../docs/reference/configuration/providers.md | 33 +++++++++++++++++++ src/config.ts | 11 ++++++- src/server/auth-cors.ts | 24 ++++++++++++-- tests/deepseek-inbound-wire.test.ts | 25 ++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c44b628714..69d0b07fc7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -99,6 +99,9 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; and GitHub Copilot declares Responses-only defaults for its GPT-5 family (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | +| `modelResponsesCompatibility?` | `Record` | Case-insensitive per-model opt-in for the Responses terminal-repair policy on custom providers. A matching model gets the 500 ms default grace unless `modelResponsesTerminalRepair` supplies an explicit grace. The effective model wire must be `openai-responses`; canonical ChatGPT forward rejects this key. | +| `modelResponsesTerminalRepair?` | `Record` | Case-insensitive per-model terminal-repair grace in milliseconds. It overrides the compatibility default and takes precedence over `responsesTerminalRepair`; positive values are floored and capped at 60 seconds. Invalid or ambiguous case-folded entries fail closed at resolution. | +| `responsesTerminalRepair?` | `"terminal-repair" \| number \| { graceMs: number }` | Provider-level terminal-repair fallback for models using the `openai-responses` wire. The string selects the 500 ms default; numeric/object values set the grace and are capped at 60 seconds. It is considered only after compatibility and explicit per-model settings, and is rejected on the canonical ChatGPT forward provider. | | xAI Responses opt-in (dashboard) | switch | For `xai` only, atomically sets or clears the `grok-4.5` and `grok-4.6` `modelAdapters` entries. A hand-edited single entry appears as mixed until the next switch write normalizes both. Other overrides and tier behavior are unchanged. | | `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. | @@ -131,6 +134,36 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +### Responses terminal-repair policy + +These three keys are overlapping controls for custom providers that need a bounded repair when a +native Responses stream does not deliver its terminal event. For each requested model, the +effective adapter (the provider adapter or its `modelAdapters` override) must be +`openai-responses`; Chat Completions and other wires never opt in. Model matching is +case-insensitive. + +Resolution uses this precedence: + +1. A matching `modelResponsesCompatibility` entry opts the model into terminal repair. Its + default grace is 500 ms, unless a matching `modelResponsesTerminalRepair` entry supplies an + explicit grace. +2. Otherwise, a matching `modelResponsesTerminalRepair` entry supplies the per-model grace. +3. Otherwise, `responsesTerminalRepair` supplies the provider-level fallback. + +Grace values are positive finite milliseconds, and the runtime floors them and caps any result at +60 seconds. Config validation rejects malformed values and rejects all three keys on the canonical +ChatGPT forward provider. The runtime resolver is defense in depth: an invalid or ambiguous +case-folded per-model entry is not selected, so resolution fails closed instead of choosing an +arbitrary entry. + +#### Decision Log: why three overlapping knobs? + +`modelResponsesCompatibility` provides a readable opt-in with a safe default, while +`modelResponsesTerminalRepair` handles models that need a different grace period. The +provider-level `responsesTerminalRepair` covers a gateway whose Responses models share one policy. +Keeping all three preserves simple compatibility migration without giving a broad default priority +over an explicit per-model choice. + ### FastWire B1 capability migration Fast capability and arbitrary Chat caller-tier forwarding are independent after FastWire B1. The diff --git a/src/config.ts b/src/config.ts index 880c8da5aa..750e12180b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,7 +72,7 @@ import { type FastWire, type ProviderCostOverlay, } from "./types"; -import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire"; import { getProviderRegistryEntry, @@ -1153,6 +1153,9 @@ const configSchema = z.object({ } const compatError = modelResponsesCompatibilityConfigError( (provider as { modelResponsesCompatibility?: unknown }).modelResponsesCompatibility, + "modelResponsesCompatibility", + name, + provider, ); if (compatError) { ctx.addIssue({ @@ -1163,6 +1166,9 @@ const configSchema = z.object({ } const modelRepairError = modelResponsesTerminalRepairConfigError( (provider as { modelResponsesTerminalRepair?: unknown }).modelResponsesTerminalRepair, + "modelResponsesTerminalRepair", + name, + provider, ); if (modelRepairError) { ctx.addIssue({ @@ -1173,6 +1179,9 @@ const configSchema = z.object({ } const repairError = responsesTerminalRepairConfigError( (provider as { responsesTerminalRepair?: unknown }).responsesTerminalRepair, + "responsesTerminalRepair", + name, + provider, ); if (repairError) { ctx.addIssue({ diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 222b093f95..89ae5b41dd 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -7,6 +7,9 @@ import { requestPacingConfigError, retryOn429PolicyConfigError, sanitizeModelCostsForDisplay, + modelResponsesCompatibilityConfigError, + modelResponsesTerminalRepairConfigError, + responsesTerminalRepairConfigError, } from "../config"; import { apiKeyTransportConfigError, @@ -616,11 +619,26 @@ 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 compatError = modelResponsesCompatibilityConfigError(raw.modelResponsesCompatibility); + const compatError = modelResponsesCompatibilityConfigError( + raw.modelResponsesCompatibility, + "modelResponsesCompatibility", + name, + typed, + ); if (compatError) return `provider ${name} ${compatError}`; - const modelRepairError = modelResponsesTerminalRepairConfigError(raw.modelResponsesTerminalRepair); + const modelRepairError = modelResponsesTerminalRepairConfigError( + raw.modelResponsesTerminalRepair, + "modelResponsesTerminalRepair", + name, + typed, + ); if (modelRepairError) return `provider ${name} ${modelRepairError}`; - const repairError = responsesTerminalRepairConfigError(raw.responsesTerminalRepair); + const repairError = responsesTerminalRepairConfigError( + raw.responsesTerminalRepair, + "responsesTerminalRepair", + name, + typed, + ); if (repairError) return `provider ${name} ${repairError}`; const preferHostedToolsError = modelPreferHostedToolsConfigError( raw.modelPreferHostedTools, diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 8dc234b5e2..d042aadb43 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -14,9 +14,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { safeConfigDTO, providerManagementConfigError } from "../src/server/auth-cors"; import { + getDefaultConfig, modelResponsesCompatibilityConfigError, modelResponsesTerminalRepairConfigError, responsesTerminalRepairConfigError, + validateConfigCandidate, } from "../src/config"; import type { OcxConfig } from "../src/types"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; @@ -1140,6 +1142,29 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(providerModelResponsesTerminalRepair("openai", canonicalOpenAi, "gpt-5")).toBeUndefined(); }); + test("validateConfigCandidate rejects every terminal-repair key on the canonical forward provider", () => { + const base = getDefaultConfig(); + const entries = [ + ["modelResponsesCompatibility", { "gpt-5": "terminal-repair" }], + ["modelResponsesTerminalRepair", { "gpt-5": 500 }], + ["responsesTerminalRepair", "terminal-repair"], + ] as const; + + for (const [field, value] of entries) { + const result = validateConfigCandidate({ + ...base, + providers: { + ...base.providers, + openai: { ...base.providers.openai!, [field]: value }, + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain(`${field} is not supported on the canonical ChatGPT forward provider`); + } + } + }); + test("duplicate case-folded keys fail closed on ambiguity", () => { const conflictProv = { adapter: "openai-responses",