Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,71 @@ export function modelAdapterRecordConfigError(
return null;
}

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 @@ -1473,6 +1538,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",
Expand Down
82 changes: 78 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,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<T>(map: Record<string, T> | 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<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();
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) };
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
apiKeyTransportConfigError,
booleanRecordConfigError,
modelAdapterRecordConfigError,
modelResponsesCompatibilityConfigError,
modelResponsesTerminalRepairConfigError,
responsesTerminalRepairConfigError,
modelPreferHostedToolsConfigError,
codexAutoStartEnabled,
nonBlankStringArrayConfigError,
Expand Down Expand Up @@ -614,6 +617,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",
Expand Down Expand Up @@ -709,6 +718,9 @@ export function safeConfigDTO(config: OcxConfig): unknown {
"modelMaxOutputTokens",
"openRouterRouting",
"modelOpenRouterRouting",
"modelResponsesCompatibility",
"modelResponsesTerminalRepair",
"responsesTerminalRepair",
"reasoningEfforts",
"modelReasoningEfforts",
"reasoningWireFormat",
Expand Down
14 changes: 14 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, "terminal-repair">;
/**
* Explicit per-model terminal-repair grace period for native Responses streams (in ms).
*/
modelResponsesTerminalRepair?: Record<string, { graceMs: number } | number>;
/**
* 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.
Expand Down
Loading
Loading