Merge Pull Request #888

This commit is contained in:
diegosouzapw
2026-04-01 18:05:45 -03:00
11 changed files with 738 additions and 375 deletions

View File

@@ -42,6 +42,7 @@ MACHINE_ID_SALT=endpoint-proxy-salt
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
ALLOW_API_KEY_REVEAL=false
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# Input Sanitizer (FASE-01 — prompt injection & PII protection)
# INPUT_SANITIZER_ENABLED=true

View File

@@ -270,7 +270,7 @@ Developers pay $20200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve
**How OmniRoute solves it:**
- **Smart 4-Tier Fallback** — If subscription quota runs out, automatically redirects to API Key → Cheap → Free with zero manual intervention
- **Real-Time Quota Tracking** — Shows token consumption in real-time with reset countdown (5h, daily, weekly)
- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI
- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next
- **Custom Combos** — Customizable fallback chains with 9 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random)
- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard

View File

@@ -507,26 +507,27 @@ post_install() {
### Environment Variables
| Variable | Default | Description |
| -------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------ |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | Disable automatic SQLite snapshots before writes/import/restore; manual backups still work |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
| `CLOUDFLARED_BIN` | unset | Use an existing `cloudflared` binary instead of managed download |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
| Variable | Default | Description |
| --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password |
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand |
| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | Server-side refresh cadence for cached Provider Limits data; UI refresh buttons still trigger manual sync |
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | Disable automatic SQLite snapshots before writes/import/restore; manual backups still work |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
| `CLOUDFLARED_BIN` | unset | Use an existing `cloudflared` binary instead of managed download |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
For the full environment variable reference, see the [README](../README.md).
@@ -769,10 +770,10 @@ OmniRoute implements provider-level resilience with four components:
Manage database backups in **Dashboard → Settings → System & Storage**.
| Action | Description |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Export Database** | Downloads the current SQLite database as a `.sqlite` file |
| **Export All (.tar.gz)** | Downloads a full backup archive including: database, settings, combos, provider connections (no credentials), API key metadata |
| Action | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Export Database** | Downloads the current SQLite database as a `.sqlite` file |
| **Export All (.tar.gz)** | Downloads a full backup archive including: database, settings, combos, provider connections (no credentials), API key metadata |
| **Import Database** | Upload a `.sqlite` file to replace the current database. A pre-import backup is automatically created unless `DISABLE_SQLITE_AUTO_BACKUP=true` |
```bash

View File

@@ -17,10 +17,8 @@ import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const LS_GROUP_BY = "omniroute:limits:groupBy";
const LS_AUTO_REFRESH = "omniroute:limits:autoRefresh";
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
const REFRESH_INTERVAL_MS = 120000;
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
const QUOTA_BAR_GREEN_THRESHOLD = 50;
const QUOTA_BAR_YELLOW_THRESHOLD = 20;
@@ -84,13 +82,8 @@ export default function ProviderLimits() {
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
const [errors, setErrors] = useState({});
const [autoRefresh, setAutoRefresh] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(LS_AUTO_REFRESH) === "true";
});
const [lastRefreshedAt, setLastRefreshedAt] = useState<Record<string, string>>({});
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(120);
const [initialLoading, setInitialLoading] = useState(true);
const [tierFilter, setTierFilter] = useState("all");
const [groupBy, setGroupBy] = useState<"none" | "environment">(() => {
@@ -109,8 +102,6 @@ export default function ProviderLimits() {
}
});
const intervalRef = useRef(null);
const countdownRef = useRef(null);
const lastFetchTimeRef = useRef({});
const staleProbeRef = useRef({});
@@ -128,6 +119,41 @@ export default function ProviderLimits() {
}
}, []);
const applyCachedQuotaState = useCallback((connectionList, caches) => {
const nextQuotaData = {};
const nextLastRefreshedAt = {};
for (const conn of connectionList) {
const cached = caches?.[conn.id];
if (!cached) continue;
nextQuotaData[conn.id] = {
quotas: parseQuotaData(conn.provider, cached),
plan: cached.plan || null,
message: cached.message || null,
raw: cached,
};
if (cached.fetchedAt) {
nextLastRefreshedAt[conn.id] = cached.fetchedAt;
}
}
setQuotaData(nextQuotaData);
setLastRefreshedAt(nextLastRefreshedAt);
}, []);
const fetchCachedProviderLimits = useCallback(async () => {
try {
const response = await fetch("/api/usage/provider-limits");
if (!response.ok) throw new Error("Failed");
const data = await response.json();
return data.caches || {};
} catch {
return {};
}
}, []);
const fetchQuota = useCallback(
async (connectionId, provider, options: { force?: boolean } = {}) => {
const force = options?.force === true;
@@ -207,72 +233,39 @@ export default function ProviderLimits() {
const refreshAll = useCallback(async () => {
if (refreshingAll) return;
setRefreshingAll(true);
setCountdown(120);
try {
const conns = await fetchConnections();
// Show table layout immediately once connections are loaded (Issue #784)
setInitialLoading(false);
const usageConnections = conns.filter(
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
(conn.authType === "oauth" || conn.authType === "apikey")
);
// Fix: Fetch quotas in chunks of 5 to avoid spamming the backend/provider APIs and hanging the UI.
const chunkSize = 5;
for (let i = 0; i < usageConnections.length; i += chunkSize) {
const chunk = usageConnections.slice(i, i + chunkSize);
await Promise.all(chunk.map((conn) => fetchQuota(conn.id, conn.provider, { force: true })));
const response = await fetch("/api/usage/provider-limits", { method: "POST" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || response.statusText;
throw new Error(errorMsg);
}
const data = await response.json();
const connectionList = await fetchConnections();
applyCachedQuotaState(connectionList, data.caches || {});
setErrors(data.errors || {});
} catch (error) {
console.error("Error refreshing all:", error);
} finally {
setRefreshingAll(false);
setInitialLoading(false); // Fallback to ensure skeleton is cleared
}
}, [refreshingAll, fetchConnections, fetchQuota]);
}, [refreshingAll, applyCachedQuotaState, fetchConnections]);
useEffect(() => {
const init = async () => {
setInitialLoading(true);
// No longer await refreshAll here so we don't block the UI
refreshAll();
const [connectionList, caches] = await Promise.all([
fetchConnections(),
fetchCachedProviderLimits(),
]);
applyCachedQuotaState(connectionList, caches);
setInitialLoading(false);
};
init();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!autoRefresh) {
if (intervalRef.current) clearInterval(intervalRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
return;
}
intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS);
countdownRef.current = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 120 : prev - 1));
}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
};
}, [autoRefresh, refreshAll]);
useEffect(() => {
const handler = () => {
if (document.hidden) {
if (intervalRef.current) clearInterval(intervalRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
} else if (autoRefresh) {
intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS);
countdownRef.current = setInterval(() => {
setCountdown((prev) => (prev <= 1 ? 120 : prev - 1));
}, 1000);
}
};
document.addEventListener("visibilitychange", handler);
return () => document.removeEventListener("visibilitychange", handler);
}, [autoRefresh, refreshAll]);
init().catch(() => {
setInitialLoading(false);
});
}, [applyCachedQuotaState, fetchCachedProviderLimits, fetchConnections]);
const filteredConnections = useMemo(
() =>
@@ -462,26 +455,6 @@ export default function ProviderLimits() {
</button>
</div>
<button
onClick={() => {
const next = !autoRefresh;
setAutoRefresh(next);
localStorage.setItem(LS_AUTO_REFRESH, String(next));
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-transparent cursor-pointer text-text-main text-[13px]"
>
<span
className="material-symbols-outlined text-[18px]"
style={{
color: autoRefresh ? "#22c55e" : "var(--text-muted)",
}}
>
{autoRefresh ? "toggle_on" : "toggle_off"}
</span>
{t("autoRefresh")}
{autoRefresh && <span className="text-xs text-text-muted">({countdown}s)</span>}
</button>
<button
onClick={refreshAll}
disabled={refreshingAll}

View File

@@ -1,280 +1,24 @@
import {
getProviderConnectionById,
updateProviderConnection,
resolveProxyForConnection,
} from "@/lib/localDb";
import { getMachineId } from "@/shared/utils/machine";
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);
}
import { fetchAndPersistProviderLimits } from "@/lib/usage/providerLimits";
/**
* Sync to cloud if enabled
*/
async function syncToCloudIfEnabled() {
try {
const machineId = await getMachineId();
if (!machineId) return;
await syncToCloud(machineId);
} catch (error) {
console.error("[Usage API] Error syncing to cloud:", error);
}
}
/**
* Refresh credentials using executor and update database
* @returns {{ connection, refreshed: boolean }}
*/
async function refreshAndUpdateCredentials(connection: any) {
const executor = getExecutor(connection.provider);
// Build credentials object from connection
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.tokenExpiresAt,
providerSpecificData: connection.providerSpecificData,
// For GitHub
copilotToken: connection.providerSpecificData?.copilotToken,
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
};
// Check if refresh is needed
const needsRefresh = executor.needsRefresh(credentials);
if (!needsRefresh) {
return { connection, refreshed: false };
}
// Use executor's refreshCredentials method
const refreshResult = await executor.refreshCredentials(credentials, console);
if (!refreshResult) {
// For GitHub, if refreshCredentials fails but we still have accessToken, try to use it directly
if (connection.provider === "github" && connection.accessToken) {
return { connection, refreshed: false };
}
throw new Error("Failed to refresh credentials. Please re-authorize the connection.");
}
// Build update object
const now = new Date().toISOString();
const updateData: Record<string, any> = {
updatedAt: now,
};
// Update accessToken if present
if (refreshResult.accessToken) {
updateData.accessToken = refreshResult.accessToken;
}
// Update refreshToken if present
if (refreshResult.refreshToken) {
updateData.refreshToken = refreshResult.refreshToken;
}
// Update token expiry
if (refreshResult.expiresIn) {
updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
} else if (refreshResult.expiresAt) {
updateData.tokenExpiresAt = refreshResult.expiresAt;
}
// Handle provider-specific data (copilotToken for GitHub, etc.)
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {
updateData.providerSpecificData = {
...connection.providerSpecificData,
copilotToken: refreshResult.copilotToken,
copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt,
};
}
// Update database
await updateProviderConnection(connection.id, updateData);
// Return updated connection
const updatedConnection = {
...connection,
...updateData,
};
return {
connection: updatedConnection,
refreshed: true,
};
}
/**
* GET /api/usage/[connectionId] - Get usage data for a specific connection
* GET /api/usage/[connectionId] - Get live usage data for a specific connection
* and persist the refreshed Provider Limits cache.
*/
export async function GET(
request: Request,
_request: Request,
{ params }: { params: Promise<{ connectionId: string }> }
) {
try {
const { connectionId } = await params;
// Get connection from database
let connection = await getProviderConnectionById(connectionId);
if (!connection) {
return Response.json({ error: "Connection not found" }, { status: 404 });
}
// Only OAuth connections and specific API key providers have usage APIs
const apikeyUsageProviders = ["glm"];
if (connection.authType !== "oauth" && !apikeyUsageProviders.includes(connection.provider)) {
return Response.json({ message: "Usage not available for API key connections" });
}
// API key providers skip OAuth refresh — call usage fetcher directly
if (connection.authType !== "oauth") {
try {
const usageData = await getUsageForProvider(connection);
return Response.json(usageData);
} catch (error) {
console.error("[Usage API] Error fetching usage:", error);
return Response.json({ error: (error as any).message }, { status: 500 });
}
}
// Resolve proxy for this connection FIRST (key → combo → provider → global → direct)
// so that both credential refresh AND usage fetch go through the proxy.
const proxyInfo = await resolveProxyForConnection(connectionId);
// Helper: perform credential refresh + usage fetch
const fetchUsageWithContext = async (proxyConfig: unknown) => {
return runWithProxyContext(proxyConfig, async () => {
let conn = connection;
let wasRefreshed = false;
// Refresh credentials if needed using executor
try {
const result = await refreshAndUpdateCredentials(conn);
conn = result.connection;
wasRefreshed = result.refreshed;
// Sync to cloud only if token was refreshed
if (wasRefreshed) {
await syncToCloudIfEnabled();
}
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
throw refreshError;
}
// Fetch usage from provider API
const usageData = await getUsageForProvider(conn);
connection = conn; // propagate updated connection for status sync below
return { usage: usageData, refreshed: wasRefreshed };
});
};
// Check if a usage result indicates a network-level error (proxy can't relay)
const isNetworkFailure = (usageResult: any): boolean => {
const msg = usageResult?.usage?.message;
if (typeof msg !== "string") return false;
return (
msg.includes("fetch failed") ||
msg.includes("ECONNREFUSED") ||
msg.includes("ETIMEDOUT") ||
msg.includes("Proxy unreachable") ||
msg.includes("UND_ERR_CONNECT_TIMEOUT")
);
};
let result: any;
const proxyConfig = proxyInfo?.proxy || null;
try {
result = await fetchUsageWithContext(proxyConfig);
} catch (proxyError: any) {
const isAuthError =
proxyError?.message?.includes?.("refresh") || proxyError?.message?.includes?.("Credential");
if (isAuthError) {
return Response.json(
{ error: `Credential refresh failed: ${proxyError.message}` },
{ status: 401 }
);
}
// If proxy was active and it's a network error (thrown), retry without proxy
const isThrownNetworkError =
proxyError?.message === "fetch failed" ||
proxyError?.code === "PROXY_UNREACHABLE" ||
proxyError?.code === "UND_ERR_CONNECT_TIMEOUT" ||
proxyError?.cause?.code === "ECONNREFUSED";
if (proxyConfig && isThrownNetworkError) {
console.warn(
`[Usage API] Proxy fetch threw for ${connectionId}, retrying without proxy:`,
proxyError?.message
);
result = await fetchUsageWithContext(null);
} else {
throw proxyError;
}
}
// If the usage result contains a network error AND a proxy was active,
// retry without proxy. getCodexUsage() catches fetch errors internally
// and returns {message: "Failed to fetch..."} instead of throwing.
if (proxyConfig && isNetworkFailure(result)) {
console.warn(
`[Usage API] Proxy usage returned network error for ${connectionId}, retrying without proxy:`,
result.usage?.message
);
try {
result = await fetchUsageWithContext(null);
} catch (directError: any) {
console.error("[Usage API] Direct fetch also failed:", directError?.message);
throw directError;
}
}
const { usage, refreshed } = result;
// Populate quota cache for quota-aware account selection
if (isRecord(usage?.quotas)) {
setQuotaCache(
connectionId,
connection.provider as string,
usage.quotas as Record<string, unknown>
);
}
// (#491) If the live usage check returned an auth error, sync the expired status
// back to the DB so the Providers page reflects the same degraded state as
// Limits & Quotas (which performs the live check).
const errorMessage = typeof usage?.message === "string" ? usage.message.toLowerCase() : "";
const isAuthError =
errorMessage.includes("token expired") ||
errorMessage.includes("access denied") ||
errorMessage.includes("re-authenticate") ||
errorMessage.includes("unauthorized");
if (isAuthError && connection.testStatus !== "expired") {
try {
await updateProviderConnection(connection.id as string, {
testStatus: "expired",
lastErrorType: "token_expired",
lastErrorAt: new Date().toISOString(),
});
} catch (dbErr) {
// Non-critical: log but don't block the response
console.error("[Usage API] Failed to sync expired status to DB:", dbErr);
}
}
const { usage } = await fetchAndPersistProviderLimits(connectionId, "manual");
return Response.json(usage);
} catch (error) {
const status =
typeof (error as { status?: unknown })?.status === "number"
? (error as { status: number }).status
: 500;
const message = (error as Error)?.message || "Failed to fetch usage";
console.error("[Usage API] Error fetching usage:", error);
console.error("[Usage API] Error stack:", (error as any).stack);
return Response.json({ error: (error as any).message }, { status: 500 });
return Response.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import {
getCachedProviderLimitsMap,
getLastProviderLimitsAutoSyncTime,
getProviderLimitsSyncIntervalMinutes,
syncAllProviderLimits,
} from "@/lib/usage/providerLimits";
/**
* GET /api/usage/provider-limits
* Returns cached Provider Limits data without triggering live refreshes.
*/
export async function GET() {
try {
return NextResponse.json({
caches: getCachedProviderLimitsMap(),
intervalMinutes: getProviderLimitsSyncIntervalMinutes(),
lastAutoSyncAt: await getLastProviderLimitsAutoSyncTime(),
});
} catch (error) {
console.error("[API] GET /api/usage/provider-limits error:", error);
return NextResponse.json({ error: "Failed to fetch cached provider limits" }, { status: 500 });
}
}
/**
* POST /api/usage/provider-limits
* Manually refresh all supported Provider Limits entries.
*/
export async function POST() {
try {
const result = await syncAllProviderLimits({ source: "manual" });
const caches = getCachedProviderLimitsMap();
return NextResponse.json({
...result,
caches,
intervalMinutes: getProviderLimitsSyncIntervalMinutes(),
lastAutoSyncAt: await getLastProviderLimitsAutoSyncTime(),
});
} catch (error) {
console.error("[API] POST /api/usage/provider-limits error:", error);
return NextResponse.json({ error: "Failed to refresh provider limits" }, { status: 500 });
}
}

View File

@@ -75,11 +75,13 @@ export async function registerNodejs(): Promise<void> {
{ initGracefulShutdown },
{ initApiBridgeServer },
{ startBackgroundRefresh },
{ startProviderLimitsSyncScheduler },
{ getSettings },
] = await Promise.all([
import("@/lib/gracefulShutdown"),
import("@/lib/apiBridgeServer"),
import("@/domain/quotaCache"),
import("@/shared/services/providerLimitsSyncScheduler"),
import("@/lib/db/settings"),
]);
@@ -87,6 +89,8 @@ export async function registerNodejs(): Promise<void> {
initApiBridgeServer();
startBackgroundRefresh();
console.log("[STARTUP] Quota cache background refresh started");
startProviderLimitsSyncScheduler();
console.log("[STARTUP] Provider limits sync scheduler started");
try {
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([

View File

@@ -0,0 +1,127 @@
import { getDbInstance, isBuildPhase, isCloud } from "./core";
type JsonRecord = Record<string, unknown>;
interface StatementLike<TRow = unknown> {
all: (...params: unknown[]) => TRow[];
get: (...params: unknown[]) => TRow | undefined;
run: (...params: unknown[]) => { changes?: number };
}
interface DbLike {
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
transaction: <T extends (...args: any[]) => unknown>(fn: T) => T;
}
interface KeyValueRow {
key: string;
value: string;
}
export interface ProviderLimitsCacheEntry {
quotas: JsonRecord | null;
plan: unknown;
message: string | null;
fetchedAt: string;
source?: string | null;
}
const PROVIDER_LIMITS_CACHE_NAMESPACE = "providerLimitsCache";
function parseJson(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
function toRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
function normalizeCacheEntry(value: unknown): ProviderLimitsCacheEntry | null {
const record = toRecord(value);
if (!record) return null;
const fetchedAt =
typeof record.fetchedAt === "string" && record.fetchedAt.trim() ? record.fetchedAt : null;
if (!fetchedAt) return null;
return {
quotas: toRecord(record.quotas),
plan: record.plan ?? null,
message: typeof record.message === "string" ? record.message : null,
fetchedAt,
source: typeof record.source === "string" ? record.source : null,
};
}
export function getProviderLimitsCache(connectionId: string): ProviderLimitsCacheEntry | null {
if (isBuildPhase || isCloud) return null;
const db = getDbInstance() as unknown as DbLike;
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(PROVIDER_LIMITS_CACHE_NAMESPACE, connectionId) as KeyValueRow | undefined;
if (!row?.value) return null;
return normalizeCacheEntry(parseJson(row.value));
}
export function getAllProviderLimitsCache(): Record<string, ProviderLimitsCacheEntry> {
if (isBuildPhase || isCloud) return {};
const db = getDbInstance() as unknown as DbLike;
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = ?")
.all(PROVIDER_LIMITS_CACHE_NAMESPACE) as KeyValueRow[];
const result: Record<string, ProviderLimitsCacheEntry> = {};
for (const row of rows) {
const parsed = normalizeCacheEntry(parseJson(row.value));
if (parsed) {
result[row.key] = parsed;
}
}
return result;
}
export function setProviderLimitsCache(
connectionId: string,
entry: ProviderLimitsCacheEntry
): ProviderLimitsCacheEntry {
if (isBuildPhase || isCloud) return entry;
const db = getDbInstance() as unknown as DbLike;
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
PROVIDER_LIMITS_CACHE_NAMESPACE,
connectionId,
JSON.stringify(entry)
);
return entry;
}
export function setProviderLimitsCacheBatch(
entries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }>
): number {
if (isBuildPhase || isCloud || entries.length === 0) return 0;
const db = getDbInstance() as unknown as DbLike;
const insert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
);
const tx = db.transaction(
(items: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }>) => {
for (const item of items) {
insert.run(PROVIDER_LIMITS_CACHE_NAMESPACE, item.connectionId, JSON.stringify(item.entry));
}
}
);
tx(entries);
return entries.length;
}
export function deleteProviderLimitsCache(connectionId: string): void {
if (isBuildPhase || isCloud) return;
const db = getDbInstance() as unknown as DbLike;
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
PROVIDER_LIMITS_CACHE_NAMESPACE,
connectionId
);
}

View File

@@ -209,3 +209,13 @@ export {
} from "./db/quotaSnapshots";
export type { QuotaSnapshotRow, ProviderUtilizationPoint } from "@/shared/types/utilization";
export {
getProviderLimitsCache,
getAllProviderLimitsCache,
setProviderLimitsCache,
setProviderLimitsCacheBatch,
deleteProviderLimitsCache,
} from "./db/providerLimits";
export type { ProviderLimitsCacheEntry } from "./db/providerLimits";

View File

@@ -0,0 +1,374 @@
import {
getAllProviderLimitsCache,
getProviderConnectionById,
getProviderConnections,
getSettings,
resolveProxyForConnection,
setProviderLimitsCache,
setProviderLimitsCacheBatch,
updateProviderConnection,
updateSettings,
type ProviderLimitsCacheEntry,
} from "@/lib/localDb";
import { syncToCloud } from "@/lib/cloudSync";
import { setQuotaCache } from "@/domain/quotaCache";
import { getMachineId } from "@/shared/utils/machine";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
type JsonRecord = Record<string, unknown>;
type SyncSource = "manual" | "scheduled";
interface ProviderConnectionLike {
id: string;
provider: string;
authType?: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresAt?: string;
providerSpecificData?: JsonRecord;
testStatus?: string;
isActive?: boolean;
}
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm"]);
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
function isRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toProviderLimitsCacheEntry(
usage: JsonRecord,
source: SyncSource,
fetchedAt = new Date().toISOString()
): ProviderLimitsCacheEntry {
return {
quotas: isRecord(usage.quotas) ? usage.quotas : null,
plan: usage.plan ?? null,
message: typeof usage.message === "string" ? usage.message : null,
fetchedAt,
source,
};
}
function isSupportedUsageConnection(connection: ProviderConnectionLike | null): boolean {
if (
!connection ||
!connection.provider ||
!USAGE_SUPPORTED_PROVIDERS.includes(connection.provider)
) {
return false;
}
if (connection.authType === "oauth") return true;
return (
connection.authType === "apikey" && PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider)
);
}
function withStatus(error: Error, status: number): Error & { status: number } {
return Object.assign(error, { status });
}
async function syncToCloudIfEnabled() {
try {
const machineId = await getMachineId();
if (!machineId) return;
await syncToCloud(machineId);
} catch (error) {
console.error("[ProviderLimits] Error syncing refreshed credentials to cloud:", error);
}
}
async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) {
const executor = getExecutor(connection.provider);
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.tokenExpiresAt,
providerSpecificData: connection.providerSpecificData,
copilotToken: connection.providerSpecificData?.copilotToken,
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
};
if (!executor.needsRefresh(credentials)) {
return { connection, refreshed: false };
}
const refreshResult = await executor.refreshCredentials(credentials, console);
if (!refreshResult) {
if (connection.provider === "github" && connection.accessToken) {
return { connection, refreshed: false };
}
throw withStatus(
new Error("Failed to refresh credentials. Please re-authorize the connection."),
401
);
}
const updateData: JsonRecord = {
updatedAt: new Date().toISOString(),
};
if (refreshResult.accessToken) {
updateData.accessToken = refreshResult.accessToken;
}
if (refreshResult.refreshToken) {
updateData.refreshToken = refreshResult.refreshToken;
}
if (refreshResult.expiresIn) {
updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
} else if (refreshResult.expiresAt) {
updateData.tokenExpiresAt = refreshResult.expiresAt;
}
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {
updateData.providerSpecificData = {
...(connection.providerSpecificData || {}),
copilotToken: refreshResult.copilotToken,
copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt,
};
}
await updateProviderConnection(connection.id, updateData);
return {
connection: {
...connection,
...updateData,
providerSpecificData:
(updateData.providerSpecificData as JsonRecord | undefined) ||
connection.providerSpecificData,
},
refreshed: true,
};
}
function isNetworkFailureMessage(message: unknown): boolean {
if (typeof message !== "string") return false;
return (
message.includes("fetch failed") ||
message.includes("ECONNREFUSED") ||
message.includes("ETIMEDOUT") ||
message.includes("Proxy unreachable") ||
message.includes("UND_ERR_CONNECT_TIMEOUT")
);
}
async function syncExpiredStatusIfNeeded(connection: ProviderConnectionLike, usage: JsonRecord) {
const errorMessage = typeof usage.message === "string" ? usage.message.toLowerCase() : "";
const isAuthError =
errorMessage.includes("token expired") ||
errorMessage.includes("access denied") ||
errorMessage.includes("re-authenticate") ||
errorMessage.includes("unauthorized");
if (!isAuthError || connection.testStatus === "expired") {
return;
}
try {
await updateProviderConnection(connection.id, {
testStatus: "expired",
lastErrorType: "token_expired",
lastErrorAt: new Date().toISOString(),
});
} catch (dbError) {
console.error("[ProviderLimits] Failed to sync expired status to DB:", dbError);
}
}
export function getProviderLimitsSyncIntervalMinutes(): number {
const raw = Number.parseInt(process.env.PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES;
}
export function getProviderLimitsSyncIntervalMs(): number {
return getProviderLimitsSyncIntervalMinutes() * 60 * 1000;
}
export async function getLastProviderLimitsAutoSyncTime(): Promise<string | null> {
try {
const settings = await getSettings();
const value = settings[PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY];
return typeof value === "string" && value.trim() ? value : null;
} catch {
return null;
}
}
async function setLastProviderLimitsAutoSyncTime(timestamp: string): Promise<void> {
await updateSettings({ [PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY]: timestamp });
}
export function getCachedProviderLimitsMap(): Record<string, ProviderLimitsCacheEntry> {
return getAllProviderLimitsCache();
}
export async function fetchLiveProviderLimits(connectionId: string): Promise<{
connection: ProviderConnectionLike;
usage: JsonRecord;
}> {
let connection = (await getProviderConnectionById(connectionId)) as ProviderConnectionLike | null;
if (!connection) {
throw withStatus(new Error("Connection not found"), 404);
}
if (!isSupportedUsageConnection(connection)) {
throw withStatus(new Error("Usage not available for this connection"), 400);
}
if (connection.authType !== "oauth") {
const usage = (await getUsageForProvider(connection)) as JsonRecord;
if (isRecord(usage.quotas)) {
setQuotaCache(connectionId, connection.provider, usage.quotas);
}
await syncExpiredStatusIfNeeded(connection, usage);
return { connection, usage };
}
const proxyInfo = await resolveProxyForConnection(connectionId);
const fetchUsageWithContext = async (proxyConfig: unknown) =>
runWithProxyContext(proxyConfig, async () => {
let conn = connection as ProviderConnectionLike;
let wasRefreshed = false;
const result = await refreshAndUpdateCredentials(conn);
conn = result.connection;
wasRefreshed = result.refreshed;
if (wasRefreshed) {
await syncToCloudIfEnabled();
}
const usageData = (await getUsageForProvider(conn)) as JsonRecord;
connection = conn;
return { usage: usageData };
});
let result: { usage: JsonRecord };
const proxyConfig = proxyInfo?.proxy || null;
try {
result = await fetchUsageWithContext(proxyConfig);
} catch (error: any) {
const isThrownNetworkError =
error?.message === "fetch failed" ||
error?.code === "PROXY_UNREACHABLE" ||
error?.code === "UND_ERR_CONNECT_TIMEOUT" ||
error?.cause?.code === "ECONNREFUSED";
if (proxyConfig && isThrownNetworkError) {
console.warn(
`[ProviderLimits] Proxy fetch threw for ${connectionId}, retrying without proxy:`,
error?.message
);
result = await fetchUsageWithContext(null);
} else {
throw error;
}
}
if (proxyConfig && isNetworkFailureMessage(result.usage?.message)) {
console.warn(
`[ProviderLimits] Proxy usage returned network error for ${connectionId}, retrying without proxy:`,
result.usage.message
);
result = await fetchUsageWithContext(null);
}
if (isRecord(result.usage.quotas)) {
setQuotaCache(connectionId, connection.provider, result.usage.quotas);
}
await syncExpiredStatusIfNeeded(connection, result.usage);
return {
connection,
usage: result.usage,
};
}
export async function fetchAndPersistProviderLimits(
connectionId: string,
source: SyncSource = "manual"
): Promise<{
connection: ProviderConnectionLike;
usage: JsonRecord;
cache: ProviderLimitsCacheEntry;
}> {
const { connection, usage } = await fetchLiveProviderLimits(connectionId);
const cache = toProviderLimitsCacheEntry(usage, source);
setProviderLimitsCache(connectionId, cache);
return { connection, usage, cache };
}
export async function syncAllProviderLimits(
options: {
source?: SyncSource;
concurrency?: number;
} = {}
): Promise<{
total: number;
succeeded: number;
failed: number;
caches: Record<string, ProviderLimitsCacheEntry>;
errors: Record<string, string>;
}> {
const { source = "manual", concurrency = 5 } = options;
const connections = (
(await getProviderConnections({ isActive: true })) as ProviderConnectionLike[]
).filter(isSupportedUsageConnection);
const cacheEntries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }> = [];
const caches: Record<string, ProviderLimitsCacheEntry> = {};
const errors: Record<string, string> = {};
for (let i = 0; i < connections.length; i += concurrency) {
const chunk = connections.slice(i, i + concurrency);
const results = await Promise.allSettled(
chunk.map(async (connection) => {
const { usage } = await fetchLiveProviderLimits(connection.id);
const cache = toProviderLimitsCacheEntry(usage, source);
return { connectionId: connection.id, cache };
})
);
results.forEach((result, index) => {
const connectionId = chunk[index]?.id;
if (!connectionId) return;
if (result.status === "fulfilled") {
cacheEntries.push({
connectionId: result.value.connectionId,
entry: result.value.cache,
});
caches[result.value.connectionId] = result.value.cache;
return;
}
const reason = result.reason as { message?: string } | undefined;
errors[connectionId] = reason?.message || "Failed to refresh provider limits";
});
}
if (cacheEntries.length > 0) {
setProviderLimitsCacheBatch(cacheEntries);
}
if (source === "scheduled") {
await setLastProviderLimitsAutoSyncTime(new Date().toISOString());
}
return {
total: connections.length,
succeeded: cacheEntries.length,
failed: connections.length - cacheEntries.length,
caches,
errors,
};
}

View File

@@ -0,0 +1,85 @@
import {
getLastProviderLimitsAutoSyncTime,
getProviderLimitsSyncIntervalMinutes,
getProviderLimitsSyncIntervalMs,
syncAllProviderLimits,
} from "@/lib/usage/providerLimits";
const STARTUP_DELAY_MS = 5_000;
let schedulerTimer: NodeJS.Timeout | null = null;
let startupTimer: NodeJS.Timeout | null = null;
let isRunning = false;
async function runProviderLimitsSyncCycle(): Promise<void> {
if (isRunning) {
console.log("[ProviderLimitsSync] Skipping cycle — previous run still in progress");
return;
}
isRunning = true;
const start = Date.now();
try {
const result = await syncAllProviderLimits({ source: "scheduled" });
console.log(
`[ProviderLimitsSync] Cycle complete: ${result.succeeded}/${result.total} synced in ${Date.now() - start}ms`
);
} catch (error) {
console.warn("[ProviderLimitsSync] Cycle failed:", (error as Error).message);
} finally {
isRunning = false;
}
}
export function startProviderLimitsSyncScheduler(): void {
if (schedulerTimer || startupTimer) {
console.log("[ProviderLimitsSync] Scheduler already running — skipping start");
return;
}
const intervalMs = getProviderLimitsSyncIntervalMs();
const intervalMinutes = getProviderLimitsSyncIntervalMinutes();
console.log(`[ProviderLimitsSync] Scheduler started — interval: ${intervalMinutes}m`);
void (async () => {
let initialDelayMs = STARTUP_DELAY_MS;
const lastAutoSyncAt = await getLastProviderLimitsAutoSyncTime();
if (lastAutoSyncAt) {
const lastRunMs = Date.parse(lastAutoSyncAt);
if (Number.isFinite(lastRunMs)) {
const elapsedMs = Date.now() - lastRunMs;
if (elapsedMs < intervalMs) {
initialDelayMs = Math.max(intervalMs - elapsedMs, STARTUP_DELAY_MS);
}
}
}
startupTimer = setTimeout(() => {
startupTimer = null;
void runProviderLimitsSyncCycle();
schedulerTimer = setInterval(() => {
void runProviderLimitsSyncCycle();
}, intervalMs);
schedulerTimer.unref?.();
}, initialDelayMs);
startupTimer.unref?.();
})();
}
export function stopProviderLimitsSyncScheduler(): void {
if (startupTimer) {
clearTimeout(startupTimer);
startupTimer = null;
}
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
console.log("[ProviderLimitsSync] Scheduler stopped");
}
}