fix(claude): correct utilization semantics, harden quota cache, fix premature model unavailability

- Fix inverted Claude OAuth utilization (remaining, not used)
- Add hasUtilization() guard to prevent false exhaustion from empty responses
- Centralize anthropic-version into CLAUDE_CONFIG.apiVersion
- Add parseDate() for safe date validation in quota cache
- Batch background refresh with MAX_CONCURRENT_REFRESHES=5
- Move setModelUnavailable to after all accounts exhausted, not first 429
- Extract safePercentage() to shared utils (dedup)
- Use isRecord() type guard in usage API route
- Exclude binary files from Tailwind v4 source scanning
This commit is contained in:
DavyMassoneto
2026-03-05 18:43:35 -03:00
parent 11bcdd810a
commit bfe495931f
10 changed files with 115 additions and 75 deletions

5
.gitignore vendored
View File

@@ -117,3 +117,8 @@ icon.iconset/
# VS Code Extension (independent Git repo)
vscode-extension/
# SQLite residual files
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal

View File

@@ -3,6 +3,7 @@
*/
import { PROVIDERS } from "../config/constants.ts";
import { safePercentage } from "@/shared/utils/formatting";
// GitHub API config
const GITHUB_CONFIG = {
@@ -34,6 +35,7 @@ const CLAUDE_CONFIG = {
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
settingsUrl: "https://api.anthropic.com/v1/settings",
apiVersion: "2023-06-01",
};
type JsonRecord = Record<string, unknown>;
@@ -469,7 +471,7 @@ async function getClaudeUsage(accessToken) {
headers: {
Authorization: `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
"anthropic-version": "2023-06-01",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
},
});
@@ -477,36 +479,34 @@ async function getClaudeUsage(accessToken) {
const data = await oauthResponse.json();
const quotas: Record<string, any> = {};
// utilization = percentage USED (e.g., 22 means 22% used, 78% remaining)
// utilization = percentage REMAINING (e.g., 90 means 90% remaining, 10% used)
const hasUtilization = (window: any) =>
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
const createQuotaObject = (window: any) => {
const used = window?.utilization ?? 0;
const remaining = 100 - used;
const remaining = safePercentage(window.utilization) as number;
const used = 100 - remaining;
return {
used,
total: 100,
remaining,
resetAt: parseResetTime(window?.resets_at),
resetAt: parseResetTime(window.resets_at),
remainingPercentage: remaining,
unlimited: false,
};
};
if (data.five_hour && typeof data.five_hour === "object") {
if (hasUtilization(data.five_hour)) {
quotas["session (5h)"] = createQuotaObject(data.five_hour);
}
if (data.seven_day && typeof data.seven_day === "object") {
if (hasUtilization(data.seven_day)) {
quotas["weekly (7d)"] = createQuotaObject(data.seven_day);
}
// Parse model-specific weekly windows (e.g., seven_day_sonnet, seven_day_opus)
for (const [key, value] of Object.entries(data)) {
if (
key.startsWith("seven_day_") &&
key !== "seven_day" &&
value &&
typeof value === "object"
) {
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) {
const modelName = key.replace("seven_day_", "");
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value);
}
@@ -519,7 +519,10 @@ async function getClaudeUsage(accessToken) {
};
}
// Fallback: Try legacy settings/org endpoint (for API key users with org admin access)
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
console.warn(
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`
);
return await getClaudeUsageLegacy(accessToken);
} catch (error) {
return { message: `Claude connected. Unable to fetch usage: ${(error as any).message}` };
@@ -536,7 +539,7 @@ async function getClaudeUsageLegacy(accessToken) {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"anthropic-version": "2023-06-01",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
},
});
@@ -550,7 +553,7 @@ async function getClaudeUsageLegacy(accessToken) {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"anthropic-version": "2023-06-01",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
},
}
);

View File

@@ -150,10 +150,9 @@ export default function ProviderLimitCard({
{!loading && !error && !message && quotas?.length > 0 && (
<div className="space-y-4">
{quotas.map((quota, index) => {
// For Antigravity, use remainingPercentage if available, otherwise calculate
const percentage =
quota.remainingPercentage !== undefined
? Math.round(((quota.total - quota.used) / quota.total) * 100)
? Math.round(quota.remainingPercentage)
: calculatePercentage(quota.used, quota.total);
const unlimited = quota.total === 0 || quota.total === null;

View File

@@ -1,4 +1,5 @@
import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts";
import { safePercentage } from "@/shared/utils/formatting";
/**
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
@@ -110,7 +111,7 @@ export function parseQuotaData(provider, data) {
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
});
}
@@ -159,7 +160,7 @@ export function parseQuotaData(provider, data) {
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
});
}

View File

@@ -6,6 +6,10 @@ import { syncToCloud } from "@/lib/cloudSync";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { setQuotaCache } from "@/domain/quotaCache";
function isRecord(value: unknown): value is Record<string, any> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Sync to cloud if enabled
*/
@@ -150,7 +154,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ conn
);
// Populate quota cache for quota-aware account selection
if (usage?.quotas) {
if (isRecord(usage?.quotas)) {
setQuotaCache(connectionId, connection.provider, usage.quotas);
}

View File

@@ -6,6 +6,9 @@
directives ensure all utility classes in route groups are included. */
@source "../app/(dashboard)";
@source "../../open-sse";
@source not "../../*.sqlite*";
@source not "../../.claude*";
@source not "../../.claude-memory";
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -8,7 +8,7 @@
*
* Background refresh runs every 1 minute:
* - Active accounts (quota > 0%): refetch every 5 minutes
* - Exhausted accounts: refetch every 20 minutes (or immediately after resetAt passes)
* - Exhausted accounts: refetch every 5 minutes (or immediately after resetAt passes)
*
* @module domain/quotaCache
*/
@@ -16,6 +16,7 @@
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
import { getProviderConnectionById, resolveProxyForConnection } from "@/lib/localDb";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { safePercentage } from "@/shared/utils/formatting";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -37,13 +38,15 @@ interface QuotaCacheEntry {
const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts
const EXHAUSTED_TTL_MS = 5 * 60 * 1000; // 5 minutes for 429-sourced entries (no resetAt)
const EXHAUSTED_REFRESH_MS = 20 * 60 * 1000; // 20 minutes: recheck exhausted accounts
const EXHAUSTED_REFRESH_MS = 5 * 60 * 1000; // 5 minutes: recheck exhausted accounts (aligned with TTL)
const REFRESH_INTERVAL_MS = 60 * 1000; // Background tick every 1 minute
// ─── State ──────────────────────────────────────────────────────────────────
const cache = new Map<string, QuotaCacheEntry>();
const MAX_CONCURRENT_REFRESHES = 5;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let tickRunning = false;
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -53,10 +56,19 @@ function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
return entries.every((q) => q.remainingPercentage <= 0);
}
function parseDate(value: string): number | null {
const ms = new Date(value).getTime();
return Number.isNaN(ms) ? null : ms;
}
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
let earliest: string | null = null;
let earliestMs = Infinity;
for (const q of Object.values(quotas)) {
if (q.resetAt && (!earliest || new Date(q.resetAt) < new Date(earliest))) {
if (!q.resetAt) continue;
const ms = parseDate(q.resetAt);
if (ms !== null && ms < earliestMs) {
earliestMs = ms;
earliest = q.resetAt;
}
}
@@ -69,8 +81,8 @@ function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaIn
if (q && typeof q === "object") {
result[key] = {
remainingPercentage:
q.remainingPercentage ??
(q.total ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 100),
safePercentage(q.remainingPercentage) ??
(q.total > 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0),
resetAt: q.resetAt || null,
};
}
@@ -114,27 +126,19 @@ export function getQuotaCache(connectionId: string): QuotaCacheEntry | null {
export function isAccountQuotaExhausted(connectionId: string): boolean {
const entry = cache.get(connectionId);
if (!entry) return false;
if (!entry.exhausted) return false;
// If exhausted and we have a resetAt that has passed, consider it available
if (entry.exhausted && entry.nextResetAt) {
if (new Date(entry.nextResetAt).getTime() <= Date.now()) {
return false; // Reset time passed, assume available until refresh confirms
}
// If resetAt has passed, assume available until refresh confirms
if (entry.nextResetAt) {
const resetMs = parseDate(entry.nextResetAt);
if (resetMs !== null && resetMs <= Date.now()) return false;
}
// Check TTL based on state
// Exhausted entries without resetAt expire after fixed TTL
const age = Date.now() - entry.fetchedAt;
if (entry.exhausted) {
// Exhausted entries without resetAt use fixed TTL
if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false;
// Exhausted entries with resetAt stay valid until resetAt or refresh
return true;
}
if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false;
// Active entries expire after ACTIVE_TTL
if (age > ACTIVE_TTL_MS) return false;
return entry.exhausted;
return true;
}
/**
@@ -154,7 +158,12 @@ export function markAccountExhaustedFrom429(connectionId: string, provider: stri
// ─── Background Refresh ─────────────────────────────────────────────────────
const refreshingSet = new Set<string>();
async function refreshEntry(entry: QuotaCacheEntry) {
if (refreshingSet.has(entry.connectionId)) return;
refreshingSet.add(entry.connectionId);
try {
const connection = await getProviderConnectionById(entry.connectionId);
if (!connection || connection.authType !== "oauth" || !connection.isActive) {
@@ -170,33 +179,43 @@ async function refreshEntry(entry: QuotaCacheEntry) {
if (usage?.quotas) {
setQuotaCache(entry.connectionId, entry.provider, usage.quotas);
}
} catch {
// Refresh failed silently — keep stale entry
} catch (err) {
console.warn(
`[QuotaCache] Refresh failed for ${entry.connectionId.slice(0, 8)}:`,
(err as any)?.message || err
);
} finally {
refreshingSet.delete(entry.connectionId);
}
}
async function backgroundRefreshTick() {
const now = Date.now();
for (const entry of cache.values()) {
const age = now - entry.fetchedAt;
if (entry.exhausted) {
// If resetAt has passed, refetch immediately
if (entry.nextResetAt && new Date(entry.nextResetAt).getTime() <= now) {
refreshEntry(entry);
continue;
}
// Recheck exhausted accounts every 20 minutes
if (age >= EXHAUSTED_REFRESH_MS) {
refreshEntry(entry);
}
} else {
// Refresh active accounts every 5 minutes
if (age >= ACTIVE_TTL_MS) {
refreshEntry(entry);
}
function needsRefresh(entry: QuotaCacheEntry, now: number): boolean {
const age = now - entry.fetchedAt;
if (entry.exhausted) {
if (entry.nextResetAt) {
const resetMs = parseDate(entry.nextResetAt);
if (resetMs !== null && resetMs <= now) return true;
}
return age >= EXHAUSTED_REFRESH_MS;
}
return age >= ACTIVE_TTL_MS;
}
async function backgroundRefreshTick() {
if (tickRunning) return;
tickRunning = true;
try {
const now = Date.now();
const pending = [...cache.values()].filter((e) => needsRefresh(e, now));
// Refresh in batches to avoid thundering herd
for (let i = 0; i < pending.length; i += MAX_CONCURRENT_REFRESHES) {
const batch = pending.slice(i, i + MAX_CONCURRENT_REFRESHES);
await Promise.allSettled(batch.map(refreshEntry));
}
} finally {
tickRunning = false;
}
}
@@ -206,10 +225,7 @@ async function backgroundRefreshTick() {
export function startBackgroundRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(backgroundRefreshTick, REFRESH_INTERVAL_MS);
// Don't prevent process exit
if (refreshTimer && typeof refreshTimer === "object" && "unref" in refreshTimer) {
(refreshTimer as any).unref();
}
refreshTimer?.unref?.();
}
/**

View File

@@ -35,8 +35,11 @@ export async function register() {
initApiBridgeServer();
// Quota cache: start background refresh for quota-aware account selection
// Dynamic import required — quotaCache depends on better-sqlite3 (Node-only),
// and instrumentation.ts is bundled for all runtimes including Edge.
const { startBackgroundRefresh } = await import("@/domain/quotaCache");
startBackgroundRefresh();
console.log("[STARTUP] Quota cache background refresh started");
// Compliance: Initialize audit_log table + cleanup expired logs
try {

View File

@@ -148,3 +148,11 @@ export function truncateUrl(url, max = 50) {
return url.length > max ? url.slice(0, max) + "…" : url;
}
}
/**
* Safely extract a finite number, returning undefined for invalid values.
* Used by quota normalization in both backend (quotaCache) and frontend (ProviderLimits).
*/
export function safePercentage(value: unknown): number | undefined {
return typeof value === "number" && isFinite(value) ? value : undefined;
}

View File

@@ -36,7 +36,7 @@ import {
setModelUnavailable,
clearModelUnavailability,
} from "../../domain/modelAvailability";
import { getQuotaCache, markAccountExhaustedFrom429 } from "../../domain/quotaCache";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
import { generateRequestId } from "../../shared/utils/requestId";
import { recordCost } from "../../domain/costRules";
@@ -311,17 +311,15 @@ async function handleSingleModelChat(
});
if (result.success) {
if (excludeConnectionId) {
clearModelUnavailability(provider, model);
}
clearModelUnavailability(provider, model);
recordCostIfNeeded(apiKeyInfo, result);
if (telemetry) telemetry.startPhase("finalize");
if (telemetry) telemetry.endPhase();
return result.response;
}
// 6. Mark quota-exhausted from 429 if no cached quota data
if (result.status === 429 && !getQuotaCache(credentials.connectionId)) {
// 6. Mark account as quota-exhausted on 429 response
if (result.status === 429) {
markAccountExhaustedFrom429(credentials.connectionId, provider);
}