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 10032fcbcf..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, @@ -708,6 +708,80 @@ 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", + 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`; + 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"`; + } + } + return null; +} + +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`; + 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) { + return `${field}.${key} must be a positive number of milliseconds or { graceMs: number }`; + } + } + return null; +} + +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) { + 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 +1151,45 @@ const configSchema = z.object({ message: modelAdaptersError, }); } + const compatError = modelResponsesCompatibilityConfigError( + (provider as { modelResponsesCompatibility?: unknown }).modelResponsesCompatibility, + "modelResponsesCompatibility", + name, + provider, + ); + if (compatError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelResponsesCompatibility"], + message: compatError, + }); + } + const modelRepairError = modelResponsesTerminalRepairConfigError( + (provider as { modelResponsesTerminalRepair?: unknown }).modelResponsesTerminalRepair, + "modelResponsesTerminalRepair", + name, + provider, + ); + if (modelRepairError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelResponsesTerminalRepair"], + message: modelRepairError, + }); + } + const repairError = responsesTerminalRepairConfigError( + (provider as { responsesTerminalRepair?: unknown }).responsesTerminalRepair, + "responsesTerminalRepair", + name, + provider, + ); + 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/providers/registry.ts b/src/providers/registry.ts index 9fda85ba63..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,18 +2878,91 @@ export function providerModelResponsesUpstreamStreaming( return entry.modelResponsesUpstreamStreaming[modelId.trim().toLowerCase()]; } -/** Resolve a registry-only terminal-repair policy for native Responses streams. */ +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; + 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() === target) { + matchedValue = v; + matchCount++; + } + } + // 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; +} + +/** + * 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 { + // 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; + + // Custom provider opt-in: effective wire must be openai-responses + if (effectiveAdapter === "openai-responses") { + // 1. Check explicit modelResponsesCompatibility + const compat = lookupCaseInsensitive(provider.modelResponsesCompatibility, modelId); + if (compat === "terminal-repair") { + const raw = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId); + if (raw !== undefined) { + 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: Math.min(graceMs, MAX_TERMINAL_REPAIR_GRACE_MS) }; + } + 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 && "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: DEFAULT_TERMINAL_REPAIR_GRACE_MS }; + const grace = typeof provider.responsesTerminalRepair === "number" + ? provider.responsesTerminalRepair + : (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; + } + } + + // 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 }; + return { graceMs: Math.min(graceMs, MAX_TERMINAL_REPAIR_GRACE_MS) }; } /** diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2257f78923..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,6 +619,27 @@ 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, + "modelResponsesCompatibility", + name, + typed, + ); + if (compatError) return `provider ${name} ${compatError}`; + const modelRepairError = modelResponsesTerminalRepairConfigError( + raw.modelResponsesTerminalRepair, + "modelResponsesTerminalRepair", + name, + typed, + ); + if (modelRepairError) return `provider ${name} ${modelRepairError}`; + const repairError = responsesTerminalRepairConfigError( + raw.responsesTerminalRepair, + "responsesTerminalRepair", + name, + typed, + ); + if (repairError) return `provider ${name} ${repairError}`; const preferHostedToolsError = modelPreferHostedToolsConfigError( raw.modelPreferHostedTools, "modelPreferHostedTools", @@ -711,6 +735,9 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelMaxOutputTokens", "openRouterRouting", "modelOpenRouterRouting", + "modelResponsesCompatibility", + "modelResponsesTerminalRepair", + "responsesTerminalRepair", "reasoningEfforts", "modelReasoningEfforts", "reasoningWireFormat", 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..d042aadb43 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -12,6 +12,15 @@ * 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 { + getDefaultConfig, + modelResponsesCompatibilityConfigError, + modelResponsesTerminalRepairConfigError, + responsesTerminalRepairConfigError, + validateConfigCandidate, +} from "../src/config"; +import type { OcxConfig } from "../src/types"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry, @@ -1014,4 +1023,225 @@ 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, "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-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(); + }); + + 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", + responsesTerminalRepair: 750, + modelResponsesTerminalRepair: { + "zero-grace": 0, + "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(); + }); + + 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("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", + 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: { + "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'); + + 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"); + }); + }); });