diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0f93991bd2..d643961e9e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -214,6 +214,7 @@ import { stageTrace } from "./chatCore/stageTrace.ts"; import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts"; import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts"; import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts"; +import { getKimiTemporaryRateLimitResetAt } from "./chatCore/kimiQuotaRecovery.ts"; import { getCallLogPipelineCaptureStreamChunks, getCallLogPipelineMaxSizeBytes, @@ -3734,8 +3735,25 @@ export async function handleChatCore({ ); } } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { + // Kimi's 403 says "billing cycle" for both an exhausted subscription and a + // temporary request window. Read its official usage endpoint before making + // the connection terminal: a non-zero Weekly quota plus an empty Ratelimit + // window must recover automatically at the reported reset time. + let kimiRateLimitResetAt: string | null = null; + if (provider === "kimi-coding") { + try { + const { fetchAndPersistProviderLimits } = await import("@/lib/usage/providerLimits"); + const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual"); + kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage); + } catch { + // Preserve the existing quota handling when Kimi's usage endpoint is unavailable. + } + } + // Providers with per-model quotas — lock the model only, not the connection - const quotaCooldownMs = retryAfterMs || COOLDOWN_MS.rateLimit; + const quotaCooldownMs = kimiRateLimitResetAt + ? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0) + : retryAfterMs || COOLDOWN_MS.rateLimit; const accountSemaphoreKey = resolveAccountSemaphoreKey({ provider, model: currentModel, @@ -3745,7 +3763,19 @@ export async function handleChatCore({ if (accountSemaphoreKey) { markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs); } - if (isModelScope() && errorConnectionId) { + if (kimiRateLimitResetAt) { + await updateProviderConnection(errorConnectionId, { + testStatus: "unavailable", + rateLimitedUntil: kimiRateLimitResetAt, + backoffLevel: 0, + lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED, + lastError: message, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}` + ); + } else if (isModelScope() && errorConnectionId) { const lockFn = provider === "antigravity" ? lockExactModel : lockModel; lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs); console.warn( diff --git a/open-sse/handlers/chatCore/kimiQuotaRecovery.ts b/open-sse/handlers/chatCore/kimiQuotaRecovery.ts new file mode 100644 index 0000000000..66ceedbf4d --- /dev/null +++ b/open-sse/handlers/chatCore/kimiQuotaRecovery.ts @@ -0,0 +1,42 @@ +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function remaining(value: JsonRecord | null): number | null { + if (!value) return null; + const candidate = value.remaining ?? value.remainingPercentage; + const parsed = typeof candidate === "number" ? candidate : Number(candidate); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Kimi uses the same 403 wording for two different conditions: + * a depleted weekly subscription and a temporary request window. The latter + * must stay recoverable, otherwise a healthy subscription is marked terminal. + */ +export function getKimiTemporaryRateLimitResetAt( + usage: unknown, + nowMs = Date.now() +): string | null { + const quotas = asRecord(asRecord(usage)?.quotas); + const rateLimit = asRecord(quotas?.Ratelimit); + const weekly = asRecord(quotas?.Weekly); + const rateLimitRemaining = remaining(rateLimit); + const weeklyRemaining = remaining(weekly); + const resetAt = typeof rateLimit?.resetAt === "string" ? rateLimit.resetAt : null; + const resetMs = resetAt ? new Date(resetAt).getTime() : NaN; + + if ( + rateLimitRemaining !== 0 || + weeklyRemaining === null || + weeklyRemaining <= 0 || + !Number.isFinite(resetMs) || + resetMs <= nowMs + ) { + return null; + } + + return resetAt; +} diff --git a/tests/unit/kimi-temporary-rate-limit.test.ts b/tests/unit/kimi-temporary-rate-limit.test.ts new file mode 100644 index 0000000000..809f9eceea --- /dev/null +++ b/tests/unit/kimi-temporary-rate-limit.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getKimiTemporaryRateLimitResetAt } from "../../open-sse/handlers/chatCore/kimiQuotaRecovery.ts"; + +const NOW = Date.parse("2026-08-11T01:00:00.000Z"); +const RESET_AT = "2026-08-11T04:49:07.783Z"; + +test("Kimi temporary request limit stays recoverable when weekly quota remains", () => { + const resetAt = getKimiTemporaryRateLimitResetAt( + { + quotas: { + Ratelimit: { remaining: 0, resetAt: RESET_AT }, + Weekly: { remaining: 14 }, + }, + }, + NOW + ); + + assert.equal(resetAt, RESET_AT); +}); + +test("Kimi depleted weekly quota is not mistaken for a temporary request limit", () => { + const resetAt = getKimiTemporaryRateLimitResetAt( + { + quotas: { + Ratelimit: { remaining: 0, resetAt: RESET_AT }, + Weekly: { remaining: 0 }, + }, + }, + NOW + ); + + assert.equal(resetAt, null); +});