Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
55
web/server/lib/quota/DOCUMENTATION.md
Normal file
55
web/server/lib/quota/DOCUMENTATION.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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` |
|
||||
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` | `minimax-coding-plan` |
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` | `minimax-cn-coding-plan` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
|
||||
|
||||
## 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/<provider>.js`.
|
||||
- Complex providers (multi-source auth, multiple API calls, non-trivial transforms): create `packages/web/server/lib/quota/providers/<provider>/` 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.
|
||||
24
web/server/lib/quota/index.js
Normal file
24
web/server/lib/quota/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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,
|
||||
fetchMinimaxCodingPlanQuota,
|
||||
fetchMinimaxCnCodingPlanQuota,
|
||||
fetchOllamaCloudQuota
|
||||
} from './providers/index.js';
|
||||
107
web/server/lib/quota/providers/claude.js
Normal file
107
web/server/lib/quota/providers/claude.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
113
web/server/lib/quota/providers/codex.js
Normal file
113
web/server/lib/quota/providers/codex.js
Normal file
@@ -0,0 +1,113 @@
|
||||
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: response.status === 401
|
||||
? 'Session expired \u2014 please re-authenticate with OpenAI'
|
||||
: `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'
|
||||
});
|
||||
}
|
||||
};
|
||||
165
web/server/lib/quota/providers/copilot.js
Normal file
165
web/server/lib/quota/providers/copilot.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
92
web/server/lib/quota/providers/google/api.js
Normal file
92
web/server/lib/quota/providers/google/api.js
Normal file
@@ -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;
|
||||
};
|
||||
108
web/server/lib/quota/providers/google/auth.js
Normal file
108
web/server/lib/quota/providers/google/auth.js
Normal file
@@ -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;
|
||||
};
|
||||
124
web/server/lib/quota/providers/google/index.js
Normal file
124
web/server/lib/quota/providers/google/index.js
Normal file
@@ -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
|
||||
}
|
||||
});
|
||||
};
|
||||
109
web/server/lib/quota/providers/google/transforms.js
Normal file
109
web/server/lib/quota/providers/google/transforms.js
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
152
web/server/lib/quota/providers/index.js
Normal file
152
web/server/lib/quota/providers/index.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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';
|
||||
import * as minimaxCodingPlan from './minimax-coding-plan.js';
|
||||
import * as minimaxCnCodingPlan from './minimax-cn-coding-plan.js';
|
||||
import * as ollamaCloud from './ollama-cloud.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
|
||||
},
|
||||
'minimax-coding-plan': {
|
||||
providerId: minimaxCodingPlan.providerId,
|
||||
providerName: minimaxCodingPlan.providerName,
|
||||
isConfigured: minimaxCodingPlan.isConfigured,
|
||||
fetchQuota: minimaxCodingPlan.fetchQuota
|
||||
},
|
||||
'minimax-cn-coding-plan': {
|
||||
providerId: minimaxCnCodingPlan.providerId,
|
||||
providerName: minimaxCnCodingPlan.providerName,
|
||||
isConfigured: minimaxCnCodingPlan.isConfigured,
|
||||
fetchQuota: minimaxCnCodingPlan.fetchQuota
|
||||
},
|
||||
'ollama-cloud': {
|
||||
providerId: ollamaCloud.providerId,
|
||||
providerName: ollamaCloud.providerName,
|
||||
isConfigured: ollamaCloud.isConfigured,
|
||||
fetchQuota: ollamaCloud.fetchQuota
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
export const fetchMinimaxCodingPlanQuota = minimaxCodingPlan.fetchQuota;
|
||||
export const fetchMinimaxCnCodingPlanQuota = minimaxCnCodingPlan.fetchQuota;
|
||||
export const fetchOllamaCloudQuota = ollamaCloud.fetchQuota;
|
||||
55
web/server/lib/quota/providers/interface.js
Normal file
55
web/server/lib/quota/providers/interface.js
Normal file
@@ -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.<string, UsageWindow>} windows - Usage windows by key (e.g., '5h', '7d', 'daily')
|
||||
* @property {Object.<string, 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<QuotaProviderResult>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} ProviderConfigurationChecker
|
||||
* @param {Object.<string, unknown>} [auth]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuotaProvider
|
||||
* @property {string} providerId
|
||||
* @property {string} providerName
|
||||
* @property {string[]} aliases
|
||||
* @property {ProviderConfigurationChecker} isConfigured
|
||||
* @property {ProviderQuotaFetcher} fetchQuota
|
||||
*/
|
||||
108
web/server/lib/quota/providers/kimi.js
Normal file
108
web/server/lib/quota/providers/kimi.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
131
web/server/lib/quota/providers/minimax-cn-coding-plan.js
Normal file
131
web/server/lib/quota/providers/minimax-cn-coding-plan.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// MiniMax Coding Plan Provider
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'minimax-cn-coding-plan';
|
||||
export const providerName = 'MiniMax Coding Plan (minimaxi.com)';
|
||||
export const aliases = ['minimax-cn-coding-plan'];
|
||||
|
||||
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://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
|
||||
{
|
||||
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 baseResp = payload?.base_resp;
|
||||
if (baseResp && baseResp.status_code !== 0) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
|
||||
});
|
||||
}
|
||||
|
||||
const windows = {};
|
||||
const modelRemains = payload?.model_remains;
|
||||
|
||||
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
|
||||
const firstModel = modelRemains[0];
|
||||
const total = toNumber(firstModel?.current_interval_total_count);
|
||||
const used = 600 - toNumber(firstModel?.current_interval_usage_count);
|
||||
|
||||
if (total === null || used === null) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Missing required quota fields',
|
||||
});
|
||||
}
|
||||
|
||||
const usedPercent =
|
||||
total > 0 ? Math.max(0, Math.min(100, (used / total) * 100)) : null;
|
||||
|
||||
const startTime = toTimestamp(firstModel?.start_time);
|
||||
const endTime = toTimestamp(firstModel?.end_time);
|
||||
const windowSeconds =
|
||||
startTime && endTime && endTime > startTime
|
||||
? Math.floor((endTime - startTime) / 1000)
|
||||
: null;
|
||||
|
||||
windows['5h'] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt: endTime,
|
||||
});
|
||||
} else {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No model quota data available',
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
};
|
||||
131
web/server/lib/quota/providers/minimax-coding-plan.js
Normal file
131
web/server/lib/quota/providers/minimax-coding-plan.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// MiniMax Coding Plan Provider
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'minimax-coding-plan';
|
||||
export const providerName = 'MiniMax Coding Plan (minimax.io)';
|
||||
export const aliases = ['minimax-coding-plan'];
|
||||
|
||||
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://www.minimax.io/v1/api/openplatform/coding_plan/remains',
|
||||
{
|
||||
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 baseResp = payload?.base_resp;
|
||||
if (baseResp && baseResp.status_code !== 0) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: baseResp.status_msg || `API error: ${baseResp.status_code}`,
|
||||
});
|
||||
}
|
||||
|
||||
const windows = {};
|
||||
const modelRemains = payload?.model_remains;
|
||||
|
||||
if (Array.isArray(modelRemains) && modelRemains.length > 0) {
|
||||
const firstModel = modelRemains[0];
|
||||
const total = toNumber(firstModel?.current_interval_total_count);
|
||||
const used = 600-toNumber(firstModel?.current_interval_usage_count);
|
||||
|
||||
if (total === null || used === null) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Missing required quota fields',
|
||||
});
|
||||
}
|
||||
|
||||
const usedPercent =
|
||||
total > 0 ? Math.max(0, Math.min(100, (used / total) * 100)) : null;
|
||||
|
||||
const startTime = toTimestamp(firstModel?.start_time);
|
||||
const endTime = toTimestamp(firstModel?.end_time);
|
||||
const windowSeconds =
|
||||
startTime && endTime && endTime > startTime
|
||||
? Math.floor((endTime - startTime) / 1000)
|
||||
: null;
|
||||
|
||||
windows['5h'] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt: endTime,
|
||||
});
|
||||
} else {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No model quota data available',
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
};
|
||||
124
web/server/lib/quota/providers/nanogpt.js
Normal file
124
web/server/lib/quota/providers/nanogpt.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
112
web/server/lib/quota/providers/ollama-cloud.js
Normal file
112
web/server/lib/quota/providers/ollama-cloud.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { buildResult, toUsageWindow, toNumber } from '../utils/index.js';
|
||||
|
||||
const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie');
|
||||
|
||||
export const providerId = 'ollama-cloud';
|
||||
export const providerName = 'Ollama Cloud';
|
||||
export const aliases = ['ollama-cloud', 'ollamacloud'];
|
||||
|
||||
const readCookieFile = () => {
|
||||
try {
|
||||
if (!existsSync(COOKIE_PATH)) return null;
|
||||
const content = readFileSync(COOKIE_PATH, 'utf-8');
|
||||
const trimmed = content.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseOllamaSettingsHtml = (html) => {
|
||||
const windows = {};
|
||||
const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i);
|
||||
if (sessionMatch) {
|
||||
windows.session = toUsageWindow({
|
||||
usedPercent: toNumber(sessionMatch[1]),
|
||||
windowSeconds: null,
|
||||
resetAt: null
|
||||
});
|
||||
}
|
||||
const weeklyMatch = html.match(/Weekly\s+usage[^0-9]*([0-9.]+)%/i);
|
||||
if (weeklyMatch) {
|
||||
windows.weekly = toUsageWindow({
|
||||
usedPercent: toNumber(weeklyMatch[1]),
|
||||
windowSeconds: null,
|
||||
resetAt: null
|
||||
});
|
||||
}
|
||||
const premiumMatch = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i);
|
||||
if (premiumMatch) {
|
||||
const used = toNumber(premiumMatch[1]);
|
||||
const total = toNumber(premiumMatch[2]);
|
||||
const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null;
|
||||
windows.premium = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `${used ?? 0} / ${total ?? 0}`
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const cookie = readCookieFile();
|
||||
return Boolean(cookie);
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const cookie = readCookieFile();
|
||||
|
||||
if (!cookie) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://ollama.com/settings', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const windows = parseOllamaSettingsHtml(html);
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
};
|
||||
91
web/server/lib/quota/providers/openai.js
Normal file
91
web/server/lib/quota/providers/openai.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
92
web/server/lib/quota/providers/openrouter.js
Normal file
92
web/server/lib/quota/providers/openrouter.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
91
web/server/lib/quota/providers/zai.js
Normal file
91
web/server/lib/quota/providers/zai.js
Normal file
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
46
web/server/lib/quota/utils/auth.js
Normal file
46
web/server/lib/quota/utils/auth.js
Normal file
@@ -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;
|
||||
};
|
||||
76
web/server/lib/quota/utils/formatters.js
Normal file
76
web/server/lib/quota/utils/formatters.js
Normal file
@@ -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);
|
||||
};
|
||||
10
web/server/lib/quota/utils/index.js
Normal file
10
web/server/lib/quota/utils/index.js
Normal file
@@ -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';
|
||||
55
web/server/lib/quota/utils/transformers.js
Normal file
55
web/server/lib/quota/utils/transformers.js
Normal file
@@ -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`;
|
||||
};
|
||||
Reference in New Issue
Block a user