mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge Pull Request #888
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
44
src/app/api/usage/provider-limits/route.ts
Normal file
44
src/app/api/usage/provider-limits/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
127
src/lib/db/providerLimits.ts
Normal file
127
src/lib/db/providerLimits.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
374
src/lib/usage/providerLimits.ts
Normal file
374
src/lib/usage/providerLimits.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
src/shared/services/providerLimitsSyncScheduler.ts
Normal file
85
src/shared/services/providerLimitsSyncScheduler.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user