From 07eb2ecdd96bbbf79535faf54a5b390bec2f6d07 Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:19:57 +0530 Subject: [PATCH] fix(providers): enrich model_cooldown 429 body with retry_after ISO + credential count (#6460) (#6523) fix(providers): enrich model_cooldown 429 body with retry_after ISO + credential count (#6460). Integrated into release/v3.8.47. (thanks @chirag127) --- config/quality/file-size-baseline.json | 9 +-- open-sse/utils/error.ts | 26 +++++++ src/sse/handlers/chatHelpers.ts | 6 ++ src/sse/services/auth.ts | 1 + src/types/provider.ts | 2 + .../unit/types-barrel-model-cooldown.test.ts | 68 +++++++++++++++++++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 4f9b334783..d9a132e8e6 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -259,8 +259,8 @@ "src/shared/validation/schemas.ts": 2523, "_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791993 (+4 = Zed hosted native-app sign-in modal branch). Cohesive UI growth for the zed-hosted OAuth provider; not extractable. The prior 6118 comment set the note but left the frozen value at 989.", "_rebaseline_2026_07_06_6351_glm_team_quota": "PR #6351 own growth (GLM team-plan quota fields threaded through the connection modals; new GlmTeamQuotaFields.tsx extracted): AddApiKeyModal.tsx ->951 (+9), EditConnectionModal.tsx ->1277 (+18). Absorbs the pre-existing session base-red on these frozen modals; release captain rebaseline-at-release supersedes.", - "_rebaseline_2026_07_06_6499_unique_default_name": "PR #6499 own growth: AddApiKeyModal.tsx 952->959 (+7 = a unique default connection name so a second API key for the same provider does not reuse 'main' and trigger the backend name-based upsert that silently overwrote the first connection). The pure name derivation was extracted to computeConnectionDefaultName.ts (unit-tested) to keep the growth minimal; the contributor's original full-form-reset rewrite was trimmed to a spread reset to avoid dropping the GLM team-quota fields #6351 added and to hold the frozen god-file growth down. Release captain rebaseline-at-release supersedes." -} + "_rebaseline_2026_07_06_6499_unique_default_name": "PR #6499 own growth: AddApiKeyModal.tsx 952->959 (+7 = a unique default connection name so a second API key for the same provider does not reuse 'main' and trigger the backend name-based upsert that silently overwrote the first connection). The pure name derivation was extracted to computeConnectionDefaultName.ts (unit-tested) to keep the growth minimal; the contributor's original full-form-reset rewrite was trimmed to a spread reset to avoid dropping the GLM team-quota fields #6351 added and to hold the frozen god-file growth down. Release captain rebaseline-at-release supersedes.", + "_rebaseline_2026_07_07_6523_chirag_cooldown_body": "PR #6523 (@chirag127, #6460) own growth: chatHelpers.ts 860->866 (+6 = retryAfterAt/credentialsCoolingCount fields on modelCooldownResponse) and auth.ts 2447->2448 (+1 = connectionsCount threaded through no-credentials fallback). Owner-approved rebaseline (file-size cap for contributor PR). Frozen (cannot grow further); release captain's rebaseline-at-release supersedes." +} \ No newline at end of file diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 9afc237715..24354451f7 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -408,11 +408,23 @@ export function providerCircuitOpenResponse( export function buildModelCooldownBody({ model, retryAfterSec, + retryAfterAt, + credentialsCoolingCount, }: { model?: string | null; retryAfterSec: number; + retryAfterAt?: string | null; + credentialsCoolingCount?: number | null; }): ModelCooldownErrorPayload { const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null; + const resolvedRetryAfterAt = + typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null; + const resolvedCoolingCount = + typeof credentialsCoolingCount === "number" && + Number.isFinite(credentialsCoolingCount) && + credentialsCoolingCount > 0 + ? Math.floor(credentialsCoolingCount) + : null; return { error: { @@ -423,6 +435,8 @@ export function buildModelCooldownBody({ code: "model_cooldown", ...(resolvedModel ? { model: resolvedModel } : {}), reset_seconds: Math.max(Math.ceil(retryAfterSec), 1), + ...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}), + ...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}), }, }; } @@ -430,16 +444,28 @@ export function buildModelCooldownBody({ export function modelCooldownResponse({ model, retryAfter, + retryAfterAt, + credentialsCoolingCount, }: { model?: string | null; retryAfter?: string | number | Date | null; + retryAfterAt?: string | null; + credentialsCoolingCount?: number | null; }) { const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); + const resolvedRetryAfterAt = + typeof retryAfterAt === "string" && retryAfterAt.length > 0 + ? retryAfterAt + : typeof retryAfter === "string" && retryAfter.length > 0 + ? retryAfter + : null; return new Response( JSON.stringify( buildModelCooldownBody({ model, retryAfterSec, + retryAfterAt: resolvedRetryAfterAt, + credentialsCoolingCount, }) ), { diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 87eb47a389..41924e05bb 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -577,6 +577,12 @@ export function handleNoCredentials( return modelCooldownResponse({ model: cooldownModel, retryAfter: credentials.retryAfter, + retryAfterAt: + typeof credentials.retryAfter === "string" ? credentials.retryAfter : null, + credentialsCoolingCount: + typeof credentials.connectionsCount === "number" + ? credentials.connectionsCount + : null, }); } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 3e002a1dfc..9282e423a7 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1377,6 +1377,7 @@ export async function getProviderCredentials( lastErrorCode: allBlockedByModelCooldown ? 429 : earliestConn?.errorCode || null, cooldownScope: allBlockedByModelCooldown ? "model" : "connection", cooldownModel: allBlockedByModelCooldown ? requestedModel : null, + connectionsCount: connections.length, }; } const syntheticFallback = await maybeSyntheticNoAuthFallback( diff --git a/src/types/provider.ts b/src/types/provider.ts index 051b1d780c..cad416b762 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -5,5 +5,7 @@ export interface ModelCooldownErrorPayload { code: "model_cooldown"; model?: string; reset_seconds: number; + retry_after?: string; + credentials_cooling?: number; }; } diff --git a/tests/unit/types-barrel-model-cooldown.test.ts b/tests/unit/types-barrel-model-cooldown.test.ts index d96dda9ac0..d1f156925b 100644 --- a/tests/unit/types-barrel-model-cooldown.test.ts +++ b/tests/unit/types-barrel-model-cooldown.test.ts @@ -14,3 +14,71 @@ test("types barrel supports the model cooldown error payload consumer", async () }, }); }); + +test("model cooldown body includes optional retry_after ISO + credentials_cooling count", async () => { + const { buildModelCooldownBody } = await import("../../open-sse/utils/error.ts"); + + const iso = "2026-07-07T12:34:56.000Z"; + assert.deepEqual( + buildModelCooldownBody({ + model: "openrouter/fusion", + retryAfterSec: 30, + retryAfterAt: iso, + credentialsCoolingCount: 3, + }), + { + error: { + message: "All credentials for model openrouter/fusion are cooling down", + type: "rate_limit_error", + code: "model_cooldown", + model: "openrouter/fusion", + reset_seconds: 30, + retry_after: iso, + credentials_cooling: 3, + }, + } + ); +}); + +test("model cooldown body omits retry_after / credentials_cooling when absent or invalid", async () => { + const { buildModelCooldownBody } = await import("../../open-sse/utils/error.ts"); + + assert.deepEqual( + buildModelCooldownBody({ + model: "x", + retryAfterSec: 5, + retryAfterAt: null, + credentialsCoolingCount: 0, + }), + { + error: { + message: "All credentials for model x are cooling down", + type: "rate_limit_error", + code: "model_cooldown", + model: "x", + reset_seconds: 5, + }, + } + ); +}); + +test("modelCooldownResponse emits HTTP 429 with Retry-After header and retry_after ISO in body (#6460)", async () => { + const { modelCooldownResponse } = await import("../../open-sse/utils/error.ts"); + + const iso = "2026-07-07T12:34:56.000Z"; + const res = modelCooldownResponse({ + model: "openrouter/fusion", + retryAfter: iso, + credentialsCoolingCount: 4, + }); + + assert.equal(res.status, 429); + assert.ok(res.headers.get("Retry-After"), "Retry-After header must be set"); + const body = await res.json(); + assert.equal(body.error.code, "model_cooldown"); + assert.equal(body.error.type, "rate_limit_error"); + assert.equal(body.error.model, "openrouter/fusion"); + assert.equal(body.error.retry_after, iso); + assert.equal(body.error.credentials_cooling, 4); + assert.equal(typeof body.error.reset_seconds, "number"); +});