From 9f7bcf4391a83e46137bb652e6506430f01db4aa Mon Sep 17 00:00:00 2001 From: benzntech Date: Wed, 12 Aug 2026 00:00:10 +0530 Subject: [PATCH] feat(quota): Phase 2 adapters, reset timers, analytics, and dashboard API --- src/app/api/settings/quota/state/route.ts | 77 +++++++++++++ src/lib/quota/quotaAdapters.ts | 125 ++++++++++++++++++++++ src/lib/quota/quotaAnalytics.ts | 114 ++++++++++++++++++++ src/lib/quota/quotaResetTimers.ts | 69 ++++++++++++ tests/unit/quota-phase2.test.ts | 67 ++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 src/app/api/settings/quota/state/route.ts create mode 100644 src/lib/quota/quotaAdapters.ts create mode 100644 src/lib/quota/quotaAnalytics.ts create mode 100644 src/lib/quota/quotaResetTimers.ts create mode 100644 tests/unit/quota-phase2.test.ts diff --git a/src/app/api/settings/quota/state/route.ts b/src/app/api/settings/quota/state/route.ts new file mode 100644 index 0000000000..7b58e168fd --- /dev/null +++ b/src/app/api/settings/quota/state/route.ts @@ -0,0 +1,77 @@ +/** + * /api/settings/quota/state — Dashboard visibility endpoint for provider quota states. + * + * GET: Returns live quota states, reset timers, and aggregated usage analytics. + * POST: Resets expired quota windows or purges a specific connection quota record. + * + * Part of: Quota-aware provider scheduling (Phase 2). + */ + +import { NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getQuotaAnalyticsSummary } from "@/lib/quota/quotaAnalytics"; +import { getActiveQuotaResetItems, resetExpiredQuotaWindows } from "@/lib/quota/quotaResetTimers"; +import { getProviderQuota, clearProviderQuotaState } from "@/lib/quota/providerQuotaState"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET() { + try { + const analytics = getQuotaAnalyticsSummary(); + const resetTimers = getActiveQuotaResetItems(); + + return NextResponse.json( + { + success: true, + analytics, + resetTimers, + timestamp: new Date().toISOString(), + }, + { headers: CORS_HEADERS } + ); + } catch (error) { + return NextResponse.json( + { success: false, error: (error as Error).message }, + { status: 500, headers: CORS_HEADERS } + ); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json().catch(() => ({})); + const { action, connectionId, model } = body as { + action?: string; + connectionId?: string; + model?: string; + }; + + if (action === "reset_expired") { + const resetCount = resetExpiredQuotaWindows(); + return NextResponse.json( + { success: true, resetCount, message: `Reset ${resetCount} expired quota windows.` }, + { headers: CORS_HEADERS } + ); + } + + if (action === "clear_connection" && connectionId && model) { + clearProviderQuotaState(connectionId, model); + return NextResponse.json( + { success: true, message: `Cleared quota state for connection ${connectionId} (${model}).` }, + { headers: CORS_HEADERS } + ); + } + + return NextResponse.json( + { success: false, error: "Invalid action or missing parameters" }, + { status: 400, headers: CORS_HEADERS } + ); + } catch (error) { + return NextResponse.json( + { success: false, error: (error as Error).message }, + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/lib/quota/quotaAdapters.ts b/src/lib/quota/quotaAdapters.ts new file mode 100644 index 0000000000..13a4de766c --- /dev/null +++ b/src/lib/quota/quotaAdapters.ts @@ -0,0 +1,125 @@ +/** + * quotaAdapters.ts — Provider-specific quota header adapters. + * + * Extracts and normalizes rate limit & token budget headers from provider HTTP responses + * (OpenAI, Anthropic, Gemini, OpenRouter, ModelScope, Generic) into standardized + * provider quota states. + * + * Part of: Quota-aware provider scheduling (Phase 2). + */ + +import { recordProviderQuotaUsage, getProviderQuota } from "./providerQuotaState.ts"; + +export interface ParsedQuotaHeaderResult { + tokensUsed?: number; + tokenLimit?: number; + tokensRemaining?: number; + windowResetMs?: number; +} + +/** + * Parse rate-limit headers from an HTTP Response or Headers object into a normalized quota result. + */ +export function parseProviderQuotaHeaders( + headers: Headers | Record, + provider?: string +): ParsedQuotaHeaderResult | null { + if (!headers) return null; + + const getHeader = (name: string): string | null => { + if (typeof (headers as Headers).get === "function") { + return (headers as Headers).get(name); + } + const record = headers as Record; + const val = record[name] ?? record[name.toLowerCase()] ?? record[name.toUpperCase()]; + if (Array.isArray(val)) return val[0] ?? null; + return val ?? null; + }; + + const parseNum = (val: string | null): number | undefined => { + if (!val) return undefined; + const cleaned = val.replace(/[^0-9.]/g, ""); + const num = parseFloat(cleaned); + return isNaN(num) ? undefined : num; + }; + + const parseResetMs = (val: string | null): number | undefined => { + if (!val) return undefined; + const now = Date.now(); + // Check if ISO date string + if (val.includes("T") || val.includes("Z")) { + const parsed = Date.parse(val); + if (!isNaN(parsed)) return Math.max(0, parsed - now); + } + // Check if seconds / ms string (e.g. "60s", "100ms", "0.5s", or raw number) + if (val.endsWith("ms")) return parseNum(val); + if (val.endsWith("s")) return (parseNum(val) ?? 0) * 1000; + if (val.endsWith("m")) return (parseNum(val) ?? 0) * 60 * 1000; + if (val.endsWith("h")) return (parseNum(val) ?? 0) * 3600 * 1000; + + const rawNum = parseNum(val); + if (rawNum !== undefined) { + // If epoch timestamp (> 1e9), convert to remaining ms + if (rawNum > 1_000_000_000) { + return Math.max(0, rawNum * 1000 - now); + } + return rawNum * 1000; // assume relative seconds + } + return undefined; + }; + + const prov = (provider || "").toLowerCase(); + + // 1. Anthropic Headers + if (prov === "anthropic" || getHeader("anthropic-ratelimit-input-tokens-limit")) { + const limit = parseNum(getHeader("anthropic-ratelimit-input-tokens-limit")); + const remaining = parseNum(getHeader("anthropic-ratelimit-input-tokens-remaining")); + const resetStr = getHeader("anthropic-ratelimit-input-tokens-reset"); + const windowResetMs = parseResetMs(resetStr); + + if (limit !== undefined || remaining !== undefined) { + const tokensUsed = limit !== undefined && remaining !== undefined ? Math.max(0, limit - remaining) : undefined; + return { tokenLimit: limit, tokensRemaining: remaining, tokensUsed, windowResetMs }; + } + } + + // 2. OpenAI / Standard x-ratelimit-* + const limitTokens = parseNum(getHeader("x-ratelimit-limit-tokens")); + const remainingTokens = parseNum(getHeader("x-ratelimit-remaining-tokens")); + const resetTokens = getHeader("x-ratelimit-reset-tokens"); + if (limitTokens !== undefined || remainingTokens !== undefined) { + const tokensUsed = limitTokens !== undefined && remainingTokens !== undefined ? Math.max(0, limitTokens - remainingTokens) : undefined; + return { tokenLimit: limitTokens, tokensRemaining: remainingTokens, tokensUsed, windowResetMs: parseResetMs(resetTokens) }; + } + + // 3. OpenRouter / Generic Request level headers + const genericLimit = parseNum(getHeader("x-ratelimit-limit")); + const genericRemaining = parseNum(getHeader("x-ratelimit-remaining")); + const genericReset = getHeader("x-ratelimit-reset"); + if (genericLimit !== undefined || genericRemaining !== undefined) { + const tokensUsed = genericLimit !== undefined && genericRemaining !== undefined ? Math.max(0, genericLimit - genericRemaining) : undefined; + return { tokenLimit: genericLimit, tokensRemaining: genericRemaining, tokensUsed, windowResetMs: parseResetMs(genericReset) }; + } + + return null; +} + +/** + * Apply parsed quota headers directly to the provider quota state ledger. + */ +export function applyQuotaHeadersToState( + connectionId: string, + model: string, + headers: Headers | Record, + provider?: string +): void { + const parsed = parseProviderQuotaHeaders(headers, provider); + if (!parsed || (!parsed.tokensUsed && !parsed.tokenLimit)) return; + + const now = Date.now(); + const windowReset = parsed.windowResetMs ? now + parsed.windowResetMs : now + 60_000; + const tokenLimit = parsed.tokenLimit ?? 0; + const tokensUsed = parsed.tokensUsed ?? (parsed.tokenLimit && parsed.tokensRemaining ? parsed.tokenLimit - parsed.tokensRemaining : 0); + + recordProviderQuotaUsage(connectionId, model, tokensUsed, tokenLimit, now, windowReset); +} diff --git a/src/lib/quota/quotaAnalytics.ts b/src/lib/quota/quotaAnalytics.ts new file mode 100644 index 0000000000..fca1f11b1b --- /dev/null +++ b/src/lib/quota/quotaAnalytics.ts @@ -0,0 +1,114 @@ +/** + * quotaAnalytics.ts — Usage analytics for provider quota state & remaining capacity. + * + * Computes aggregated capacity metrics, average remaining ratios, exhausted connection counts, + * and per-connection quota usage summaries for dashboard visibility. + * + * Part of: Quota-aware provider scheduling (Phase 2). + */ + +import { getDbInstance } from "@/lib/db/core"; +import { createLogger } from "@/shared/utils/logger"; + +const log = createLogger("quota:analytics"); + +export interface QuotaAnalyticsSummary { + totalConnectionsTracked: number; + exhaustedConnections: number; + healthyConnections: number; + averageRemainingRatio: number; + totalTokensUsed: number; + totalTokenLimit: number; + connections: Array<{ + connectionId: string; + model: string; + tokensUsed: number; + tokenLimit: number; + tokensRemaining: number; + remainingRatio: number; + windowReset: number; + isExhausted: boolean; + }>; +} + +/** + * Compute real-time quota analytics across all provider connections. + */ +export function getQuotaAnalyticsSummary(): QuotaAnalyticsSummary { + try { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM provider_quota_state").all() as Array<{ + connection_id: string; + model: string; + tokens_used: number; + token_limit: number; + window_start: number; + window_reset: number; + }>; + + const now = Date.now(); + let totalTokensUsed = 0; + let totalTokenLimit = 0; + let exhaustedConnections = 0; + let healthyConnections = 0; + let ratioSum = 0; + + const connections = rows.map((r) => { + const tokensUsed = Number(r.tokens_used ?? 0); + const tokenLimit = Number(r.token_limit ?? 0); + const windowReset = Number(r.window_reset ?? 0); + const isExpired = windowReset > 0 && now > windowReset; + + const effectiveUsed = isExpired ? 0 : tokensUsed; + const effectiveLimit = isExpired ? 0 : tokenLimit; + const tokensRemaining = Math.max(0, effectiveLimit - effectiveUsed); + const remainingRatio = effectiveLimit > 0 ? tokensRemaining / effectiveLimit : 1.0; + const isExhausted = effectiveLimit > 0 && remainingRatio <= 0.05; + + totalTokensUsed += effectiveUsed; + totalTokenLimit += effectiveLimit; + ratioSum += remainingRatio; + + if (isExhausted) { + exhaustedConnections++; + } else { + healthyConnections++; + } + + return { + connectionId: String(r.connection_id), + model: String(r.model), + tokensUsed: effectiveUsed, + tokenLimit: effectiveLimit, + tokensRemaining, + remainingRatio, + windowReset, + isExhausted, + }; + }); + + const count = connections.length; + const averageRemainingRatio = count > 0 ? ratioSum / count : 1.0; + + return { + totalConnectionsTracked: count, + exhaustedConnections, + healthyConnections, + averageRemainingRatio, + totalTokensUsed, + totalTokenLimit, + connections, + }; + } catch (error) { + log.error("Failed to compute quota analytics summary", error); + return { + totalConnectionsTracked: 0, + exhaustedConnections: 0, + healthyConnections: 0, + averageRemainingRatio: 1.0, + totalTokensUsed: 0, + totalTokenLimit: 0, + connections: [], + }; + } +} diff --git a/src/lib/quota/quotaResetTimers.ts b/src/lib/quota/quotaResetTimers.ts new file mode 100644 index 0000000000..875be7959b --- /dev/null +++ b/src/lib/quota/quotaResetTimers.ts @@ -0,0 +1,69 @@ +/** + * quotaResetTimers.ts — Automated quota window reset timers and capacity recovery. + * + * Tracks window reset timestamps and automatically clears exhausted provider quota + * states when their reset windows elapse. + * + * Part of: Quota-aware provider scheduling (Phase 2). + */ + +import { getDbInstance } from "@/lib/db/core"; +import { createLogger } from "@/shared/utils/logger"; + +const log = createLogger("quota:reset-timers"); + +export interface QuotaResetItem { + connectionId: string; + model: string; + tokensUsed: number; + tokenLimit: number; + windowReset: number; + timeRemainingMs: number; +} + +/** + * Get all active quota states and their remaining window reset times. + */ +export function getActiveQuotaResetItems(): QuotaResetItem[] { + try { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM provider_quota_state WHERE window_reset > 0").all() as Array<{ + connection_id: string; + model: string; + tokens_used: number; + token_limit: number; + window_reset: number; + }>; + + const now = Date.now(); + return rows.map((r) => ({ + connectionId: String(r.connection_id), + model: String(r.model), + tokensUsed: Number(r.tokens_used), + tokenLimit: Number(r.token_limit), + windowReset: Number(r.window_reset), + timeRemainingMs: Math.max(0, Number(r.window_reset) - now), + })); + } catch (error) { + log.error("Failed to query active quota reset items", error); + return []; + } +} + +/** + * Purge or reset all expired quota windows in SQLite. + * Returns the count of reset connections. + */ +export function resetExpiredQuotaWindows(): number { + try { + const db = getDbInstance(); + const now = Date.now(); + const result = db + .prepare("DELETE FROM provider_quota_state WHERE window_reset > 0 AND window_reset <= ?") + .run(now); + return result.changes ?? 0; + } catch (error) { + log.error("Failed to reset expired quota windows", error); + return 0; + } +} diff --git a/tests/unit/quota-phase2.test.ts b/tests/unit/quota-phase2.test.ts new file mode 100644 index 0000000000..2773e89d48 --- /dev/null +++ b/tests/unit/quota-phase2.test.ts @@ -0,0 +1,67 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseProviderQuotaHeaders, applyQuotaHeadersToState } from "../../src/lib/quota/quotaAdapters.ts"; +import { getQuotaAnalyticsSummary } from "../../src/lib/quota/quotaAnalytics.ts"; +import { getActiveQuotaResetItems, resetExpiredQuotaWindows } from "../../src/lib/quota/quotaResetTimers.ts"; +import { recordProviderQuotaUsage, getProviderQuota } from "../../src/lib/quota/providerQuotaState.ts"; + +test("parseProviderQuotaHeaders: parses OpenAI rate limit headers", () => { + const headers = new Headers({ + "x-ratelimit-limit-tokens": "100000", + "x-ratelimit-remaining-tokens": "80000", + "x-ratelimit-reset-tokens": "60s", + }); + const parsed = parseProviderQuotaHeaders(headers, "openai"); + assert.ok(parsed); + assert.equal(parsed?.tokenLimit, 100000); + assert.equal(parsed?.tokensRemaining, 80000); + assert.equal(parsed?.tokensUsed, 20000); + assert.equal(parsed?.windowResetMs, 60000); +}); + +test("parseProviderQuotaHeaders: parses Anthropic rate limit headers", () => { + const headers = new Headers({ + "anthropic-ratelimit-input-tokens-limit": "50000", + "anthropic-ratelimit-input-tokens-remaining": "10000", + "anthropic-ratelimit-input-tokens-reset": "30s", + }); + const parsed = parseProviderQuotaHeaders(headers, "anthropic"); + assert.ok(parsed); + assert.equal(parsed?.tokenLimit, 50000); + assert.equal(parsed?.tokensRemaining, 10000); + assert.equal(parsed?.tokensUsed, 40000); + assert.equal(parsed?.windowResetMs, 30000); +}); + +test("applyQuotaHeadersToState & getQuotaAnalyticsSummary: records and aggregates quota analytics", () => { + const connId = "test-conn-p2-01"; + const model = "gpt-4o"; + const headers = { + "x-ratelimit-limit-tokens": "100000", + "x-ratelimit-remaining-tokens": "20000", + "x-ratelimit-reset-tokens": "120s", + }; + + applyQuotaHeadersToState(connId, model, headers, "openai"); + + const snapshot = getProviderQuota(connId, model); + assert.ok(snapshot); + assert.equal(snapshot?.tokensUsed, 80000); + assert.equal(snapshot?.tokenLimit, 100000); + + const analytics = getQuotaAnalyticsSummary(); + assert.ok(analytics.totalConnectionsTracked > 0); + assert.ok(analytics.connections.some((c) => c.connectionId === connId)); +}); + +test("quotaResetTimers: tracks active reset items and purges expired windows", () => { + const connId = "test-conn-expired"; + const model = "claude-sonnet-4-6"; + const now = Date.now(); + + // Record already expired window + recordProviderQuotaUsage(connId, model, 5000, 5000, now - 10000, now - 1000); + + const expiredCount = resetExpiredQuotaWindows(); + assert.ok(expiredCount >= 1); +});