From 243abe9d165f922ac04787760b914c92e2077c9a Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Sun, 15 Feb 2026 11:19:10 -0300 Subject: [PATCH] refactor(quota): modularize quota providers and add docs map (#427) * refactor(quota): migrate server quota providers to modular registry architecture * docs: add quota module documentation Explain the quota module purpose and runtime signals. List entrypoints, provider registry, and response shape. Outline steps to add a new provider and testing commands. --- AGENTS.md | 13 + packages/web/server/index.js | 2 +- packages/web/server/lib/quota-providers.js | 1337 ----------------- .../web/server/lib/quota/DOCUMENTATION.md | 52 + packages/web/server/lib/quota/index.js | 21 + .../web/server/lib/quota/providers/claude.js | 107 ++ .../web/server/lib/quota/providers/codex.js | 111 ++ .../web/server/lib/quota/providers/copilot.js | 165 ++ .../server/lib/quota/providers/google/api.js | 92 ++ .../server/lib/quota/providers/google/auth.js | 108 ++ .../lib/quota/providers/google/index.js | 124 ++ .../lib/quota/providers/google/transforms.js | 109 ++ .../web/server/lib/quota/providers/index.js | 128 ++ .../server/lib/quota/providers/interface.js | 55 + .../web/server/lib/quota/providers/kimi.js | 108 ++ .../web/server/lib/quota/providers/nanogpt.js | 124 ++ .../web/server/lib/quota/providers/openai.js | 91 ++ .../server/lib/quota/providers/openrouter.js | 92 ++ .../web/server/lib/quota/providers/zai.js | 91 ++ packages/web/server/lib/quota/utils/auth.js | 46 + .../web/server/lib/quota/utils/formatters.js | 76 + packages/web/server/lib/quota/utils/index.js | 10 + .../server/lib/quota/utils/transformers.js | 55 + 23 files changed, 1779 insertions(+), 1338 deletions(-) delete mode 100644 packages/web/server/lib/quota-providers.js create mode 100644 packages/web/server/lib/quota/DOCUMENTATION.md create mode 100644 packages/web/server/lib/quota/index.js create mode 100644 packages/web/server/lib/quota/providers/claude.js create mode 100644 packages/web/server/lib/quota/providers/codex.js create mode 100644 packages/web/server/lib/quota/providers/copilot.js create mode 100644 packages/web/server/lib/quota/providers/google/api.js create mode 100644 packages/web/server/lib/quota/providers/google/auth.js create mode 100644 packages/web/server/lib/quota/providers/google/index.js create mode 100644 packages/web/server/lib/quota/providers/google/transforms.js create mode 100644 packages/web/server/lib/quota/providers/index.js create mode 100644 packages/web/server/lib/quota/providers/interface.js create mode 100644 packages/web/server/lib/quota/providers/kimi.js create mode 100644 packages/web/server/lib/quota/providers/nanogpt.js create mode 100644 packages/web/server/lib/quota/providers/openai.js create mode 100644 packages/web/server/lib/quota/providers/openrouter.js create mode 100644 packages/web/server/lib/quota/providers/zai.js create mode 100644 packages/web/server/lib/quota/utils/auth.js create mode 100644 packages/web/server/lib/quota/utils/formatters.js create mode 100644 packages/web/server/lib/quota/utils/index.js create mode 100644 packages/web/server/lib/quota/utils/transformers.js diff --git a/AGENTS.md b/AGENTS.md index 22c97559..2e820fc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,19 @@ Workspaces are `packages/*` (see `package.json`). - Desktop app (Tauri): `packages/desktop` - VS Code extension: `packages/vscode` +## Documentation map +Before changing any mapped module, read its module documentation first. + +### web +Web runtime and server implementation for OpenChamber. + +#### lib +Server-side integration modules used by API routes and runtime services. + +##### quota +Quota provider registry, dispatch, and provider integrations for usage endpoints. +- Module docs: `packages/web/server/lib/quota/DOCUMENTATION.md` + ## Build / dev commands (verified) All scripts are in `package.json`. - Validate: `bun run type-check`, `bun run lint` diff --git a/packages/web/server/index.js b/packages/web/server/index.js index d4ec00f7..0a31c5c3 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -7111,7 +7111,7 @@ async function main(options = {}) { let quotaProviders = null; const getQuotaProviders = async () => { if (!quotaProviders) { - quotaProviders = await import('./lib/quota-providers.js'); + quotaProviders = await import('./lib/quota/index.js'); } return quotaProviders; }; diff --git a/packages/web/server/lib/quota-providers.js b/packages/web/server/lib/quota-providers.js deleted file mode 100644 index 3a3ae3f5..00000000 --- a/packages/web/server/lib/quota-providers.js +++ /dev/null @@ -1,1337 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { readAuthFile } from './opencode-auth.js'; - -const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); -const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); - - -const ANTIGRAVITY_ACCOUNTS_PATHS = [ - path.join(OPENCODE_CONFIG_DIR, 'antigravity-accounts.json'), - path.join(OPENCODE_DATA_DIR, 'antigravity-accounts.json') -]; - -const readJsonFile = (filePath) => { - if (!fs.existsSync(filePath)) { - return null; - } - try { - const raw = fs.readFileSync(filePath, 'utf8'); - const trimmed = raw.trim(); - if (!trimmed) return null; - return JSON.parse(trimmed); - } catch (error) { - console.warn(`Failed to read JSON file: ${filePath}`, error); - return null; - } -}; - -const getAuthEntry = (auth, aliases) => { - for (const alias of aliases) { - if (auth[alias]) { - return auth[alias]; - } - } - return null; -}; - -const normalizeAuthEntry = (entry) => { - if (!entry) return null; - if (typeof entry === 'string') { - return { token: entry }; - } - if (typeof entry === 'object') { - return entry; - } - return null; -}; - -const asObject = (value) => (value && typeof value === 'object' ? value : null); - -const asNonEmptyString = (value) => { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed ? trimmed : null; -}; - -const parseGoogleRefreshToken = (rawRefreshToken) => { - const refreshToken = asNonEmptyString(rawRefreshToken); - if (!refreshToken) { - return { refreshToken: null, projectId: null, managedProjectId: null }; - } - - const [rawToken = '', rawProject = '', rawManagedProject = ''] = refreshToken.split('|'); - return { - refreshToken: asNonEmptyString(rawToken), - projectId: asNonEmptyString(rawProject), - managedProjectId: asNonEmptyString(rawManagedProject) - }; -}; - -const toNumber = (value) => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -}; - -const toTimestamp = (value) => { - if (!value) return null; - if (typeof value === 'number') { - return value < 1_000_000_000_000 ? value * 1000 : value; - } - if (typeof value === 'string') { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : parsed; - } - return null; -}; - -const formatResetTime = (timestamp) => { - try { - const resetDate = new Date(timestamp); - const now = new Date(); - const isToday = resetDate.toDateString() === now.toDateString(); - - if (isToday) { - // Same day: show time only (e.g., "9:56 PM") - return resetDate.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit' - }); - } - - // Different day: show date + weekday + time (e.g., "Feb 2, Sun 9:56 PM") - return resetDate.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - weekday: 'short', - hour: 'numeric', - minute: '2-digit' - }); - } catch { - return null; - } -}; - -const calculateResetAfterSeconds = (resetAt) => { - if (!resetAt) return null; - const delta = Math.floor((resetAt - Date.now()) / 1000); - return delta < 0 ? 0 : delta; -}; - -const toUsageWindow = ({ usedPercent, windowSeconds, resetAt, valueLabel }) => { - const resetAfterSeconds = calculateResetAfterSeconds(resetAt); - const resetFormatted = resetAt ? formatResetTime(resetAt) : null; - return { - usedPercent, - remainingPercent: usedPercent !== null ? Math.max(0, 100 - usedPercent) : null, - windowSeconds: windowSeconds ?? null, - resetAfterSeconds, - resetAt, - resetAtFormatted: resetFormatted, - resetAfterFormatted: resetFormatted, - ...(valueLabel ? { valueLabel } : {}) - }; -}; - -const buildResult = ({ providerId, providerName, ok, configured, usage, error }) => ({ - providerId, - providerName, - ok, - configured, - usage: usage ?? null, - ...(error ? { error } : {}), - fetchedAt: Date.now() -}); - -const durationToLabel = (duration, unit) => { - if (!duration || !unit) return 'limit'; - if (unit === 'TIME_UNIT_MINUTE') return `${duration}m`; - if (unit === 'TIME_UNIT_HOUR') return `${duration}h`; - if (unit === 'TIME_UNIT_DAY') return `${duration}d`; - return 'limit'; -}; - -const durationToSeconds = (duration, unit) => { - if (!duration || !unit) return null; - if (unit === 'TIME_UNIT_MINUTE') return duration * 60; - if (unit === 'TIME_UNIT_HOUR') return duration * 3600; - if (unit === 'TIME_UNIT_DAY') return duration * 86400; - return null; -}; - - -export const listConfiguredQuotaProviders = () => { - const auth = readAuthFile(); - const configured = new Set(); - - const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); - if (anthropicAuth?.access || anthropicAuth?.token) { - configured.add('claude'); - } - - const openaiAuth = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])); - if (openaiAuth?.access || openaiAuth?.token) { - configured.add('codex'); - } - - if (resolveGeminiCliAuth(auth) || resolveAntigravityAuth()) { - configured.add('google'); - } - - const zaiAuth = normalizeAuthEntry(getAuthEntry(auth, ['zai-coding-plan', 'zai', 'z.ai'])); - if (zaiAuth?.key || zaiAuth?.token) { - configured.add('zai-coding-plan'); - } - - const kimiAuth = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])); - if (kimiAuth?.key || kimiAuth?.token) { - configured.add('kimi-for-coding'); - } - - const openrouterAuth = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])); - if (openrouterAuth?.key || openrouterAuth?.token) { - configured.add('openrouter'); - } - - const nanopgAuth = normalizeAuthEntry(getAuthEntry(auth, ['nano-gpt', 'nanogpt', 'nano_gpt'])); - if (nanopgAuth?.key || nanopgAuth?.token) { - configured.add('nano-gpt'); - } - - const copilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); - if (copilotAuth?.access || copilotAuth?.token) { - configured.add('github-copilot'); - configured.add('github-copilot-addon'); - } - - return Array.from(configured); -}; - -export const fetchOpenaiQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])); - const accessToken = entry?.access ?? entry?.token; - - if (!accessToken) { - return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const primary = payload?.rate_limit?.primary_window ?? null; - const secondary = payload?.rate_limit?.secondary_window ?? null; - - const windows = {}; - if (primary) { - windows['5h'] = toUsageWindow({ - usedPercent: primary.used_percent ?? null, - windowSeconds: primary.limit_window_seconds ?? null, - resetAt: primary.reset_at ? primary.reset_at * 1000 : null - }); - } - if (secondary) { - windows['weekly'] = toUsageWindow({ - usedPercent: secondary.used_percent ?? null, - windowSeconds: secondary.limit_window_seconds ?? null, - resetAt: secondary.reset_at ? secondary.reset_at * 1000 : null - }); - } - - return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -// OAuth Secret value used to init client -// Note: It's ok to save this in git because this is an installed application -// as described here: https://developers.google.com/identity/protocols/oauth2#installed -// "The process results in a client ID and, in some cases, a client secret, -// which you embed in the source code of your application. (In this context, -// the client secret is obviously not treated as a secret.)" -// ref: https://github.com/opgginc/opencode-bar - -const ANTIGRAVITY_GOOGLE_CLIENT_ID = - '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; -const ANTIGRAVITY_GOOGLE_CLIENT_SECRET = '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'; -const GEMINI_GOOGLE_CLIENT_ID = - '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; -const GEMINI_GOOGLE_CLIENT_SECRET = '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'; -const DEFAULT_PROJECT_ID = 'rising-fact-p41fc'; -const GOOGLE_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60; -const GOOGLE_DAILY_WINDOW_SECONDS = 24 * 60 * 60; -const GOOGLE_PRIMARY_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; - -const GOOGLE_ENDPOINTS = [ - 'https://daily-cloudcode-pa.sandbox.googleapis.com', - 'https://autopush-cloudcode-pa.sandbox.googleapis.com', - GOOGLE_PRIMARY_ENDPOINT -]; - -const GOOGLE_HEADERS = { - 'User-Agent': 'antigravity/1.11.5 windows/amd64', - 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', - 'Client-Metadata': - '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}' -}; - -const resolveGoogleWindow = (sourceId, resetAt) => { - if (sourceId === 'gemini') { - return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; - } - - if (sourceId === 'antigravity') { - const remainingSeconds = typeof resetAt === 'number' - ? Math.max(0, Math.round((resetAt - Date.now()) / 1000)) - : null; - - if (remainingSeconds !== null && remainingSeconds > 10 * 60 * 60) { - return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; - } - - return { label: '5h', seconds: GOOGLE_FIVE_HOUR_WINDOW_SECONDS }; - } - - return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; -}; - -const resolveGeminiCliAuth = (auth) => { - const entry = normalizeAuthEntry(getAuthEntry(auth, ['google', 'google.oauth'])); - const entryObject = asObject(entry); - if (!entryObject) { - return null; - } - - const oauthObject = asObject(entryObject.oauth) ?? entryObject; - const accessToken = asNonEmptyString(oauthObject.access) ?? asNonEmptyString(oauthObject.token); - const refreshParts = parseGoogleRefreshToken(oauthObject.refresh); - - if (!accessToken && !refreshParts.refreshToken) { - return null; - } - - return { - sourceId: 'gemini', - sourceLabel: 'Gemini', - accessToken, - refreshToken: refreshParts.refreshToken, - projectId: refreshParts.projectId ?? refreshParts.managedProjectId, - expires: toTimestamp(oauthObject.expires) - }; -}; - -const resolveAntigravityAuth = () => { - for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { - const data = readJsonFile(filePath); - const accounts = data?.accounts; - if (Array.isArray(accounts) && accounts.length > 0) { - const index = typeof data.activeIndex === 'number' ? data.activeIndex : 0; - const account = accounts[index] ?? accounts[0]; - if (account?.refreshToken) { - const refreshParts = parseGoogleRefreshToken(account.refreshToken); - return { - sourceId: 'antigravity', - sourceLabel: 'Antigravity', - refreshToken: refreshParts.refreshToken, - projectId: asNonEmptyString(account.projectId) - ?? asNonEmptyString(account.managedProjectId) - ?? refreshParts.projectId - ?? refreshParts.managedProjectId, - email: account.email - }; - } - } - } - - return null; -}; - -const resolveGoogleAuthSources = () => { - const auth = readAuthFile(); - const sources = []; - - const geminiAuth = resolveGeminiCliAuth(auth); - if (geminiAuth) { - sources.push(geminiAuth); - } - - const antigravityAuth = resolveAntigravityAuth(); - if (antigravityAuth) { - sources.push(antigravityAuth); - } - - return sources; -}; - -const resolveGoogleOAuthClient = (sourceId) => { - if (sourceId === 'gemini') { - return { - clientId: GEMINI_GOOGLE_CLIENT_ID, - clientSecret: GEMINI_GOOGLE_CLIENT_SECRET - }; - } - - return { - clientId: ANTIGRAVITY_GOOGLE_CLIENT_ID, - clientSecret: ANTIGRAVITY_GOOGLE_CLIENT_SECRET - }; -}; - -const refreshGoogleAccessToken = async (refreshToken, clientId, clientSecret) => { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }) - }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - return typeof data?.access_token === 'string' ? data.access_token : null; -}; - -const fetchGoogleQuotaBuckets = async (accessToken, projectId) => { - const body = projectId ? { project: projectId } : {}; - - try { - const response = await fetch(`${GOOGLE_PRIMARY_ENDPOINT}/v1internal:retrieveUserQuota`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(15000) - }); - - if (!response.ok) { - return null; - } - - return await response.json(); - } catch { - return null; - } -}; - -const fetchGoogleModels = async (accessToken, projectId) => { - const body = projectId ? { project: projectId } : {}; - - for (const endpoint of GOOGLE_ENDPOINTS) { - try { - const response = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - ...GOOGLE_HEADERS - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(15000) - }); - - if (response.ok) { - return await response.json(); - } - } catch { - continue; - } - } - - return null; -}; - -export const fetchGoogleQuota = async () => { - const authSources = resolveGoogleAuthSources(); - if (!authSources.length) { - return buildResult({ - providerId: 'google', - providerName: 'Google', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - const models = {}; - const sourceErrors = []; - - for (const source of authSources) { - const now = Date.now(); - let accessToken = source.accessToken; - - if (!accessToken || (typeof source.expires === 'number' && source.expires <= now)) { - if (!source.refreshToken) { - sourceErrors.push(`${source.sourceLabel}: Missing refresh token`); - continue; - } - const { clientId, clientSecret } = resolveGoogleOAuthClient(source.sourceId); - accessToken = await refreshGoogleAccessToken(source.refreshToken, clientId, clientSecret); - } - - if (!accessToken) { - sourceErrors.push(`${source.sourceLabel}: Failed to refresh OAuth token`); - continue; - } - - const projectId = source.projectId ?? DEFAULT_PROJECT_ID; - let mergedAnyModel = false; - - if (source.sourceId === 'gemini') { - const quotaPayload = await fetchGoogleQuotaBuckets(accessToken, projectId); - const buckets = Array.isArray(quotaPayload?.buckets) ? quotaPayload.buckets : []; - - for (const bucket of buckets) { - const modelId = asNonEmptyString(bucket?.modelId); - if (!modelId) { - continue; - } - - const scopedName = modelId.startsWith(`${source.sourceId}/`) - ? modelId - : `${source.sourceId}/${modelId}`; - - const remainingFraction = toNumber(bucket?.remainingFraction); - const remainingPercent = remainingFraction !== null - ? Math.round(remainingFraction * 100) - : null; - const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null; - const resetAt = toTimestamp(bucket?.resetTime); - const window = resolveGoogleWindow(source.sourceId, resetAt); - - models[scopedName] = { - windows: { - [window.label]: toUsageWindow({ - usedPercent, - windowSeconds: window.seconds, - resetAt - }) - } - }; - mergedAnyModel = true; - } - } - - const payload = await fetchGoogleModels(accessToken, projectId); - if (payload) { - for (const [modelName, modelData] of Object.entries(payload.models ?? {})) { - const scopedName = modelName.startsWith(`${source.sourceId}/`) - ? modelName - : `${source.sourceId}/${modelName}`; - - const remainingFraction = modelData?.quotaInfo?.remainingFraction; - const remainingPercent = typeof remainingFraction === 'number' - ? Math.round(remainingFraction * 100) - : null; - const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null; - const resetAt = modelData?.quotaInfo?.resetTime - ? new Date(modelData.quotaInfo.resetTime).getTime() - : null; - const window = resolveGoogleWindow(source.sourceId, resetAt); - models[scopedName] = { - windows: { - [window.label]: toUsageWindow({ - usedPercent, - windowSeconds: window.seconds, - resetAt - }) - } - }; - mergedAnyModel = true; - } - } - - if (!mergedAnyModel) { - sourceErrors.push(`${source.sourceLabel}: Failed to fetch models`); - } - } - - if (!Object.keys(models).length) { - return buildResult({ - providerId: 'google', - providerName: 'Google', - ok: false, - configured: true, - error: sourceErrors[0] ?? 'Failed to fetch models' - }); - } - - return buildResult({ - providerId: 'google', - providerName: 'Google', - ok: true, - configured: true, - usage: { - windows: {}, - models: Object.keys(models).length ? models : undefined - } - }); -}; - -const formatMoney = (value) => { - if (typeof value !== 'number' || !Number.isFinite(value)) return null; - return value.toFixed(2); -}; - -export const fetchClaudeQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); - const accessToken = entry?.access ?? entry?.token; - - if (!accessToken) { - return buildResult({ - providerId: 'claude', - providerName: 'Claude', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.anthropic.com/api/oauth/usage', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'anthropic-beta': 'oauth-2025-04-20' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'claude', - providerName: 'Claude', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const windows = {}; - const fiveHour = payload?.five_hour ?? null; - const sevenDay = payload?.seven_day ?? null; - const sevenDaySonnet = payload?.seven_day_sonnet ?? null; - const sevenDayOpus = payload?.seven_day_opus ?? null; - - if (fiveHour) { - windows['5h'] = toUsageWindow({ - usedPercent: toNumber(fiveHour.utilization), - windowSeconds: null, - resetAt: toTimestamp(fiveHour.resets_at) - }); - } - if (sevenDay) { - windows['7d'] = toUsageWindow({ - usedPercent: toNumber(sevenDay.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDay.resets_at) - }); - } - if (sevenDaySonnet) { - windows['7d-sonnet'] = toUsageWindow({ - usedPercent: toNumber(sevenDaySonnet.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDaySonnet.resets_at) - }); - } - if (sevenDayOpus) { - windows['7d-opus'] = toUsageWindow({ - usedPercent: toNumber(sevenDayOpus.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDayOpus.resets_at) - }); - } - - return buildResult({ - providerId: 'claude', - providerName: 'Claude', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'claude', - providerName: 'Claude', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -export const fetchCodexQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])); - const accessToken = entry?.access ?? entry?.token; - const accountId = entry?.accountId; - - if (!accessToken) { - return buildResult({ - providerId: 'codex', - providerName: 'Codex', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const headers = { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}) - }; - const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { - method: 'GET', - headers - }); - - if (!response.ok) { - return buildResult({ - providerId: 'codex', - providerName: 'Codex', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const primary = payload?.rate_limit?.primary_window ?? null; - const secondary = payload?.rate_limit?.secondary_window ?? null; - const credits = payload?.credits ?? null; - - const windows = {}; - if (primary) { - windows['5h'] = toUsageWindow({ - usedPercent: toNumber(primary.used_percent), - windowSeconds: toNumber(primary.limit_window_seconds), - resetAt: toTimestamp(primary.reset_at) - }); - } - if (secondary) { - windows['weekly'] = toUsageWindow({ - usedPercent: toNumber(secondary.used_percent), - windowSeconds: toNumber(secondary.limit_window_seconds), - resetAt: toTimestamp(secondary.reset_at) - }); - } - if (credits) { - const balance = toNumber(credits.balance); - const unlimited = Boolean(credits.unlimited); - const label = unlimited - ? 'Unlimited' - : balance !== null - ? `$${formatMoney(balance)} remaining` - : null; - windows.credits = toUsageWindow({ - usedPercent: null, - windowSeconds: null, - resetAt: null, - valueLabel: label - }); - } - - return buildResult({ - providerId: 'codex', - providerName: 'Codex', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'codex', - providerName: 'Codex', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -const buildCopilotWindows = (payload) => { - const quota = payload?.quota_snapshots ?? {}; - const resetAt = toTimestamp(payload?.quota_reset_date); - const windows = {}; - - const addWindow = (label, snapshot) => { - if (!snapshot) return; - const entitlement = toNumber(snapshot.entitlement); - const remaining = toNumber(snapshot.remaining); - const usedPercent = entitlement && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100)) - : null; - const valueLabel = entitlement !== null && remaining !== null - ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` - : null; - windows[label] = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt, - valueLabel - }); - }; - - addWindow('chat', quota.chat); - addWindow('completions', quota.completions); - addWindow('premium', quota.premium_interactions); - - return windows; -}; - -export const fetchCopilotQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); - const accessToken = entry?.access ?? entry?.token; - - if (!accessToken) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.github.com/copilot_internal/user', { - method: 'GET', - headers: { - Authorization: `token ${accessToken}`, - Accept: 'application/json', - 'Editor-Version': 'vscode/1.96.2', - 'X-Github-Api-Version': '2025-04-01' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: true, - configured: true, - usage: { windows: buildCopilotWindows(payload) } - }); - } catch (error) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -export const fetchCopilotAddonQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); - const accessToken = entry?.access ?? entry?.token; - - if (!accessToken) { - return buildResult({ - providerId: 'github-copilot-addon', - providerName: 'GitHub Copilot Add-on', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.github.com/copilot_internal/user', { - method: 'GET', - headers: { - Authorization: `token ${accessToken}`, - Accept: 'application/json', - 'Editor-Version': 'vscode/1.96.2', - 'X-Github-Api-Version': '2025-04-01' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'github-copilot-addon', - providerName: 'GitHub Copilot Add-on', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const windows = buildCopilotWindows(payload); - const premium = windows.premium ? { premium: windows.premium } : windows; - - return buildResult({ - providerId: 'github-copilot-addon', - providerName: 'GitHub Copilot Add-on', - ok: true, - configured: true, - usage: { windows: premium } - }); - } catch (error) { - return buildResult({ - providerId: 'github-copilot-addon', - providerName: 'GitHub Copilot Add-on', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -export const fetchKimiQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])); - const apiKey = entry?.key ?? entry?.token; - - if (!apiKey) { - return buildResult({ - providerId: 'kimi-for-coding', - providerName: 'Kimi for Coding', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.kimi.com/coding/v1/usages', { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'kimi-for-coding', - providerName: 'Kimi for Coding', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const windows = {}; - const usage = payload?.usage ?? null; - if (usage) { - const limit = toNumber(usage.limit); - const remaining = toNumber(usage.remaining); - const usedPercent = limit && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100)) - : null; - windows.weekly = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt: toTimestamp(usage.resetTime) - }); - } - - const limits = Array.isArray(payload?.limits) ? payload.limits : []; - for (const limit of limits) { - const window = limit?.window; - const detail = limit?.detail; - const rawLabel = durationToLabel(window?.duration, window?.timeUnit); - const windowSeconds = durationToSeconds(window?.duration, window?.timeUnit); - const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel; - const total = toNumber(detail?.limit); - const remaining = toNumber(detail?.remaining); - const usedPercent = total && remaining !== null - ? Math.max(0, Math.min(100, 100 - (remaining / total) * 100)) - : null; - windows[label] = toUsageWindow({ - usedPercent, - windowSeconds, - resetAt: toTimestamp(detail?.resetTime) - }); - } - - return buildResult({ - providerId: 'kimi-for-coding', - providerName: 'Kimi for Coding', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'kimi-for-coding', - providerName: 'Kimi for Coding', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -export const fetchOpenRouterQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])); - const apiKey = entry?.key ?? entry?.token; - - if (!apiKey) { - return buildResult({ - providerId: 'openrouter', - providerName: 'OpenRouter', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://openrouter.ai/api/v1/credits', { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'openrouter', - providerName: 'OpenRouter', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const credits = payload?.data ?? {}; - const totalCredits = toNumber(credits.total_credits); - const totalUsage = toNumber(credits.total_usage); - const remaining = totalCredits !== null && totalUsage !== null - ? Math.max(0, totalCredits - totalUsage) - : null; - const usedPercent = totalCredits && totalUsage !== null - ? Math.max(0, Math.min(100, (totalUsage / totalCredits) * 100)) - : null; - const valueLabel = remaining !== null ? `$${formatMoney(remaining)} remaining` : null; - - const windows = { - credits: toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt: null, - valueLabel - }) - }; - - return buildResult({ - providerId: 'openrouter', - providerName: 'OpenRouter', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'openrouter', - providerName: 'OpenRouter', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -const normalizeTimestamp = (value) => { - if (typeof value !== 'number') return null; - return value < 1_000_000_000_000 ? value * 1000 : value; -}; - -const ZAI_TOKEN_WINDOW_SECONDS = { 3: 3600 }; - -const resolveWindowSeconds = (limit) => { - if (!limit || !limit.number) return null; - const unitSeconds = ZAI_TOKEN_WINDOW_SECONDS[limit.unit]; - if (!unitSeconds) return null; - return unitSeconds * limit.number; -}; - -const resolveWindowLabel = (windowSeconds) => { - if (!windowSeconds) return 'tokens'; - if (windowSeconds % 86400 === 0) { - const days = windowSeconds / 86400; - return days === 7 ? 'weekly' : `${days}d`; - } - if (windowSeconds % 3600 === 0) { - return `${windowSeconds / 3600}h`; - } - return `${windowSeconds}s`; -}; - -export const fetchZaiQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['zai-coding-plan', 'zai', 'z.ai'])); - const apiKey = entry?.key ?? entry?.token; - - if (!apiKey) { - return buildResult({ - providerId: 'zai-coding-plan', - providerName: 'z.ai', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.z.ai/api/monitor/usage/quota/limit', { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'zai-coding-plan', - providerName: 'z.ai', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : []; - const tokensLimit = limits.find((limit) => limit?.type === 'TOKENS_LIMIT'); - const windowSeconds = resolveWindowSeconds(tokensLimit); - const windowLabel = resolveWindowLabel(windowSeconds); - const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null; - const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null; - - const windows = {}; - if (tokensLimit) { - windows[windowLabel] = toUsageWindow({ - usedPercent, - windowSeconds, - resetAt - }); - } - - return buildResult({ - providerId: 'zai-coding-plan', - providerName: 'z.ai', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'zai-coding-plan', - providerName: 'z.ai', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -const NANO_GPT_DAILY_WINDOW_SECONDS = 86400; - -export const fetchNanoGptQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['nano-gpt', 'nanogpt', 'nano_gpt'])); - const apiKey = entry?.key ?? entry?.token; - - if (!apiKey) { - return buildResult({ - providerId: 'nano-gpt', - providerName: 'NanoGPT', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://nano-gpt.com/api/subscription/v1/usage', { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'nano-gpt', - providerName: 'NanoGPT', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const windows = {}; - const period = payload?.period ?? null; - const daily = payload?.daily ?? null; - const monthly = payload?.monthly ?? null; - const state = payload?.state ?? 'active'; - - if (daily) { - let usedPercent = null; - const percentUsed = daily?.percentUsed; - if (typeof percentUsed === 'number') { - usedPercent = Math.max(0, Math.min(100, percentUsed * 100)); - } else { - const used = toNumber(daily?.used); - const limit = toNumber(daily?.limit ?? daily?.limits?.daily); - if (used !== null && limit !== null && limit > 0) { - usedPercent = Math.max(0, Math.min(100, (used / limit) * 100)); - } - } - const resetAt = toTimestamp(daily?.resetAt); - const valueLabel = state !== 'active' ? `(${state})` : null; - windows['daily'] = toUsageWindow({ - usedPercent, - windowSeconds: NANO_GPT_DAILY_WINDOW_SECONDS, - resetAt, - valueLabel - }); - } - - if (monthly) { - let usedPercent = null; - const percentUsed = monthly?.percentUsed; - if (typeof percentUsed === 'number') { - usedPercent = Math.max(0, Math.min(100, percentUsed * 100)); - } else { - const used = toNumber(monthly?.used); - const limit = toNumber(monthly?.limit ?? monthly?.limits?.monthly); - if (used !== null && limit !== null && limit > 0) { - usedPercent = Math.max(0, Math.min(100, (used / limit) * 100)); - } - } - const resetAt = toTimestamp(monthly?.resetAt ?? period?.currentPeriodEnd); - const valueLabel = state !== 'active' ? `(${state})` : null; - windows['monthly'] = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt, - valueLabel - }); - } - - return buildResult({ - providerId: 'nano-gpt', - providerName: 'NanoGPT', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'nano-gpt', - providerName: 'NanoGPT', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - -export const fetchQuotaForProvider = async (providerId) => { - switch (providerId) { - case 'claude': - return fetchClaudeQuota(); - case 'codex': - return fetchCodexQuota(); - case 'github-copilot': - return fetchCopilotQuota(); - case 'github-copilot-addon': - return fetchCopilotAddonQuota(); - case 'google': - return fetchGoogleQuota(); - case 'kimi-for-coding': - return fetchKimiQuota(); - case 'nano-gpt': - return fetchNanoGptQuota(); - case 'openrouter': - return fetchOpenRouterQuota(); - case 'zai-coding-plan': - return fetchZaiQuota(); - default: - return buildResult({ - providerId, - providerName: providerId, - ok: false, - configured: false, - error: 'Unsupported provider' - }); - } -}; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md new file mode 100644 index 00000000..e5a9f72b --- /dev/null +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -0,0 +1,52 @@ +# Quota Module Documentation + +## Purpose +This module fetches quota and usage signals for supported providers in the web server runtime. + +## Entrypoints and structure +- `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher. +- `packages/web/server/lib/quota/providers/interface.js`: JSDoc provider contract used as implementation reference. +- `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules. +- `packages/web/server/lib/quota/utils/`: shared auth, transform, and formatting helpers. + +## Supported provider IDs (dispatcher) + +These provider IDs are currently dispatchable via `fetchQuotaForProvider(providerId)` in `packages/web/server/lib/quota/providers/index.js`. + +| Provider ID | Display name | Module | Auth aliases/keys | +| --- | --- | --- | --- | +| `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` | +| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` | +| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file | +| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` | +| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` | +| `kimi-for-coding` | Kimi for Coding | `providers/kimi.js` | `kimi-for-coding`, `kimi` | +| `nano-gpt` | NanoGPT | `providers/nanogpt.js` | `nano-gpt`, `nanogpt`, `nano_gpt` | +| `openrouter` | OpenRouter | `providers/openrouter.js` | `openrouter` | +| `zai-coding-plan` | z.ai | `providers/zai.js` | `zai-coding-plan`, `zai`, `z.ai` | + +## Internal-only provider module +- `providers/openai.js` exists for logic parity/reuse but is intentionally not registered for dispatcher ID routing. + +## Response contract +All providers should return results via shared helpers to preserve API shape: +- Required fields: `providerId`, `providerName`, `ok`, `configured`, `usage`, `fetchedAt` +- Optional field: `error` +- Unsupported provider requests should return `ok: false`, `configured: false`, `error: Unsupported provider` + +## Add a new provider (quick steps) +1. Choose module shape based on complexity: + - Simple providers: create `packages/web/server/lib/quota/providers/.js`. + - Complex providers (multi-source auth, multiple API calls, non-trivial transforms): create `packages/web/server/lib/quota/providers//` with split modules like Google (`index.js`, `auth.js`, `api.js`, `transforms.js`). +2. Export `providerId`, `providerName`, `aliases`, `isConfigured`, and `fetchQuota`. +3. Use shared helpers from `packages/web/server/lib/quota/utils/index.js` (`buildResult`, `toUsageWindow`, auth/conversion helpers) to keep payload shape consistent. +4. Register the provider in `packages/web/server/lib/quota/providers/index.js`. +5. If needed for direct use, export a named fetcher from `packages/web/server/lib/quota/providers/index.js` and `packages/web/server/lib/quota/index.js`. +6. Update this file with the new provider ID, module path, and alias/auth details. +7. Validate with `bun run type-check`, `bun run lint`, and `bun run build`. + +## Notes for contributors +- Keep provider IDs stable; clients use them directly. +- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs. +- Keep Google behavior changes isolated and review `providers/google/*` together. diff --git a/packages/web/server/lib/quota/index.js b/packages/web/server/lib/quota/index.js new file mode 100644 index 00000000..7c8e83e4 --- /dev/null +++ b/packages/web/server/lib/quota/index.js @@ -0,0 +1,21 @@ +/** + * Quota module + * + * Provides quota usage tracking for various AI provider services. + * @module quota + */ + +export { + listConfiguredQuotaProviders, + fetchQuotaForProvider, + fetchClaudeQuota, + fetchOpenaiQuota, + fetchGoogleQuota, + fetchCodexQuota, + fetchCopilotQuota, + fetchCopilotAddonQuota, + fetchKimiQuota, + fetchOpenRouterQuota, + fetchZaiQuota, + fetchNanoGptQuota +} from './providers/index.js'; diff --git a/packages/web/server/lib/quota/providers/claude.js b/packages/web/server/lib/quota/providers/claude.js new file mode 100644 index 00000000..a871a3f8 --- /dev/null +++ b/packages/web/server/lib/quota/providers/claude.js @@ -0,0 +1,107 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp +} from '../utils/index.js'; + +export const providerId = 'claude'; +export const providerName = 'Claude'; +export const aliases = ['anthropic', 'claude']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.access || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = {}; + const fiveHour = payload?.five_hour ?? null; + const sevenDay = payload?.seven_day ?? null; + const sevenDaySonnet = payload?.seven_day_sonnet ?? null; + const sevenDayOpus = payload?.seven_day_opus ?? null; + + if (fiveHour) { + windows['5h'] = toUsageWindow({ + usedPercent: toNumber(fiveHour.utilization), + windowSeconds: null, + resetAt: toTimestamp(fiveHour.resets_at) + }); + } + if (sevenDay) { + windows['7d'] = toUsageWindow({ + usedPercent: toNumber(sevenDay.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDay.resets_at) + }); + } + if (sevenDaySonnet) { + windows['7d-sonnet'] = toUsageWindow({ + usedPercent: toNumber(sevenDaySonnet.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDaySonnet.resets_at) + }); + } + if (sevenDayOpus) { + windows['7d-opus'] = toUsageWindow({ + usedPercent: toNumber(sevenDayOpus.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDayOpus.resets_at) + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/codex.js b/packages/web/server/lib/quota/providers/codex.js new file mode 100644 index 00000000..4aec2ca5 --- /dev/null +++ b/packages/web/server/lib/quota/providers/codex.js @@ -0,0 +1,111 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp, + formatMoney +} from '../utils/index.js'; + +export const providerId = 'codex'; +export const providerName = 'Codex'; +export const aliases = ['openai', 'codex', 'chatgpt']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.access || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const accessToken = entry?.access ?? entry?.token; + const accountId = entry?.accountId; + + if (!accessToken) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const headers = { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}) + }; + const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { + method: 'GET', + headers + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const primary = payload?.rate_limit?.primary_window ?? null; + const secondary = payload?.rate_limit?.secondary_window ?? null; + const credits = payload?.credits ?? null; + + const windows = {}; + if (primary) { + windows['5h'] = toUsageWindow({ + usedPercent: toNumber(primary.used_percent), + windowSeconds: toNumber(primary.limit_window_seconds), + resetAt: toTimestamp(primary.reset_at) + }); + } + if (secondary) { + windows['weekly'] = toUsageWindow({ + usedPercent: toNumber(secondary.used_percent), + windowSeconds: toNumber(secondary.limit_window_seconds), + resetAt: toTimestamp(secondary.reset_at) + }); + } + if (credits) { + const balance = toNumber(credits.balance); + const unlimited = Boolean(credits.unlimited); + const label = unlimited + ? 'Unlimited' + : balance !== null + ? `$${formatMoney(balance)} remaining` + : null; + windows.credits = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: label + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/copilot.js b/packages/web/server/lib/quota/providers/copilot.js new file mode 100644 index 00000000..1064562d --- /dev/null +++ b/packages/web/server/lib/quota/providers/copilot.js @@ -0,0 +1,165 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp +} from '../utils/index.js'; + +const buildCopilotWindows = (payload) => { + const quota = payload?.quota_snapshots ?? {}; + const resetAt = toTimestamp(payload?.quota_reset_date); + const windows = {}; + + const addWindow = (label, snapshot) => { + if (!snapshot) return; + const entitlement = toNumber(snapshot.entitlement); + const remaining = toNumber(snapshot.remaining); + const usedPercent = entitlement && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100)) + : null; + const valueLabel = entitlement !== null && remaining !== null + ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` + : null; + windows[label] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + valueLabel + }); + }; + + addWindow('chat', quota.chat); + addWindow('completions', quota.completions); + addWindow('premium', quota.premium_interactions); + + return windows; +}; + +export const providerId = 'github-copilot'; +export const providerName = 'GitHub Copilot'; +export const aliases = ['github-copilot', 'copilot']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.access || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + 'Editor-Version': 'vscode/1.96.2', + 'X-Github-Api-Version': '2025-04-01' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows: buildCopilotWindows(payload) } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +export const providerIdAddon = 'github-copilot-addon'; +export const providerNameAddon = 'GitHub Copilot Add-on'; + +export const fetchQuotaAddon = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId: providerIdAddon, + providerName: providerNameAddon, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + 'Editor-Version': 'vscode/1.96.2', + 'X-Github-Api-Version': '2025-04-01' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: providerIdAddon, + providerName: providerNameAddon, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = buildCopilotWindows(payload); + const premium = windows.premium ? { premium: windows.premium } : windows; + + return buildResult({ + providerId: providerIdAddon, + providerName: providerNameAddon, + ok: true, + configured: true, + usage: { windows: premium } + }); + } catch (error) { + return buildResult({ + providerId: providerIdAddon, + providerName: providerNameAddon, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/google/api.js b/packages/web/server/lib/quota/providers/google/api.js new file mode 100644 index 00000000..49190165 --- /dev/null +++ b/packages/web/server/lib/quota/providers/google/api.js @@ -0,0 +1,92 @@ +/** + * Google Provider - API + * + * API calls for Google quota providers. + * @module quota/providers/google/api + */ + +const GOOGLE_PRIMARY_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; + +const GOOGLE_ENDPOINTS = [ + 'https://daily-cloudcode-pa.sandbox.googleapis.com', + 'https://autopush-cloudcode-pa.sandbox.googleapis.com', + GOOGLE_PRIMARY_ENDPOINT +]; + +const GOOGLE_HEADERS = { + 'User-Agent': 'antigravity/1.11.5 windows/amd64', + 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', + 'Client-Metadata': + '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}' +}; + +export const refreshGoogleAccessToken = async (refreshToken, clientId, clientSecret) => { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }) + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + return typeof data?.access_token === 'string' ? data.access_token : null; +}; + +export const fetchGoogleQuotaBuckets = async (accessToken, projectId) => { + const body = projectId ? { project: projectId } : {}; + + try { + const response = await fetch(`${GOOGLE_PRIMARY_ENDPOINT}/v1internal:retrieveUserQuota`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000) + }); + + if (!response.ok) { + return null; + } + + return await response.json(); + } catch { + return null; + } +}; + +export const fetchGoogleModels = async (accessToken, projectId) => { + const body = projectId ? { project: projectId } : {}; + + for (const endpoint of GOOGLE_ENDPOINTS) { + try { + const response = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...GOOGLE_HEADERS + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000) + }); + + if (response.ok) { + return await response.json(); + } + } catch { + continue; + } + } + + return null; +}; diff --git a/packages/web/server/lib/quota/providers/google/auth.js b/packages/web/server/lib/quota/providers/google/auth.js new file mode 100644 index 00000000..9f7477c3 --- /dev/null +++ b/packages/web/server/lib/quota/providers/google/auth.js @@ -0,0 +1,108 @@ +/** + * Google Provider - Auth + * + * Authentication resolution logic for Google quota providers. + * @module quota/providers/google/auth + */ + +import { + ANTIGRAVITY_ACCOUNTS_PATHS, + readJsonFile, + getAuthEntry, + normalizeAuthEntry, + asObject, + asNonEmptyString, + toTimestamp +} from '../../utils/index.js'; +import { readAuthFile } from '../../../opencode-auth.js'; +import { parseGoogleRefreshToken } from './transforms.js'; + +const ANTIGRAVITY_GOOGLE_CLIENT_ID = + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +const ANTIGRAVITY_GOOGLE_CLIENT_SECRET = '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'; +const GEMINI_GOOGLE_CLIENT_ID = + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; +const GEMINI_GOOGLE_CLIENT_SECRET = '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'; +export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc'; + +export const resolveGoogleOAuthClient = (sourceId) => { + if (sourceId === 'gemini') { + return { + clientId: GEMINI_GOOGLE_CLIENT_ID, + clientSecret: GEMINI_GOOGLE_CLIENT_SECRET + }; + } + + return { + clientId: ANTIGRAVITY_GOOGLE_CLIENT_ID, + clientSecret: ANTIGRAVITY_GOOGLE_CLIENT_SECRET + }; +}; + +export const resolveGeminiCliAuth = (auth) => { + const entry = normalizeAuthEntry(getAuthEntry(auth, ['google', 'google.oauth'])); + const entryObject = asObject(entry); + if (!entryObject) { + return null; + } + + const oauthObject = asObject(entryObject.oauth) ?? entryObject; + const accessToken = asNonEmptyString(oauthObject.access) ?? asNonEmptyString(oauthObject.token); + const refreshParts = parseGoogleRefreshToken(oauthObject.refresh); + + if (!accessToken && !refreshParts.refreshToken) { + return null; + } + + return { + sourceId: 'gemini', + sourceLabel: 'Gemini', + accessToken, + refreshToken: refreshParts.refreshToken, + projectId: refreshParts.projectId ?? refreshParts.managedProjectId, + expires: toTimestamp(oauthObject.expires) + }; +}; + +export const resolveAntigravityAuth = () => { + for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { + const data = readJsonFile(filePath); + const accounts = data?.accounts; + if (Array.isArray(accounts) && accounts.length > 0) { + const index = typeof data.activeIndex === 'number' ? data.activeIndex : 0; + const account = accounts[index] ?? accounts[0]; + if (account?.refreshToken) { + const refreshParts = parseGoogleRefreshToken(account.refreshToken); + return { + sourceId: 'antigravity', + sourceLabel: 'Antigravity', + refreshToken: refreshParts.refreshToken, + projectId: asNonEmptyString(account.projectId) + ?? asNonEmptyString(account.managedProjectId) + ?? refreshParts.projectId + ?? refreshParts.managedProjectId, + email: account.email + }; + } + } + } + + return null; +}; + +export const resolveGoogleAuthSources = () => { + const auth = readAuthFile(); + const sources = []; + + const geminiAuth = resolveGeminiCliAuth(auth); + if (geminiAuth) { + sources.push(geminiAuth); + } + + const antigravityAuth = resolveAntigravityAuth(); + if (antigravityAuth) { + sources.push(antigravityAuth); + } + + return sources; +}; diff --git a/packages/web/server/lib/quota/providers/google/index.js b/packages/web/server/lib/quota/providers/google/index.js new file mode 100644 index 00000000..0fabc907 --- /dev/null +++ b/packages/web/server/lib/quota/providers/google/index.js @@ -0,0 +1,124 @@ +/** + * Google Provider + * + * Google quota provider implementation. + * @module quota/providers/google + */ + +export { + resolveGoogleOAuthClient, + resolveGeminiCliAuth, + resolveAntigravityAuth, + resolveGoogleAuthSources, + DEFAULT_PROJECT_ID +} from './auth.js'; + +export { + resolveGoogleWindow, + transformQuotaBucket, + transformModelData +} from './transforms.js'; + +export { + refreshGoogleAccessToken, + fetchGoogleQuotaBuckets, + fetchGoogleModels +} from './api.js'; + +import { buildResult } from '../../utils/index.js'; +import { + resolveGoogleAuthSources, + resolveGoogleOAuthClient, + DEFAULT_PROJECT_ID +} from './auth.js'; +import { transformQuotaBucket, transformModelData } from './transforms.js'; +import { + refreshGoogleAccessToken, + fetchGoogleQuotaBuckets, + fetchGoogleModels +} from './api.js'; + +export const fetchGoogleQuota = async () => { + const authSources = resolveGoogleAuthSources(); + if (!authSources.length) { + return buildResult({ + providerId: 'google', + providerName: 'Google', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const models = {}; + const sourceErrors = []; + + for (const source of authSources) { + const now = Date.now(); + let accessToken = source.accessToken; + + if (!accessToken || (typeof source.expires === 'number' && source.expires <= now)) { + if (!source.refreshToken) { + sourceErrors.push(`${source.sourceLabel}: Missing refresh token`); + continue; + } + const { clientId, clientSecret } = resolveGoogleOAuthClient(source.sourceId); + accessToken = await refreshGoogleAccessToken(source.refreshToken, clientId, clientSecret); + } + + if (!accessToken) { + sourceErrors.push(`${source.sourceLabel}: Failed to refresh OAuth token`); + continue; + } + + const projectId = source.projectId ?? DEFAULT_PROJECT_ID; + let mergedAnyModel = false; + + if (source.sourceId === 'gemini') { + const quotaPayload = await fetchGoogleQuotaBuckets(accessToken, projectId); + const buckets = Array.isArray(quotaPayload?.buckets) ? quotaPayload.buckets : []; + + for (const bucket of buckets) { + const transformed = transformQuotaBucket(bucket, source.sourceId); + if (transformed) { + Object.assign(models, transformed); + mergedAnyModel = true; + } + } + } + + const payload = await fetchGoogleModels(accessToken, projectId); + if (payload) { + for (const [modelName, modelData] of Object.entries(payload.models ?? {})) { + const transformed = transformModelData(modelName, modelData, source.sourceId); + Object.assign(models, transformed); + mergedAnyModel = true; + } + } + + if (!mergedAnyModel) { + sourceErrors.push(`${source.sourceLabel}: Failed to fetch models`); + } + } + + if (!Object.keys(models).length) { + return buildResult({ + providerId: 'google', + providerName: 'Google', + ok: false, + configured: true, + error: sourceErrors[0] ?? 'Failed to fetch models' + }); + } + + return buildResult({ + providerId: 'google', + providerName: 'Google', + ok: true, + configured: true, + usage: { + windows: {}, + models: Object.keys(models).length ? models : undefined + } + }); +}; diff --git a/packages/web/server/lib/quota/providers/google/transforms.js b/packages/web/server/lib/quota/providers/google/transforms.js new file mode 100644 index 00000000..9954588c --- /dev/null +++ b/packages/web/server/lib/quota/providers/google/transforms.js @@ -0,0 +1,109 @@ +/** + * Google Provider - Transforms + * + * Data transformation functions for Google quota responses. + * @module quota/providers/google/transforms + */ + +import { + asNonEmptyString, + toNumber, + toTimestamp, + toUsageWindow +} from '../../utils/index.js'; + +const GOOGLE_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60; +const GOOGLE_DAILY_WINDOW_SECONDS = 24 * 60 * 60; + +export const parseGoogleRefreshToken = (rawRefreshToken) => { + const refreshToken = asNonEmptyString(rawRefreshToken); + if (!refreshToken) { + return { refreshToken: null, projectId: null, managedProjectId: null }; + } + + const [rawToken = '', rawProject = '', rawManagedProject = ''] = refreshToken.split('|'); + return { + refreshToken: asNonEmptyString(rawToken), + projectId: asNonEmptyString(rawProject), + managedProjectId: asNonEmptyString(rawManagedProject) + }; +}; + +export const resolveGoogleWindow = (sourceId, resetAt) => { + if (sourceId === 'gemini') { + return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; + } + + if (sourceId === 'antigravity') { + const remainingSeconds = typeof resetAt === 'number' + ? Math.max(0, Math.round((resetAt - Date.now()) / 1000)) + : null; + + if (remainingSeconds !== null && remainingSeconds > 10 * 60 * 60) { + return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; + } + + return { label: '5h', seconds: GOOGLE_FIVE_HOUR_WINDOW_SECONDS }; + } + + return { label: 'daily', seconds: GOOGLE_DAILY_WINDOW_SECONDS }; +}; + +export const transformQuotaBucket = (bucket, sourceId) => { + const modelId = asNonEmptyString(bucket?.modelId); + if (!modelId) { + return null; + } + + const scopedName = modelId.startsWith(`${sourceId}/`) + ? modelId + : `${sourceId}/${modelId}`; + + const remainingFraction = toNumber(bucket?.remainingFraction); + const remainingPercent = remainingFraction !== null + ? Math.round(remainingFraction * 100) + : null; + const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null; + const resetAt = toTimestamp(bucket?.resetTime); + const window = resolveGoogleWindow(sourceId, resetAt); + + return { + [scopedName]: { + windows: { + [window.label]: toUsageWindow({ + usedPercent, + windowSeconds: window.seconds, + resetAt + }) + } + } + }; +}; + +export const transformModelData = (modelName, modelData, sourceId) => { + const scopedName = modelName.startsWith(`${sourceId}/`) + ? modelName + : `${sourceId}/${modelName}`; + + const remainingFraction = modelData?.quotaInfo?.remainingFraction; + const remainingPercent = typeof remainingFraction === 'number' + ? Math.round(remainingFraction * 100) + : null; + const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null; + const resetAt = modelData?.quotaInfo?.resetTime + ? new Date(modelData.quotaInfo.resetTime).getTime() + : null; + const window = resolveGoogleWindow(sourceId, resetAt); + + return { + [scopedName]: { + windows: { + [window.label]: toUsageWindow({ + usedPercent, + windowSeconds: window.seconds, + resetAt + }) + } + } + }; +}; diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js new file mode 100644 index 00000000..3d208475 --- /dev/null +++ b/packages/web/server/lib/quota/providers/index.js @@ -0,0 +1,128 @@ +/** + * Quota Providers Registry + * + * Implements quota fetching for various AI providers using a registry pattern. + * @module quota/providers + */ + +import { buildResult } from '../utils/index.js'; + +import * as claude from './claude.js'; +import * as codex from './codex.js'; +import * as copilot from './copilot.js'; +import * as google from './google/index.js'; +import * as kimi from './kimi.js'; +import * as nanogpt from './nanogpt.js'; +import * as openai from './openai.js'; +import * as openrouter from './openrouter.js'; +import * as zai from './zai.js'; + +const registry = { + claude: { + providerId: claude.providerId, + providerName: claude.providerName, + isConfigured: claude.isConfigured, + fetchQuota: claude.fetchQuota + }, + codex: { + providerId: codex.providerId, + providerName: codex.providerName, + isConfigured: codex.isConfigured, + fetchQuota: codex.fetchQuota + }, + google: { + providerId: 'google', + providerName: 'Google', + isConfigured: () => google.resolveGoogleAuthSources().length > 0, + fetchQuota: google.fetchGoogleQuota + }, + 'zai-coding-plan': { + providerId: zai.providerId, + providerName: zai.providerName, + isConfigured: zai.isConfigured, + fetchQuota: zai.fetchQuota + }, + 'kimi-for-coding': { + providerId: kimi.providerId, + providerName: kimi.providerName, + isConfigured: kimi.isConfigured, + fetchQuota: kimi.fetchQuota + }, + openrouter: { + providerId: openrouter.providerId, + providerName: openrouter.providerName, + isConfigured: openrouter.isConfigured, + fetchQuota: openrouter.fetchQuota + }, + 'nano-gpt': { + providerId: nanogpt.providerId, + providerName: nanogpt.providerName, + isConfigured: nanogpt.isConfigured, + fetchQuota: nanogpt.fetchQuota + }, + 'github-copilot': { + providerId: copilot.providerId, + providerName: copilot.providerName, + isConfigured: copilot.isConfigured, + fetchQuota: copilot.fetchQuota + }, + 'github-copilot-addon': { + providerId: copilot.providerIdAddon, + providerName: copilot.providerNameAddon, + isConfigured: copilot.isConfigured, + fetchQuota: copilot.fetchQuotaAddon + } +}; + +export const listConfiguredQuotaProviders = () => { + const configured = []; + + for (const [id, provider] of Object.entries(registry)) { + try { + if (provider.isConfigured()) { + configured.push(id); + } + } catch { + // Ignore provider-specific config errors in list API. + } + } + + return configured; +}; + +export const fetchQuotaForProvider = async (providerId) => { + const provider = registry[providerId]; + + if (!provider) { + return buildResult({ + providerId, + providerName: providerId, + ok: false, + configured: false, + error: 'Unsupported provider' + }); + } + + try { + return await provider.fetchQuota(); + } catch (error) { + return buildResult({ + providerId: provider.providerId, + providerName: provider.providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +export const fetchClaudeQuota = claude.fetchQuota; +export const fetchOpenaiQuota = openai.fetchQuota; +export const fetchGoogleQuota = google.fetchGoogleQuota; +export const fetchCodexQuota = codex.fetchQuota; +export const fetchCopilotQuota = copilot.fetchQuota; +export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon; +export const fetchKimiQuota = kimi.fetchQuota; +export const fetchOpenRouterQuota = openrouter.fetchQuota; +export const fetchZaiQuota = zai.fetchQuota; +export const fetchNanoGptQuota = nanogpt.fetchQuota; diff --git a/packages/web/server/lib/quota/providers/interface.js b/packages/web/server/lib/quota/providers/interface.js new file mode 100644 index 00000000..9bf461a7 --- /dev/null +++ b/packages/web/server/lib/quota/providers/interface.js @@ -0,0 +1,55 @@ +/** + * Quota Provider Interface + * + * Defines the contract for implementing quota providers. + * @module quota/providers + */ + +/** + * @typedef {Object} UsageWindow + * @property {number|null} usedPercent - Percentage of usage (0-100) + * @property {number|null} remainingPercent - Percentage remaining (0-100) + * @property {number|null} windowSeconds - Window duration in seconds + * @property {number|null} resetAfterSeconds - Seconds until reset + * @property {number|null} resetAt - Unix timestamp when quota resets + * @property {string|null} resetAtFormatted - Human-readable reset time + * @property {string|null} resetAfterFormatted - Human-readable time until reset + * @property {string|null} valueLabel - Optional label for display (e.g., "$10.00 remaining") + */ + +/** + * @typedef {Object} ProviderUsage + * @property {Object.} windows - Usage windows by key (e.g., '5h', '7d', 'daily') + * @property {Object.} [models] - Model-specific usage (provider-specific) + */ + +/** + * @typedef {Object} QuotaProviderResult + * @property {string} providerId - Unique identifier for the provider + * @property {string} providerName - Display name for the provider + * @property {boolean} ok - Whether the fetch was successful + * @property {boolean} configured - Whether the provider is configured + * @property {ProviderUsage|null} usage - Usage data if successful + * @property {string|null} [error] - Error message if not successful + * @property {number} fetchedAt - Unix timestamp when the result was fetched + */ + +/** + * @typedef {Function} ProviderQuotaFetcher + * @returns {Promise} + */ + +/** + * @typedef {Function} ProviderConfigurationChecker + * @param {Object.} [auth] + * @returns {boolean} + */ + +/** + * @typedef {Object} QuotaProvider + * @property {string} providerId + * @property {string} providerName + * @property {string[]} aliases + * @property {ProviderConfigurationChecker} isConfigured + * @property {ProviderQuotaFetcher} fetchQuota + */ diff --git a/packages/web/server/lib/quota/providers/kimi.js b/packages/web/server/lib/quota/providers/kimi.js new file mode 100644 index 00000000..a3196aa6 --- /dev/null +++ b/packages/web/server/lib/quota/providers/kimi.js @@ -0,0 +1,108 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp, + durationToLabel, + durationToSeconds +} from '../utils/index.js'; + +export const providerId = 'kimi-for-coding'; +export const providerName = 'Kimi for Coding'; +export const aliases = ['kimi-for-coding', 'kimi']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.kimi.com/coding/v1/usages', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = {}; + const usage = payload?.usage ?? null; + if (usage) { + const limit = toNumber(usage.limit); + const remaining = toNumber(usage.remaining); + const usedPercent = limit && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100)) + : null; + windows.weekly = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: toTimestamp(usage.resetTime) + }); + } + + const limits = Array.isArray(payload?.limits) ? payload.limits : []; + for (const limit of limits) { + const window = limit?.window; + const detail = limit?.detail; + const rawLabel = durationToLabel(window?.duration, window?.timeUnit); + const windowSeconds = durationToSeconds(window?.duration, window?.timeUnit); + const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel; + const total = toNumber(detail?.limit); + const remaining = toNumber(detail?.remaining); + const usedPercent = total && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / total) * 100)) + : null; + windows[label] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt: toTimestamp(detail?.resetTime) + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/nanogpt.js b/packages/web/server/lib/quota/providers/nanogpt.js new file mode 100644 index 00000000..099b1be8 --- /dev/null +++ b/packages/web/server/lib/quota/providers/nanogpt.js @@ -0,0 +1,124 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp +} from '../utils/index.js'; + +const NANO_GPT_DAILY_WINDOW_SECONDS = 86400; + +export const providerId = 'nano-gpt'; +export const providerName = 'NanoGPT'; +export const aliases = ['nano-gpt', 'nanogpt', 'nano_gpt']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://nano-gpt.com/api/subscription/v1/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = {}; + const period = payload?.period ?? null; + const daily = payload?.daily ?? null; + const monthly = payload?.monthly ?? null; + const state = payload?.state ?? 'active'; + + if (daily) { + let usedPercent = null; + const percentUsed = daily?.percentUsed; + if (typeof percentUsed === 'number') { + usedPercent = Math.max(0, Math.min(100, percentUsed * 100)); + } else { + const used = toNumber(daily?.used); + const limit = toNumber(daily?.limit ?? daily?.limits?.daily); + if (used !== null && limit !== null && limit > 0) { + usedPercent = Math.max(0, Math.min(100, (used / limit) * 100)); + } + } + const resetAt = toTimestamp(daily?.resetAt); + const valueLabel = state !== 'active' ? `(${state})` : null; + windows['daily'] = toUsageWindow({ + usedPercent, + windowSeconds: NANO_GPT_DAILY_WINDOW_SECONDS, + resetAt, + valueLabel + }); + } + + if (monthly) { + let usedPercent = null; + const percentUsed = monthly?.percentUsed; + if (typeof percentUsed === 'number') { + usedPercent = Math.max(0, Math.min(100, percentUsed * 100)); + } else { + const used = toNumber(monthly?.used); + const limit = toNumber(monthly?.limit ?? monthly?.limits?.monthly); + if (used !== null && limit !== null && limit > 0) { + usedPercent = Math.max(0, Math.min(100, (used / limit) * 100)); + } + } + const resetAt = toTimestamp(monthly?.resetAt ?? period?.currentPeriodEnd); + const valueLabel = state !== 'active' ? `(${state})` : null; + windows['monthly'] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + valueLabel + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/openai.js b/packages/web/server/lib/quota/providers/openai.js new file mode 100644 index 00000000..88b5470e --- /dev/null +++ b/packages/web/server/lib/quota/providers/openai.js @@ -0,0 +1,91 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp +} from '../utils/index.js'; + +export const providerId = 'openai'; +export const providerName = 'OpenAI'; +export const aliases = ['openai', 'codex', 'chatgpt']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.access || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const primary = payload?.rate_limit?.primary_window ?? null; + const secondary = payload?.rate_limit?.secondary_window ?? null; + + const windows = {}; + if (primary) { + windows['5h'] = toUsageWindow({ + usedPercent: primary.used_percent ?? null, + windowSeconds: primary.limit_window_seconds ?? null, + resetAt: primary.reset_at ? primary.reset_at * 1000 : null + }); + } + if (secondary) { + windows['weekly'] = toUsageWindow({ + usedPercent: secondary.used_percent ?? null, + windowSeconds: secondary.limit_window_seconds ?? null, + resetAt: secondary.reset_at ? secondary.reset_at * 1000 : null + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/openrouter.js b/packages/web/server/lib/quota/providers/openrouter.js new file mode 100644 index 00000000..91437ec7 --- /dev/null +++ b/packages/web/server/lib/quota/providers/openrouter.js @@ -0,0 +1,92 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + formatMoney +} from '../utils/index.js'; + +export const providerId = 'openrouter'; +export const providerName = 'OpenRouter'; +export const aliases = ['openrouter']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://openrouter.ai/api/v1/credits', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const credits = payload?.data ?? {}; + const totalCredits = toNumber(credits.total_credits); + const totalUsage = toNumber(credits.total_usage); + const remaining = totalCredits !== null && totalUsage !== null + ? Math.max(0, totalCredits - totalUsage) + : null; + const usedPercent = totalCredits && totalUsage !== null + ? Math.max(0, Math.min(100, (totalUsage / totalCredits) * 100)) + : null; + const valueLabel = remaining !== null ? `$${formatMoney(remaining)} remaining` : null; + + const windows = { + credits: toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: null, + valueLabel + }) + }; + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/zai.js b/packages/web/server/lib/quota/providers/zai.js new file mode 100644 index 00000000..a0a55c1c --- /dev/null +++ b/packages/web/server/lib/quota/providers/zai.js @@ -0,0 +1,91 @@ +import { readAuthFile } from '../../opencode-auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp, + resolveWindowSeconds, + resolveWindowLabel, + normalizeTimestamp +} from '../utils/index.js'; + +export const providerId = 'zai-coding-plan'; +export const providerName = 'z.ai'; +export const aliases = ['zai-coding-plan', 'zai', 'z.ai']; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.z.ai/api/monitor/usage/quota/limit', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : []; + const tokensLimit = limits.find((limit) => limit?.type === 'TOKENS_LIMIT'); + const windowSeconds = resolveWindowSeconds(tokensLimit); + const windowLabel = resolveWindowLabel(windowSeconds); + const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null; + const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null; + + const windows = {}; + if (tokensLimit) { + windows[windowLabel] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; diff --git a/packages/web/server/lib/quota/utils/auth.js b/packages/web/server/lib/quota/utils/auth.js new file mode 100644 index 00000000..5325dfdb --- /dev/null +++ b/packages/web/server/lib/quota/utils/auth.js @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); +const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); + +export const ANTIGRAVITY_ACCOUNTS_PATHS = [ + path.join(OPENCODE_CONFIG_DIR, 'antigravity-accounts.json'), + path.join(OPENCODE_DATA_DIR, 'antigravity-accounts.json') +]; + +export const readJsonFile = (filePath) => { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) return null; + return JSON.parse(trimmed); + } catch (error) { + console.warn(`Failed to read JSON file: ${filePath}`, error); + return null; + } +}; + +export const getAuthEntry = (auth, aliases) => { + for (const alias of aliases) { + if (auth[alias]) { + return auth[alias]; + } + } + return null; +}; + +export const normalizeAuthEntry = (entry) => { + if (!entry) return null; + if (typeof entry === 'string') { + return { token: entry }; + } + if (typeof entry === 'object') { + return entry; + } + return null; +}; diff --git a/packages/web/server/lib/quota/utils/formatters.js b/packages/web/server/lib/quota/utils/formatters.js new file mode 100644 index 00000000..4e353b15 --- /dev/null +++ b/packages/web/server/lib/quota/utils/formatters.js @@ -0,0 +1,76 @@ +export const formatResetTime = (timestamp) => { + try { + const resetDate = new Date(timestamp); + const now = new Date(); + const isToday = resetDate.toDateString() === now.toDateString(); + + if (isToday) { + return resetDate.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit' + }); + } + + return resetDate.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + weekday: 'short', + hour: 'numeric', + minute: '2-digit' + }); + } catch { + return null; + } +}; + +export const calculateResetAfterSeconds = (resetAt) => { + if (!resetAt) return null; + const delta = Math.floor((resetAt - Date.now()) / 1000); + return delta < 0 ? 0 : delta; +}; + +export const toUsageWindow = ({ usedPercent, windowSeconds, resetAt, valueLabel }) => { + const resetAfterSeconds = calculateResetAfterSeconds(resetAt); + const resetFormatted = resetAt ? formatResetTime(resetAt) : null; + return { + usedPercent, + remainingPercent: usedPercent !== null ? Math.max(0, 100 - usedPercent) : null, + windowSeconds: windowSeconds ?? null, + resetAfterSeconds, + resetAt, + resetAtFormatted: resetFormatted, + resetAfterFormatted: resetFormatted, + ...(valueLabel ? { valueLabel } : {}) + }; +}; + +export const buildResult = ({ providerId, providerName, ok, configured, usage, error }) => ({ + providerId, + providerName, + ok, + configured, + usage: usage ?? null, + ...(error ? { error } : {}), + fetchedAt: Date.now() +}); + +export const durationToLabel = (duration, unit) => { + if (!duration || !unit) return 'limit'; + if (unit === 'TIME_UNIT_MINUTE') return `${duration}m`; + if (unit === 'TIME_UNIT_HOUR') return `${duration}h`; + if (unit === 'TIME_UNIT_DAY') return `${duration}d`; + return 'limit'; +}; + +export const durationToSeconds = (duration, unit) => { + if (!duration || !unit) return null; + if (unit === 'TIME_UNIT_MINUTE') return duration * 60; + if (unit === 'TIME_UNIT_HOUR') return duration * 3600; + if (unit === 'TIME_UNIT_DAY') return duration * 86400; + return null; +}; + +export const formatMoney = (value) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return value.toFixed(2); +}; diff --git a/packages/web/server/lib/quota/utils/index.js b/packages/web/server/lib/quota/utils/index.js new file mode 100644 index 00000000..5513cc64 --- /dev/null +++ b/packages/web/server/lib/quota/utils/index.js @@ -0,0 +1,10 @@ +/** + * Quota Utilities + * + * Shared utility functions for quota calculations and formatting. + * @module quota/utils + */ + +export * from './auth.js'; +export * from './transformers.js'; +export * from './formatters.js'; diff --git a/packages/web/server/lib/quota/utils/transformers.js b/packages/web/server/lib/quota/utils/transformers.js new file mode 100644 index 00000000..8c9ac101 --- /dev/null +++ b/packages/web/server/lib/quota/utils/transformers.js @@ -0,0 +1,55 @@ +export const asObject = (value) => (value && typeof value === 'object' ? value : null); + +export const asNonEmptyString = (value) => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +export const toNumber = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +export const toTimestamp = (value) => { + if (!value) return null; + if (typeof value === 'number') { + return value < 1_000_000_000_000 ? value * 1000 : value; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +}; + +export const normalizeTimestamp = (value) => { + if (typeof value !== 'number') return null; + return value < 1_000_000_000_000 ? value * 1000 : value; +}; + +export const resolveWindowSeconds = (limit) => { + const ZAI_TOKEN_WINDOW_SECONDS = { 3: 3600 }; + if (!limit || !limit.number) return null; + const unitSeconds = ZAI_TOKEN_WINDOW_SECONDS[limit.unit]; + if (!unitSeconds) return null; + return unitSeconds * limit.number; +}; + +export const resolveWindowLabel = (windowSeconds) => { + if (!windowSeconds) return 'tokens'; + if (windowSeconds % 86400 === 0) { + const days = windowSeconds / 86400; + return days === 7 ? 'weekly' : `${days}d`; + } + if (windowSeconds % 3600 === 0) { + return `${windowSeconds / 3600}h`; + } + return `${windowSeconds}s`; +};