mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #214 from DavyMassoneto/fix/claude-oauth-usage-endpoint
fix: harden quota system — code review fixes + build fix
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -117,3 +117,8 @@ icon.iconset/
|
||||
|
||||
# VS Code Extension (independent Git repo)
|
||||
vscode-extension/
|
||||
|
||||
# SQLite residual files
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.sqlite-journal
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
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
|
||||
@@ -147,6 +152,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ conn
|
||||
const usage = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
getUsageForProvider(connection)
|
||||
);
|
||||
|
||||
// Populate quota cache for quota-aware account selection
|
||||
if (isRecord(usage?.quotas)) {
|
||||
setQuotaCache(connectionId, connection.provider, usage.quotas);
|
||||
}
|
||||
|
||||
return Response.json(usage);
|
||||
} catch (error) {
|
||||
console.error("[Usage API] Error fetching usage:", error);
|
||||
|
||||
@@ -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 *));
|
||||
|
||||
|
||||
264
src/domain/quotaCache.ts
Normal file
264
src/domain/quotaCache.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Quota Cache — Domain Layer
|
||||
*
|
||||
* In-memory cache of provider quota data per connectionId.
|
||||
* Populated by:
|
||||
* - Dashboard usage endpoint (GET /api/usage/[connectionId])
|
||||
* - 429 responses marking account as exhausted
|
||||
*
|
||||
* Background refresh runs every 1 minute:
|
||||
* - Active accounts (quota > 0%): refetch every 5 minutes
|
||||
* - Exhausted accounts: refetch every 5 minutes (or immediately after resetAt passes)
|
||||
*
|
||||
* @module domain/quotaCache
|
||||
*/
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface QuotaInfo {
|
||||
remainingPercentage: number;
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
interface QuotaCacheEntry {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
quotas: Record<string, QuotaInfo>;
|
||||
fetchedAt: number;
|
||||
exhausted: boolean;
|
||||
nextResetAt: string | null;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 = 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
|
||||
const entries = Object.values(quotas);
|
||||
if (entries.length === 0) return false;
|
||||
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) continue;
|
||||
const ms = parseDate(q.resetAt);
|
||||
if (ms !== null && ms < earliestMs) {
|
||||
earliestMs = ms;
|
||||
earliest = q.resetAt;
|
||||
}
|
||||
}
|
||||
return earliest;
|
||||
}
|
||||
|
||||
function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaInfo> {
|
||||
const result: Record<string, QuotaInfo> = {};
|
||||
for (const [key, q] of Object.entries(rawQuotas)) {
|
||||
if (q && typeof q === "object") {
|
||||
result[key] = {
|
||||
remainingPercentage:
|
||||
safePercentage(q.remainingPercentage) ??
|
||||
(q.total > 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0),
|
||||
resetAt: q.resetAt || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Store quota data for a connection (called by usage endpoint and background refresh).
|
||||
*/
|
||||
export function setQuotaCache(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
rawQuotas: Record<string, any>
|
||||
) {
|
||||
const quotas = normalizeQuotas(rawQuotas);
|
||||
const exhausted = isExhausted(quotas);
|
||||
cache.set(connectionId, {
|
||||
connectionId,
|
||||
provider,
|
||||
quotas,
|
||||
fetchedAt: Date.now(),
|
||||
exhausted,
|
||||
nextResetAt: exhausted ? earliestResetAt(quotas) : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached quota entry (returns null if not cached).
|
||||
*/
|
||||
export function getQuotaCache(connectionId: string): QuotaCacheEntry | null {
|
||||
return cache.get(connectionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account's quota is exhausted based on cached data.
|
||||
* Returns false if no cache entry exists (unknown = assume available).
|
||||
*/
|
||||
export function isAccountQuotaExhausted(connectionId: string): boolean {
|
||||
const entry = cache.get(connectionId);
|
||||
if (!entry) return false;
|
||||
if (!entry.exhausted) return false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Exhausted entries without resetAt expire after fixed TTL
|
||||
const age = Date.now() - entry.fetchedAt;
|
||||
if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an account as quota-exhausted from a 429 response (no quota data available).
|
||||
* Uses 5-minute fixed TTL since we don't know the actual resetAt.
|
||||
*/
|
||||
export function markAccountExhaustedFrom429(connectionId: string, provider: string) {
|
||||
cache.set(connectionId, {
|
||||
connectionId,
|
||||
provider,
|
||||
quotas: {},
|
||||
fetchedAt: Date.now(),
|
||||
exhausted: true,
|
||||
nextResetAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
cache.delete(entry.connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyInfo = await resolveProxyForConnection(entry.connectionId);
|
||||
const usage = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
getUsageForProvider(connection)
|
||||
);
|
||||
|
||||
if (usage?.quotas) {
|
||||
setQuotaCache(entry.connectionId, entry.provider, usage.quotas);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[QuotaCache] Refresh failed for ${entry.connectionId.slice(0, 8)}:`,
|
||||
(err as any)?.message || err
|
||||
);
|
||||
} finally {
|
||||
refreshingSet.delete(entry.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the background refresh timer.
|
||||
*/
|
||||
export function startBackgroundRefresh() {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = setInterval(backgroundRefreshTick, REFRESH_INTERVAL_MS);
|
||||
refreshTimer?.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the background refresh timer.
|
||||
*/
|
||||
export function stopBackgroundRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache stats (for debugging/dashboard).
|
||||
*/
|
||||
export function getQuotaCacheStats() {
|
||||
const entries: Array<{
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
exhausted: boolean;
|
||||
nextResetAt: string | null;
|
||||
ageMs: number;
|
||||
}> = [];
|
||||
|
||||
for (const entry of cache.values()) {
|
||||
entries.push({
|
||||
connectionId: entry.connectionId.slice(0, 8) + "...",
|
||||
provider: entry.provider,
|
||||
exhausted: entry.exhausted,
|
||||
nextResetAt: entry.nextResetAt,
|
||||
ageMs: Date.now() - entry.fetchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { total: cache.size, entries };
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export async function register() {
|
||||
const { initApiBridgeServer } = await import("@/lib/apiBridgeServer");
|
||||
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 {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,12 @@ import { sanitizeRequest } from "../../shared/utils/inputSanitizer";
|
||||
|
||||
// Pipeline integration — wired modules
|
||||
import { getCircuitBreaker, CircuitBreakerOpenError } from "../../shared/utils/circuitBreaker";
|
||||
import { isModelAvailable, setModelUnavailable } from "../../domain/modelAvailability";
|
||||
import {
|
||||
isModelAvailable,
|
||||
setModelUnavailable,
|
||||
clearModelUnavailability,
|
||||
} from "../../domain/modelAvailability";
|
||||
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
|
||||
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
|
||||
import { generateRequestId } from "../../shared/utils/requestId";
|
||||
import { recordCost } from "../../domain/costRules";
|
||||
@@ -127,7 +132,10 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
telemetry.startPhase("policy");
|
||||
const policy = await enforceApiKeyPolicy(request, modelStr);
|
||||
if (policy.rejection) {
|
||||
log.warn("POLICY", `API key policy rejected: ${modelStr} (key=${policy.apiKeyInfo?.id || "unknown"})`);
|
||||
log.warn(
|
||||
"POLICY",
|
||||
`API key policy rejected: ${modelStr} (key=${policy.apiKeyInfo?.id || "unknown"})`
|
||||
);
|
||||
return policy.rejection;
|
||||
}
|
||||
const apiKeyInfo = policy.apiKeyInfo;
|
||||
@@ -243,6 +251,13 @@ async function handleSingleModelChat(
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionId);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (lastStatus === 429 || lastStatus === 503) {
|
||||
setModelUnavailable(provider, model, 60000, `HTTP ${lastStatus}`);
|
||||
log.info(
|
||||
"AVAILABILITY",
|
||||
`${provider}/${model} marked unavailable — all accounts exhausted (HTTP ${lastStatus})`
|
||||
);
|
||||
}
|
||||
return handleNoCredentials(
|
||||
credentials,
|
||||
excludeConnectionId,
|
||||
@@ -296,22 +311,19 @@ async function handleSingleModelChat(
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
clearModelUnavailability(provider, model);
|
||||
recordCostIfNeeded(apiKeyInfo, result);
|
||||
if (telemetry) telemetry.startPhase("finalize");
|
||||
if (telemetry) telemetry.endPhase();
|
||||
return result.response;
|
||||
}
|
||||
|
||||
// Pipeline: Mark model unavailable on repeated failures
|
||||
if (result.status === 429 || result.status === 503) {
|
||||
setModelUnavailable(provider, model, 60000, `HTTP ${result.status}`);
|
||||
log.info(
|
||||
"AVAILABILITY",
|
||||
`${provider}/${model} marked unavailable for 60s (HTTP ${result.status})`
|
||||
);
|
||||
// 6. Mark account as quota-exhausted on 429 response
|
||||
if (result.status === 429) {
|
||||
markAccountExhaustedFrom429(credentials.connectionId, provider);
|
||||
}
|
||||
|
||||
// 6. Fallback to next account
|
||||
// 7. Fallback to next account
|
||||
const { shouldFallback } = await markAccountUnavailable(
|
||||
credentials.connectionId,
|
||||
result.status,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
updateProviderConnection,
|
||||
getSettings,
|
||||
} from "@/lib/localDb";
|
||||
import { isAccountQuotaExhausted } from "@/domain/quotaCache";
|
||||
import {
|
||||
isAccountUnavailable,
|
||||
getUnavailableUntil,
|
||||
@@ -197,6 +198,19 @@ export async function getProviderCredentials(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quota-aware: prioritize accounts with available quota
|
||||
const withQuota = availableConnections.filter((c) => !isAccountQuotaExhausted(c.id));
|
||||
const exhaustedQuota = availableConnections.filter((c) => isAccountQuotaExhausted(c.id));
|
||||
const orderedConnections =
|
||||
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : availableConnections;
|
||||
|
||||
if (exhaustedQuota.length > 0) {
|
||||
log.debug(
|
||||
"AUTH",
|
||||
`${provider} | quota-aware: ${withQuota.length} with quota, ${exhaustedQuota.length} exhausted`
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await getSettings();
|
||||
const strategy = settings.fallbackStrategy || "fill-first";
|
||||
|
||||
@@ -205,7 +219,7 @@ export async function getProviderCredentials(
|
||||
const stickyLimit = toNumber((settings as Record<string, unknown>).stickyRoundRobinLimit, 3);
|
||||
|
||||
// Sort by lastUsed (most recent first) to find current candidate
|
||||
const byRecency = [...availableConnections].sort((a: any, b: any) => {
|
||||
const byRecency = [...orderedConnections].sort((a: any, b: any) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return 1;
|
||||
if (!b.lastUsedAt) return -1;
|
||||
@@ -225,7 +239,7 @@ export async function getProviderCredentials(
|
||||
});
|
||||
} else {
|
||||
// Pick the least recently used (excluding current if possible)
|
||||
const sortedByOldest = [...availableConnections].sort((a: any, b: any) => {
|
||||
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return -1;
|
||||
if (!b.lastUsedAt) return 1;
|
||||
@@ -242,14 +256,14 @@ export async function getProviderCredentials(
|
||||
}
|
||||
} else if (strategy === "p2c") {
|
||||
// Power of Two Choices: pick 2 random, choose the one with fewer failures
|
||||
if (availableConnections.length <= 2) {
|
||||
connection = availableConnections[0];
|
||||
if (orderedConnections.length <= 2) {
|
||||
connection = orderedConnections[0];
|
||||
} else {
|
||||
const i = Math.floor(Math.random() * availableConnections.length);
|
||||
let j = Math.floor(Math.random() * (availableConnections.length - 1));
|
||||
const i = Math.floor(Math.random() * orderedConnections.length);
|
||||
let j = Math.floor(Math.random() * (orderedConnections.length - 1));
|
||||
if (j >= i) j++;
|
||||
const a = availableConnections[i];
|
||||
const b = availableConnections[j];
|
||||
const a = orderedConnections[i];
|
||||
const b = orderedConnections[j];
|
||||
// Prefer the one with fewer consecutive uses / better health
|
||||
const scoreA = (a.consecutiveUseCount || 0) + (a.lastError ? 10 : 0);
|
||||
const scoreB = (b.consecutiveUseCount || 0) + (b.lastError ? 10 : 0);
|
||||
@@ -257,11 +271,11 @@ export async function getProviderCredentials(
|
||||
}
|
||||
} else if (strategy === "random") {
|
||||
// Random: Fisher-Yates-inspired random pick
|
||||
const idx = Math.floor(Math.random() * availableConnections.length);
|
||||
connection = availableConnections[idx];
|
||||
const idx = Math.floor(Math.random() * orderedConnections.length);
|
||||
connection = orderedConnections[idx];
|
||||
} else if (strategy === "least-used") {
|
||||
// Least Used: pick the one with oldest lastUsedAt
|
||||
const sorted = [...availableConnections].sort((a, b) => {
|
||||
const sorted = [...orderedConnections].sort((a, b) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return -1;
|
||||
if (!b.lastUsedAt) return 1;
|
||||
@@ -271,13 +285,13 @@ export async function getProviderCredentials(
|
||||
} else if (strategy === "cost-optimized") {
|
||||
// Cost Optimized: sort by priority ascending (lower = cheaper/preferred)
|
||||
// Future: can be enhanced with actual cost data per provider
|
||||
const sorted = [...availableConnections].sort(
|
||||
const sorted = [...orderedConnections].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
connection = sorted[0];
|
||||
} else {
|
||||
// Default: fill-first (already sorted by priority in getProviderConnections)
|
||||
connection = availableConnections[0];
|
||||
connection = orderedConnections[0];
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user