Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/8632-kimi-quota-reset-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(providers):** Kimi Coding billing-cycle quota errors now recover automatically at the cached reset instead of remaining unavailable ([#8632](https://github.com/diegosouzapw/OmniRoute/pull/8632)) — thanks @glazec
6 changes: 4 additions & 2 deletions open-sse/services/errorClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isDailyQuotaExhausted,
isOAuthInvalidToken,
} from "./accountFallback.ts";
import { isSubscriptionQuotaText } from "./quotaTextCooldowns.ts";
import { getProviderCategory, getRegistryEntry } from "../config/providerRegistry.ts";

// Terminal stop signals where an empty content payload is still a legitimate,
Expand Down Expand Up @@ -136,15 +137,16 @@ export function classifyProviderError(
): string | null {
const bodyStr = responseBodyToString(responseBody);
const creditsExhausted = isCreditsExhausted(bodyStr);
const subscriptionQuotaExhausted = isSubscriptionQuotaText(bodyStr.toLowerCase());
const accountDeactivated = isAccountDeactivated(bodyStr);
const oauthInvalid = isOAuthInvalidToken(bodyStr);
const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider);

if (creditsExhausted && [400, 402, 403].includes(statusCode)) {
if ((creditsExhausted || subscriptionQuotaExhausted) && [400, 402, 403].includes(statusCode)) {
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
}

if (creditsExhausted && statusCode === 429 && preserveQuota429) {
if ((creditsExhausted || subscriptionQuotaExhausted) && statusCode === 429 && preserveQuota429) {
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
}

Expand Down
7 changes: 7 additions & 0 deletions src/lib/usage/providerLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,13 @@ export async function maybeClearRecoveredQuotaState(
): Promise<ProviderConnectionLike> {
if (!hasUsableQuota(usage)) return connection;
if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection;
if (
connection.lastErrorType === "quota_exhausted" &&
connection.rateLimitedUntil &&
new Date(connection.rateLimitedUntil).getTime() > Date.now()
) {
return connection;
}

const hasTransientState =
connection.testStatus === "unavailable" ||
Expand Down
18 changes: 17 additions & 1 deletion src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,12 @@ function getEarliestFutureDate(candidates: Array<string | null>): string | null
);
}

function getCachedQuotaResetAt(connectionId: string): string | null {
const entry = getQuotaCache(connectionId);
if (!entry?.quotas) return null;
return getEarliestFutureDate(Object.values(entry.quotas).map((quota) => quota.resetAt));
}

function isRetryableModelLockoutReason(reason: unknown): boolean {
return typeof reason === "string" && reason.length > 0
? !NON_RETRYABLE_MODEL_LOCKOUT_REASONS.has(reason)
Expand Down Expand Up @@ -2121,7 +2127,17 @@ export async function markAccountUnavailable(
providerErrorType,
provider
);
const cooldownMs = terminalStatus ? 0 : rawCooldownMs;
const cachedQuotaResetAt =
providerErrorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED ||
reason === RateLimitReason.QUOTA_EXHAUSTED
? getCachedQuotaResetAt(connectionId)
: null;
const cachedQuotaResetMs = parseFutureDateMs(cachedQuotaResetAt);
const cooldownMs = terminalStatus
? 0
: cachedQuotaResetMs
? cachedQuotaResetMs - Date.now()
: rawCooldownMs;

// ── #3027: per-model subscription/permission 403 → model-only lockout ──
if (isPerModelQuotaProvider && status === 403 && provider && model && !terminalStatus) {
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/error-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ test("classifyProviderError: 400 + billing signal => QUOTA_EXHAUSTED", () => {
assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED);
});

test("classifyProviderError: Kimi billing-cycle 403 => QUOTA_EXHAUSTED", () => {
const result = classifyProviderError(
403,
{
error: {
message:
"You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle.",
},
},
"kimi-coding"
);
assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED);
});

test("classifyProviderError: 429 without billing signal => RATE_LIMITED", () => {
const result = classifyProviderError(429, { error: { message: "too many requests" } });
assert.equal(result, PROVIDER_ERROR_TYPES.RATE_LIMITED);
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/kimi-quota-reset-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kimi-quota-reset-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-kimi-quota-reset-secret";

const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const auth = await import("../../src/sse/services/auth.ts");
const quotaCache = await import("../../src/domain/quotaCache.ts");

test.after(() => {
quotaCache.__clearForTests();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

test("Kimi billing-cycle quota errors remain active and recover at the cached reset", async () => {
await settingsDb.updateSettings({ autoDisableBannedAccounts: true });

const connection = await providersDb.createProviderConnection({
provider: "kimi-coding",
authType: "oauth",
accessToken: "kimi-access-token",
refreshToken: "kimi-refresh-token",
isActive: true,
testStatus: "active",
});
const connectionId = (connection as { id: string }).id;
const resetAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
quotaCache.setQuotaCache(connectionId, "kimi-coding", {
Ratelimit: { remainingPercentage: 0, resetAt },
Weekly: {
remainingPercentage: 62,
resetAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
},
});

const result = await auth.markAccountUnavailable(
connectionId,
403,
"You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle.",
"kimi-coding",
"kimi-for-coding"
);
const after = await providersDb.getProviderConnectionById(connectionId);

assert.equal(result.shouldFallback, true);
assert.ok(Math.abs(result.cooldownMs - 30 * 60 * 1000) < 2_000);
assert.equal(after.isActive, true);
assert.equal(after.testStatus, "unavailable");
assert.equal(after.lastErrorType, "quota_exhausted");
assert.ok(
Math.abs(new Date(after.rateLimitedUntil).getTime() - new Date(resetAt).getTime()) < 100
);
});
31 changes: 31 additions & 0 deletions tests/unit/provider-limits-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,37 @@ test("error-only quota response does not clear transient state", async () => {
assert.equal(updated.lastErrorType, "rate_limited");
});

test("partial quota refresh does not clear a quota cooldown before its reset", async () => {
const resetAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
const created = await providersDb.createProviderConnection({
provider: "kimi-coding",
authType: "oauth",
accessToken: "kimi-access-token",
refreshToken: "kimi-refresh-token",
testStatus: "unavailable",
isActive: true,
lastError: "usage limit reached",
lastErrorType: "quota_exhausted",
errorCode: 403,
rateLimitedUntil: resetAt,
backoffLevel: 1,
});
const connectionId = (created as { id: string }).id;
const connection = await providersDb.getProviderConnectionById(connectionId);

await providerLimits.maybeClearRecoveredQuotaState(connection, {
quotas: {
Ratelimit: { remainingPercentage: 0 },
Weekly: { remainingPercentage: 62 },
},
});
const after = await providersDb.getProviderConnectionById(connectionId);

assert.equal(after.testStatus, "unavailable");
assert.equal(after.lastErrorType, "quota_exhausted");
assert.equal(after.rateLimitedUntil, resetAt);
});

test("CAS primitive clears when expected state matches", async () => {
const created = await createGlmConnectionWithTransientCooldown();
const connectionId = (created as { id: string }).id;
Expand Down