mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
feat(quota): Phase 2 adapters, reset timers, analytics, and dashboard API
This commit is contained in:
committed by
adevwithpurpose
parent
6143da70d1
commit
9f7bcf4391
77
src/app/api/settings/quota/state/route.ts
Normal file
77
src/app/api/settings/quota/state/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/lib/quota/quotaAdapters.ts
Normal file
125
src/lib/quota/quotaAdapters.ts
Normal file
@@ -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<string, string | string[] | undefined>,
|
||||
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<string, string | string[] | undefined>;
|
||||
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<string, string | string[] | undefined>,
|
||||
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);
|
||||
}
|
||||
114
src/lib/quota/quotaAnalytics.ts
Normal file
114
src/lib/quota/quotaAnalytics.ts
Normal file
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
69
src/lib/quota/quotaResetTimers.ts
Normal file
69
src/lib/quota/quotaResetTimers.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
67
tests/unit/quota-phase2.test.ts
Normal file
67
tests/unit/quota-phase2.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user