diff --git a/changelog.d/features/6354-per-model-timeout.md b/changelog.d/features/6354-per-model-timeout.md new file mode 100644 index 0000000000..9537a5d0f1 --- /dev/null +++ b/changelog.d/features/6354-per-model-timeout.md @@ -0,0 +1 @@ +- feat(sse): configurable per-model upstream header-response timeout override, precedence model > provider > global; applied to codex reasoning-heavy tiers (gpt-5.5-high/xhigh, gpt-5.6-\*-high/xhigh) (#6354) diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index ac8c0aa48d..6dc6ff9654 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -73,6 +73,21 @@ export function getModelsByProviderId(providerId: string): RegistryModel[] { return PROVIDER_MODELS[alias] || []; } +/** + * Model-level upstream header-response timeout override, when the registry + * entry for `modelId` sets one (#6354). Returns `undefined` when the model + * isn't found or has no override, so callers can fall through to the + * provider-level/global defaults unchanged. + */ +export function getModelTimeoutMs(aliasOrId: string, modelId: string): number | undefined { + // Callers (e.g. chatCore's timeout resolution) pass the raw provider id + // ("codex"), not the public alias ("cx") that PROVIDER_MODELS is keyed by + // — resolve id→alias the same way getProviderModels()/getModelsByProviderId() + // do, so the override actually resolves (#6354). + const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; + return getProviderModel(alias, modelId)?.timeoutMs; +} + const CLAUDE_MODEL_PATTERN = /(?:^|[\/._-])claude(?:[._-]|$)/; const CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS = [/(?:^|[\/._-])haiku(?:[._-]|$)/] as const; const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index 01a43c71fb..7d6fee4557 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -43,11 +43,15 @@ export const codexProvider: RegistryEntry = { id: "gpt-5.6-sol-xhigh", name: "GPT 5.6 Sol (xHigh)", ...GPT_5_6_CODEX_CAPABILITIES, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.6-sol-high", name: "GPT 5.6 Sol (High)", ...GPT_5_6_CODEX_CAPABILITIES, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.6-sol-medium", @@ -78,11 +82,15 @@ export const codexProvider: RegistryEntry = { id: "gpt-5.6-terra-xhigh", name: "GPT 5.6 Terra (xHigh)", ...GPT_5_6_CODEX_CAPABILITIES, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.6-terra-high", name: "GPT 5.6 Terra (High)", ...GPT_5_6_CODEX_CAPABILITIES, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.6-terra-medium", @@ -108,11 +116,15 @@ export const codexProvider: RegistryEntry = { id: "gpt-5.6-luna-xhigh", name: "GPT 5.6 Luna (xHigh)", ...GPT_5_6_CODEX_CAPABILITIES, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.6-luna-high", name: "GPT 5.6 Luna (High)", ...GPT_5_6_CODEX_CAPABILITIES, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.6-luna-medium", @@ -149,6 +161,8 @@ export const codexProvider: RegistryEntry = { // #6191: input cap per reporter; TODO confirm exact value maxInputTokens: 272000, maxOutputTokens: 128000, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.5-high", @@ -158,6 +172,8 @@ export const codexProvider: RegistryEntry = { // #6191: input cap per reporter; TODO confirm exact value maxInputTokens: 272000, maxOutputTokens: 128000, + // #6354: reasoning-heavy tier — more header-wait room than the global default. + timeoutMs: 1200000, }, { id: "gpt-5.5-medium", diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index d022b86169..fd9d809381 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -71,6 +71,9 @@ export interface RegistryModel { * reasoning_content instead of failing with a DeepSeek 400 (#2900). */ interleavedField?: string; + /** Per-model upstream header-response timeout override — precedes + * `RegistryEntry.timeoutMs` and the global `FETCH_TIMEOUT_MS` (#6354). */ + timeoutMs?: number; } // Reasoning models reject temperature, top_p, penalties, logprobs, n. diff --git a/open-sse/handlers/chatCore/upstreamTimeouts.ts b/open-sse/handlers/chatCore/upstreamTimeouts.ts index 6f3636bb1f..0c63c53346 100644 --- a/open-sse/handlers/chatCore/upstreamTimeouts.ts +++ b/open-sse/handlers/chatCore/upstreamTimeouts.ts @@ -1,4 +1,5 @@ import { FETCH_TIMEOUT_MS } from "../../config/constants.ts"; +import { getModelTimeoutMs } from "../../config/providerModels.ts"; import { getLoggedInputTokens, getLoggedOutputTokens, @@ -62,7 +63,16 @@ export function computeBillableTokens(usage: unknown): number { return getLoggedInputTokens(usage) + getLoggedOutputTokens(usage) + getReasoningTokens(usage); } -export function getExecutorTimeoutMs(executor: unknown): number { +/** Resolves the model-level `timeoutMs` registry override, when both + * `provider` and `model` are known and the model registers one (#6354). */ +function resolveModelTimeoutOverride(provider?: string, model?: string): number | undefined { + if (!provider || !model) return undefined; + const override = getModelTimeoutMs(provider, model); + if (typeof override !== "number" || !Number.isFinite(override)) return undefined; + return Math.max(0, Math.floor(override)); +} + +function resolveProviderTimeoutMs(executor: unknown): number { const getTimeoutMs = (executor as { getTimeoutMs?: () => unknown } | null)?.getTimeoutMs; if (typeof getTimeoutMs !== "function") return FETCH_TIMEOUT_MS; @@ -75,6 +85,19 @@ export function getExecutorTimeoutMs(executor: unknown): number { } } +/** + * Resolves the upstream header-response timeout in precedence order: + * model-level override (registry `RegistryModel.timeoutMs`) → provider-level + * override (`executor.getTimeoutMs()`) → global `FETCH_TIMEOUT_MS` default. + * `provider`/`model` are optional so existing single-argument call sites + * keep resolving to the provider/global chain unchanged (#6354). + */ +export function getExecutorTimeoutMs(executor: unknown, provider?: string, model?: string): number { + const modelOverride = resolveModelTimeoutOverride(provider, model); + if (modelOverride !== undefined) return modelOverride; + return resolveProviderTimeoutMs(executor); +} + export function normalizeExecutorResult( result: | Response @@ -111,7 +134,7 @@ export async function executeWithUpstreamStartTimeout({ log?: { warn?: (tag: string, message: string) => void } | null; execute: (signal: AbortSignal) => Promise; }): Promise { - const timeoutMs = getExecutorTimeoutMs(executor); + const timeoutMs = getExecutorTimeoutMs(executor, provider, model); if (timeoutMs <= 0) return execute(signal); if (signal.aborted) throw createAbortError(signal); diff --git a/tests/unit/upstream-timeout-model-override.test.ts b/tests/unit/upstream-timeout-model-override.test.ts new file mode 100644 index 0000000000..ec3a05b43f --- /dev/null +++ b/tests/unit/upstream-timeout-model-override.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getExecutorTimeoutMs } from "../../open-sse/handlers/chatCore/upstreamTimeouts.ts"; +import { FETCH_TIMEOUT_MS } from "../../open-sse/config/constants.ts"; +import { getModelTimeoutMs } from "../../open-sse/config/providerModels.ts"; + +test("model-level timeoutMs wins over provider-level and global", () => { + const executor = { getTimeoutMs: () => 30000 }; + const result = getExecutorTimeoutMs(executor, "codex", "gpt-5.5-high"); + assert.equal(result, 1200000); + assert.notEqual(result, 30000); + assert.notEqual(result, FETCH_TIMEOUT_MS); +}); + +test("provider-level timeoutMs still wins over global when no model override exists (regression guard)", () => { + const executor = { getTimeoutMs: () => 45000 }; + // No model registered for this provider/model combo -> no model override, + // must still fall back to the provider-level executor.getTimeoutMs(). + const result = getExecutorTimeoutMs(executor, "codex", "gpt-5.5-medium"); + assert.equal(result, 45000); +}); + +test("global FETCH_TIMEOUT_MS remains the fallback with neither override", () => { + const executor = {}; + const result = getExecutorTimeoutMs(executor, "codex", "gpt-5.5-medium"); + assert.equal(result, FETCH_TIMEOUT_MS); +}); + +test("getExecutorTimeoutMs is backward compatible when called with only an executor", () => { + const executor = { getTimeoutMs: () => 5000 }; + assert.equal(getExecutorTimeoutMs(executor), 5000); +}); + +test("gpt-5.5-high resolves to its override; gpt-5.5-medium resolves to the provider/global default (reported-scenario guard)", () => { + const executor = {}; + const highResult = getExecutorTimeoutMs(executor, "codex", "gpt-5.5-high"); + const mediumResult = getExecutorTimeoutMs(executor, "codex", "gpt-5.5-medium"); + assert.equal(highResult, 1200000); + assert.equal(mediumResult, FETCH_TIMEOUT_MS); + assert.notEqual(highResult, mediumResult); +}); + +test("getModelTimeoutMs returns the registered override for reasoning-heavy codex tiers", () => { + assert.equal(getModelTimeoutMs("codex", "gpt-5.5-high"), 1200000); + assert.equal(getModelTimeoutMs("codex", "gpt-5.5-xhigh"), 1200000); + assert.equal(getModelTimeoutMs("codex", "gpt-5.5-medium"), undefined); + assert.equal(getModelTimeoutMs("codex", "nonexistent-model"), undefined); +});