fix(kimi): recupera limite temporario sem bloquear conta (#10058)

This commit is contained in:
Diego Bortoli
2026-08-13 01:00:09 -03:00
committed by GitHub
parent 3b41e795fc
commit fffeb14e40
3 changed files with 108 additions and 2 deletions

View File

@@ -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(

View File

@@ -0,0 +1,42 @@
type JsonRecord = Record<string, unknown>;
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;
}

View File

@@ -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);
});