Skip to content
33 changes: 33 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `modelSupportsReasoningSummaries?` | `Record<string, boolean>` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. |
| `modelReasoningSummaryDelivery?` | `Record<string, "sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | Per-model Responses delivery enum; rewrites an existing delivery field. |
| `modelAdapters?` | `Record<string, string>` | 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<string, "terminal-repair">` | 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<string, number \| { graceMs: number }>` | 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<string,string[]>` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. 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<string, string>` | Provider-wide wire aliases for reasoning labels. |
Expand Down Expand Up @@ -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
Expand Down
115 changes: 114 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 91 additions & 4 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2877,18 +2878,104 @@ 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;

type CaseInsensitiveLookup<T> =
| { kind: "missing" }
| { kind: "ambiguous" }
| { kind: "value"; value: T };

function lookupCaseInsensitive<T>(map: Record<string, T> | undefined, key: string): CaseInsensitiveLookup<T> {
if (!map) return { kind: "missing" };
const target = key.trim().toLowerCase();
if (!target) return { kind: "missing" };
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 { kind: "ambiguous" };
return matchCount === 1 && matchedValue !== undefined
? { kind: "value", value: matchedValue }
: { kind: "missing" };
}

/**
* 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<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode" | "modelAdapters" | "modelResponsesCompatibility" | "modelResponsesTerminalRepair" | "responsesTerminalRepair">>,
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();
// Match resolveWireProtocolOverride: explicit modelAdapters entries are exact-keyed. The
// compatibility/repair maps are intentionally case-insensitive, but folding this map here
// would make the policy disagree with the adapter selected for the actual request.
const effectiveAdapter = 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.kind === "ambiguous") return undefined;
if (compat.kind === "value" && compat.value === "terminal-repair") {
const raw = lookupCaseInsensitive(provider.modelResponsesTerminalRepair, modelId);
if (raw.kind === "ambiguous") return undefined;
if (raw.kind === "value") {
const grace = typeof raw.value === "number" ? raw.value : (typeof raw.value === "object" && raw.value && "graceMs" in raw.value ? (raw.value 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.kind === "ambiguous") return undefined;
if (rawModel.kind === "value") {
const grace = typeof rawModel.value === "number" ? rawModel.value : (typeof rawModel.value === "object" && rawModel.value && "graceMs" in rawModel.value ? (rawModel.value 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) };
}

/**
Expand Down
27 changes: 27 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
requestPacingConfigError,
retryOn429PolicyConfigError,
sanitizeModelCostsForDisplay,
modelResponsesCompatibilityConfigError,
modelResponsesTerminalRepairConfigError,
responsesTerminalRepairConfigError,
} from "../config";
import {
apiKeyTransportConfigError,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -711,6 +735,9 @@ export function safeConfigDTO(config: OcxConfig): unknown {
"modelMaxOutputTokens",
"openRouterRouting",
"modelOpenRouterRouting",
"modelResponsesCompatibility",
"modelResponsesTerminalRepair",
"responsesTerminalRepair",
"reasoningEfforts",
"modelReasoningEfforts",
"reasoningWireFormat",
Expand Down
Loading
Loading