diff --git a/.env.example b/.env.example index b92b5e7d5c..a6c3713888 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 38139db5ca..016444ee97 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Developers pay $20–200/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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e149dbac77..859a428f3f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 1ef9380b52..35ce3cca1d 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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>({}); 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() { - -