mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
Route agy quota through the Antigravity usage implementation (closes #3230). Integrated into release/v3.8.11. Thanks @wilsonicdev.
3042 lines
99 KiB
TypeScript
3042 lines
99 KiB
TypeScript
/**
|
||
* Usage Fetcher - Get usage data from provider APIs
|
||
*/
|
||
|
||
import { PROVIDERS } from "../config/constants.ts";
|
||
import {
|
||
getAntigravityFetchAvailableModelsUrls,
|
||
ANTIGRAVITY_BASE_URLS,
|
||
} from "../config/antigravityUpstream.ts";
|
||
import { isUserCallableAntigravityModelId } from "../config/antigravityModelAliases.ts";
|
||
import { getGlmQuotaUrl } from "../config/glmProvider.ts";
|
||
import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts";
|
||
import { safePercentage } from "@/shared/utils/formatting";
|
||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
|
||
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
|
||
import {
|
||
fetchOpencodeQuota,
|
||
type OpencodeTripleWindowQuota,
|
||
} from "./opencodeQuotaFetcher.ts";
|
||
import {
|
||
applyAntigravityClientProfileHeaders,
|
||
getAntigravityBootstrapHeaders,
|
||
getAntigravityClientProfile,
|
||
} from "./antigravityClientProfile.ts";
|
||
import {
|
||
antigravityUserAgent,
|
||
getAntigravityHeaders,
|
||
getAntigravityLoadCodeAssistMetadata,
|
||
} from "./antigravityHeaders.ts";
|
||
import {
|
||
getAntigravityRemainingCredits,
|
||
updateAntigravityRemainingCredits,
|
||
} from "../executors/antigravity.ts";
|
||
import { getCreditsMode } from "./antigravityCredits.ts";
|
||
import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts";
|
||
import { generateAntigravityRequestId, getAntigravitySessionId } from "./antigravityIdentity.ts";
|
||
import {
|
||
extractCodeAssistOnboardTierId,
|
||
extractCodeAssistSubscriptionTier,
|
||
} from "./codeAssistSubscription.ts";
|
||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||
|
||
// Quota / usage upstream URLs (overridable for testing or relays).
|
||
const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/";
|
||
const GEMINI_CLI_USAGE_URL =
|
||
process.env.OMNIROUTE_GEMINI_CLI_USAGE_URL ??
|
||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist";
|
||
const CODEWHISPERER_BASE_URL =
|
||
process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com";
|
||
|
||
// Antigravity API config (credentials from PROVIDERS via credential loader)
|
||
const ANTIGRAVITY_CONFIG = {
|
||
quotaApiUrls: getAntigravityFetchAvailableModelsUrls(),
|
||
loadProjectApiUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist",
|
||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||
get clientId() {
|
||
return PROVIDERS.antigravity.clientId;
|
||
},
|
||
get clientSecret() {
|
||
return PROVIDERS.antigravity.clientSecret;
|
||
},
|
||
get userAgent() {
|
||
return antigravityUserAgent();
|
||
},
|
||
};
|
||
|
||
// Codex (OpenAI) API config
|
||
const CODEX_CONFIG = {
|
||
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
|
||
};
|
||
|
||
// Claude API config
|
||
const CLAUDE_CONFIG = {
|
||
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||
apiVersion: "2023-06-01",
|
||
};
|
||
|
||
// Kimi Coding API config
|
||
const KIMI_CONFIG = {
|
||
baseUrl: "https://api.kimi.com/coding/v1",
|
||
usageUrl: "https://api.kimi.com/coding/v1/usages",
|
||
apiVersion: "2023-06-01",
|
||
};
|
||
|
||
const NANOGPT_CONFIG = {
|
||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||
};
|
||
|
||
const OPENCODE_GO_QUOTA_URL =
|
||
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit";
|
||
const OPENCODE_GO_QUOTA_TOTALS = {
|
||
session: 12,
|
||
weekly: 30,
|
||
mcp_monthly: 60,
|
||
} as const;
|
||
const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const;
|
||
type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number];
|
||
|
||
// Cursor dashboard usage API config
|
||
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
|
||
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
|
||
// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request).
|
||
const CURSOR_USAGE_CONFIG = {
|
||
usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage",
|
||
origin: "https://cursor.com",
|
||
referer: "https://cursor.com/dashboard/spending",
|
||
userAgent:
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||
};
|
||
|
||
const MINIMAX_USAGE_CONFIG = {
|
||
minimax: {
|
||
usageUrls: [
|
||
"https://www.minimax.io/v1/token_plan/remains",
|
||
"https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
|
||
],
|
||
},
|
||
"minimax-cn": {
|
||
usageUrls: [
|
||
"https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
||
"https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
||
],
|
||
},
|
||
} as const;
|
||
|
||
type JsonRecord = Record<string, unknown>;
|
||
type UsageQuota = {
|
||
used: number;
|
||
total: number;
|
||
remaining?: number;
|
||
remainingPercentage?: number;
|
||
resetAt: string | null;
|
||
unlimited: boolean;
|
||
/**
|
||
* True when the upstream provider reported the remaining fraction. False
|
||
* means the API didn't include the field and the 0 value here is a sentinel,
|
||
* NOT a confirmed-exhausted state. Antigravity-specific.
|
||
*/
|
||
fractionReported?: boolean;
|
||
displayName?: string;
|
||
details?: Array<{
|
||
name: string;
|
||
used: number;
|
||
}>;
|
||
currency?: string;
|
||
grantedBalance?: number;
|
||
toppedUpBalance?: number;
|
||
};
|
||
type UsageProviderConnection = JsonRecord & {
|
||
id?: string;
|
||
provider?: string;
|
||
accessToken?: string;
|
||
apiKey?: string;
|
||
providerSpecificData?: JsonRecord;
|
||
projectId?: string;
|
||
email?: string;
|
||
};
|
||
type SubscriptionCacheEntry = {
|
||
data: unknown;
|
||
fetchedAt: number;
|
||
};
|
||
|
||
function toRecord(value: unknown): JsonRecord {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||
}
|
||
|
||
function toNumber(value: unknown, fallback = 0): number {
|
||
const parsed =
|
||
typeof value === "number"
|
||
? value
|
||
: typeof value === "string" && value.trim().length > 0
|
||
? Number(value)
|
||
: Number.NaN;
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
function toPercentage(value: unknown): number {
|
||
return Math.max(0, Math.min(100, toNumber(value, 0)));
|
||
}
|
||
|
||
function toTitleCase(value: string): string {
|
||
return value
|
||
.trim()
|
||
.split(/[\s_-]+/)
|
||
.filter(Boolean)
|
||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||
.join(" ");
|
||
}
|
||
|
||
function getGlmTokenQuotaName(
|
||
limit: JsonRecord,
|
||
existingQuotas: Record<string, UsageQuota>
|
||
): string {
|
||
const unit = toNumber(limit.unit, 0);
|
||
const number = toNumber(limit.number, 0);
|
||
|
||
if (unit === 3 && number === 5) return "session";
|
||
if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly";
|
||
|
||
return existingQuotas.session ? "weekly" : "session";
|
||
}
|
||
|
||
function getGlmQuotaDisplayName(quotaName: string): string {
|
||
if (quotaName === "session") return "5 Hours Quota";
|
||
if (quotaName === "weekly") return "Weekly Quota";
|
||
return quotaName;
|
||
}
|
||
|
||
function getOpenCodeGoTokenQuotaName(
|
||
limit: JsonRecord,
|
||
existingQuotas: Record<string, UsageQuota>
|
||
): "session" | "weekly" {
|
||
const unit = toNumber(limit.unit, 0);
|
||
const number = toNumber(limit.number, 0);
|
||
|
||
if (unit === 3 && number === 5) return "session";
|
||
if (unit === 6 && number === 1) return "weekly";
|
||
if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly";
|
||
|
||
return existingQuotas.session ? "weekly" : "session";
|
||
}
|
||
|
||
function getOpenCodeGoQuotaDisplayName(quotaName: OpenCodeGoQuotaName): string {
|
||
if (quotaName === "session") return "5-hour rolling";
|
||
if (quotaName === "weekly") return "Weekly";
|
||
return "Monthly";
|
||
}
|
||
|
||
function normalizeOpenCodeGoQuotaToken(apiKey: string): string {
|
||
return apiKey.trim().replace(/^Bearer\s+/i, "");
|
||
}
|
||
|
||
function buildOpenCodeGoDollarQuota(
|
||
quotaName: OpenCodeGoQuotaName,
|
||
percentage: unknown,
|
||
resetAt: string | null,
|
||
usedOverride?: unknown,
|
||
details?: UsageQuota["details"]
|
||
): UsageQuota {
|
||
const total = OPENCODE_GO_QUOTA_TOTALS[quotaName];
|
||
const percentUsed = toPercentage(percentage);
|
||
const rawUsed = toNumber(usedOverride, Number.NaN);
|
||
const used = roundCurrency(
|
||
Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100
|
||
);
|
||
const remaining = roundCurrency(Math.max(0, total - used));
|
||
const remainingPercentage =
|
||
total > 0
|
||
? clampPercentage(Math.round((remaining / total) * 100))
|
||
: clampPercentage(100 - percentUsed);
|
||
|
||
return {
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage,
|
||
resetAt,
|
||
unlimited: false,
|
||
displayName: getOpenCodeGoQuotaDisplayName(quotaName),
|
||
currency: "USD",
|
||
details,
|
||
};
|
||
}
|
||
|
||
function orderOpenCodeGoQuotas(quotas: Record<string, UsageQuota>): Record<string, UsageQuota> {
|
||
const ordered: Record<string, UsageQuota> = {};
|
||
|
||
for (const key of OPENCODE_GO_QUOTA_ORDER) {
|
||
if (quotas[key]) ordered[key] = quotas[key];
|
||
}
|
||
|
||
for (const [key, quota] of Object.entries(quotas)) {
|
||
if (!ordered[key]) ordered[key] = quota;
|
||
}
|
||
|
||
return ordered;
|
||
}
|
||
|
||
function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown {
|
||
const obj = toRecord(source);
|
||
return obj[snakeKey] ?? obj[camelKey] ?? null;
|
||
}
|
||
|
||
function clampPercentage(value: number): number {
|
||
return Math.max(0, Math.min(100, value));
|
||
}
|
||
|
||
function roundCurrency(value: number): number {
|
||
return Math.round(value * 100) / 100;
|
||
}
|
||
|
||
function toDisplayLabel(value: string): string {
|
||
return value
|
||
.replace(/^copilot[_\s-]*/i, "")
|
||
.split(/[\s_-]+/)
|
||
.filter(Boolean)
|
||
.map((part) => {
|
||
if (/^pro\+$/i.test(part)) return "Pro+";
|
||
if (/^[a-z]{2,}$/.test(part))
|
||
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
||
return part;
|
||
})
|
||
.join(" ")
|
||
.trim();
|
||
}
|
||
|
||
function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota {
|
||
if (!quota) return false;
|
||
if (quota.unlimited && quota.total <= 0) return false;
|
||
return quota.total > 0 || quota.remainingPercentage !== undefined;
|
||
}
|
||
|
||
function pickFirstNonEmptyString(...values: unknown[]): string | undefined {
|
||
for (const value of values) {
|
||
if (typeof value !== "string") continue;
|
||
const trimmed = value.trim();
|
||
if (trimmed) return trimmed;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function inferMiniMaxPlanLabelFromTotals(models: JsonRecord[]): string | null {
|
||
const maxSessionTotal = models.reduce(
|
||
(maxTotal, model) => Math.max(maxTotal, getMiniMaxSessionTotal(model)),
|
||
0
|
||
);
|
||
|
||
if (maxSessionTotal >= 15_000) return "Max";
|
||
if (maxSessionTotal >= 4_500) return "Plus";
|
||
if (maxSessionTotal >= 1_500) return "Starter";
|
||
return null;
|
||
}
|
||
|
||
function getMiniMaxPlanLabel(payload: JsonRecord, models: JsonRecord[] = []): string {
|
||
const raw = pickFirstNonEmptyString(
|
||
getFieldValue(payload, "current_subscribe_title", "currentSubscribeTitle"),
|
||
getFieldValue(payload, "plan_name", "planName"),
|
||
getFieldValue(payload, "plan", "plan"),
|
||
getFieldValue(payload, "current_plan_title", "currentPlanTitle"),
|
||
getFieldValue(payload, "combo_title", "comboTitle")
|
||
);
|
||
|
||
if (!raw) return inferMiniMaxPlanLabelFromTotals(models) || "Coding Plan";
|
||
|
||
const cleaned = raw
|
||
.replace(/^minimax\s+/i, "")
|
||
.replace(/\bcoding\s+plan\b/gi, "")
|
||
.replace(/\s{2,}/g, " ")
|
||
.trim();
|
||
|
||
return cleaned || inferMiniMaxPlanLabelFromTotals(models) || "Coding Plan";
|
||
}
|
||
|
||
function getClaudePlanLabel(...candidates: Array<string | null | undefined>): string | null {
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== "string") continue;
|
||
const trimmed = candidate.trim();
|
||
if (
|
||
!trimmed ||
|
||
trimmed.toLowerCase() === "claude code" ||
|
||
trimmed.toLowerCase() === "unknown"
|
||
) {
|
||
continue;
|
||
}
|
||
return trimmed;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function createQuotaFromUsage(
|
||
usedValue: unknown,
|
||
totalValue: unknown,
|
||
resetValue: unknown
|
||
): UsageQuota {
|
||
const total = Math.max(0, toNumber(totalValue, 0));
|
||
const used = total > 0 ? Math.min(Math.max(0, toNumber(usedValue, 0)), total) : 0;
|
||
const remaining = total > 0 ? Math.max(total - used, 0) : 0;
|
||
|
||
return {
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0,
|
||
resetAt: parseResetTime(resetValue),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
function getMiniMaxQuotaResetAt(
|
||
model: JsonRecord,
|
||
capturedAtMs: number,
|
||
remainsTimeSnakeKey: string,
|
||
remainsTimeCamelKey: string,
|
||
endTimeSnakeKey: string,
|
||
endTimeCamelKey: string
|
||
): string | null {
|
||
const remainsMs = toNumber(getFieldValue(model, remainsTimeSnakeKey, remainsTimeCamelKey), 0);
|
||
if (remainsMs > 0) {
|
||
return new Date(capturedAtMs + remainsMs).toISOString();
|
||
}
|
||
|
||
return parseResetTime(getFieldValue(model, endTimeSnakeKey, endTimeCamelKey));
|
||
}
|
||
|
||
function isMiniMaxTextQuotaModel(modelName: string): boolean {
|
||
const normalized = modelName.trim().toLowerCase();
|
||
return (
|
||
normalized.startsWith("minimax-m") ||
|
||
normalized.startsWith("coding-plan") ||
|
||
// MiniMax Coding Plan surfaces the text/coding quota under model "general"
|
||
// (media buckets like "video"/"image"/"music" are excluded).
|
||
normalized === "general"
|
||
);
|
||
}
|
||
|
||
function getMiniMaxSessionTotal(model: JsonRecord): number {
|
||
return Math.max(
|
||
0,
|
||
toNumber(getFieldValue(model, "current_interval_total_count", "currentIntervalTotalCount"), 0)
|
||
);
|
||
}
|
||
|
||
function getMiniMaxWeeklyTotal(model: JsonRecord): number {
|
||
return Math.max(
|
||
0,
|
||
toNumber(getFieldValue(model, "current_weekly_total_count", "currentWeeklyTotalCount"), 0)
|
||
);
|
||
}
|
||
|
||
function pickMiniMaxRepresentativeModel(
|
||
models: JsonRecord[],
|
||
getTotal: (model: JsonRecord) => number
|
||
): JsonRecord | null {
|
||
const withQuota = models.filter((model) => getTotal(model) > 0);
|
||
const pool = withQuota.length > 0 ? withQuota : models;
|
||
if (pool.length === 0) return null;
|
||
|
||
return pool.reduce((best, current) => (getTotal(current) > getTotal(best) ? current : best));
|
||
}
|
||
|
||
function createMiniMaxQuotaFromCount(
|
||
total: number,
|
||
count: number,
|
||
resetAt: string | null,
|
||
countMeansRemaining: boolean
|
||
): UsageQuota {
|
||
const used = countMeansRemaining ? Math.max(total - count, 0) : count;
|
||
return createQuotaFromUsage(used, total, resetAt);
|
||
}
|
||
|
||
/**
|
||
* MiniMax Coding Plan exposes per-window remaining as a 0–100 percent
|
||
* (`current_interval_remaining_percent` / `current_weekly_remaining_percent`)
|
||
* with zero request counts. Read it defensively (string-encoded numbers ok).
|
||
*/
|
||
function getMiniMaxRemainingPercent(
|
||
model: JsonRecord,
|
||
snakeKey: string,
|
||
camelKey: string
|
||
): number | null {
|
||
const raw = getFieldValue(model, snakeKey, camelKey);
|
||
if (raw === null || raw === undefined || raw === "") return null;
|
||
const parsed = toNumber(raw, NaN);
|
||
return Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : null;
|
||
}
|
||
|
||
/** Build a 0–100 percent-based window quota (used = 100 − remaining). */
|
||
function createMiniMaxQuotaFromPercent(
|
||
remainingPercent: number,
|
||
resetAt: string | null
|
||
): UsageQuota {
|
||
const clamped = Math.max(0, Math.min(100, remainingPercent));
|
||
return createQuotaFromUsage(100 - clamped, 100, resetAt);
|
||
}
|
||
|
||
/**
|
||
* Build one MiniMax usage window (session or weekly) from the representative
|
||
* model. Token Plan keys report request counts (`*_total_count`); Coding Plan
|
||
* keys report zero counts and a `*_remaining_percent` instead — fall back to
|
||
* that so the Coding Plan still surfaces a quota. The percent signal is keyed
|
||
* off "counts == 0 + percent present", NOT the endpoint URL, because the
|
||
* `token_plan/remains` and `coding_plan/remains` endpoints return identical
|
||
* Coding-Plan payloads for a Coding Plan key.
|
||
*/
|
||
function buildMiniMaxWindow(
|
||
models: JsonRecord[],
|
||
getTotal: (model: JsonRecord) => number,
|
||
usageCountKeys: [string, string],
|
||
percentKeys: [string, string],
|
||
resetKeys: [string, string, string, string],
|
||
capturedAtMs: number,
|
||
countMeansRemaining: boolean
|
||
): UsageQuota | null {
|
||
const model = pickMiniMaxRepresentativeModel(models, getTotal);
|
||
if (!model) return null;
|
||
|
||
const resetAt = getMiniMaxQuotaResetAt(model, capturedAtMs, ...resetKeys);
|
||
const total = getTotal(model);
|
||
|
||
if (total > 0) {
|
||
const count = Math.max(0, toNumber(getFieldValue(model, ...usageCountKeys), 0));
|
||
return createMiniMaxQuotaFromCount(total, count, resetAt, countMeansRemaining);
|
||
}
|
||
|
||
const remainingPercent = getMiniMaxRemainingPercent(model, ...percentKeys);
|
||
return remainingPercent !== null
|
||
? createMiniMaxQuotaFromPercent(remainingPercent, resetAt)
|
||
: null;
|
||
}
|
||
|
||
function getMiniMaxAuthErrorMessage(message: string): string {
|
||
const normalized = message.toLowerCase();
|
||
if (
|
||
normalized.includes("token plan") ||
|
||
normalized.includes("coding plan") ||
|
||
normalized.includes("active period") ||
|
||
normalized.includes("invalid api key") ||
|
||
normalized.includes("invalid key") ||
|
||
normalized.includes("subscription")
|
||
) {
|
||
return "MiniMax Token Plan API key invalid or inactive. Use an active Token Plan key.";
|
||
}
|
||
|
||
return "MiniMax access denied. Confirm the key is an active Token Plan API key.";
|
||
}
|
||
|
||
function getMiniMaxErrorSummary(status: number, message: string): string {
|
||
const compact = message.replace(/\s+/g, " ").trim();
|
||
if (!compact) {
|
||
return `MiniMax usage endpoint error (${status}).`;
|
||
}
|
||
if (compact.length <= 160) {
|
||
return `MiniMax usage endpoint error (${status}): ${compact}`;
|
||
}
|
||
return `MiniMax usage endpoint error (${status}): ${compact.slice(0, 157)}...`;
|
||
}
|
||
|
||
async function getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn") {
|
||
if (!apiKey) {
|
||
return { message: "MiniMax API key not available. Add a Token Plan API key." };
|
||
}
|
||
|
||
const usageUrls = MINIMAX_USAGE_CONFIG[provider].usageUrls;
|
||
let lastErrorMessage = "";
|
||
|
||
for (let index = 0; index < usageUrls.length; index += 1) {
|
||
const usageUrl = usageUrls[index];
|
||
const canFallback = index < usageUrls.length - 1;
|
||
|
||
try {
|
||
const response = await fetch(usageUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
|
||
const rawText = await response.text();
|
||
let payload: JsonRecord = {};
|
||
if (rawText) {
|
||
try {
|
||
payload = toRecord(JSON.parse(rawText));
|
||
} catch {
|
||
payload = {};
|
||
}
|
||
}
|
||
|
||
const baseResp = toRecord(getFieldValue(payload, "base_resp", "baseResp"));
|
||
const apiStatusCode = toNumber(getFieldValue(baseResp, "status_code", "statusCode"), 0);
|
||
const apiStatusMessage = String(
|
||
getFieldValue(baseResp, "status_msg", "statusMsg") ?? ""
|
||
).trim();
|
||
const combinedMessage = `${apiStatusMessage} ${rawText}`.trim();
|
||
const authLikeStatusMessage =
|
||
/token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i;
|
||
|
||
if (
|
||
response.status === 401 ||
|
||
response.status === 403 ||
|
||
apiStatusCode === 1004 ||
|
||
authLikeStatusMessage.test(apiStatusMessage)
|
||
) {
|
||
return { message: getMiniMaxAuthErrorMessage(apiStatusMessage || combinedMessage) };
|
||
}
|
||
|
||
if (!response.ok) {
|
||
lastErrorMessage = getMiniMaxErrorSummary(response.status, combinedMessage);
|
||
if (
|
||
(response.status === 404 || response.status === 405 || response.status >= 500) &&
|
||
canFallback
|
||
) {
|
||
continue;
|
||
}
|
||
return { message: `MiniMax connected. ${lastErrorMessage}` };
|
||
}
|
||
|
||
if (rawText && Object.keys(payload).length === 0) {
|
||
return { message: "MiniMax connected. Unable to parse usage response." };
|
||
}
|
||
|
||
if (apiStatusCode !== 0) {
|
||
if (apiStatusMessage) {
|
||
return { message: `MiniMax connected. ${apiStatusMessage}` };
|
||
}
|
||
return { message: "MiniMax connected. Upstream quota API returned an error." };
|
||
}
|
||
|
||
const capturedAtMs = Date.now();
|
||
const modelRemains = getFieldValue(payload, "model_remains", "modelRemains");
|
||
const allModels = Array.isArray(modelRemains)
|
||
? modelRemains.map((item) => toRecord(item))
|
||
: [];
|
||
const textModels = allModels.filter((model) => {
|
||
const modelName = String(getFieldValue(model, "model_name", "modelName") ?? "");
|
||
return isMiniMaxTextQuotaModel(modelName);
|
||
});
|
||
|
||
if (textModels.length === 0) {
|
||
return { message: "MiniMax connected. No text quota data was returned." };
|
||
}
|
||
|
||
const countMeansRemaining = usageUrl.includes("/coding_plan/remains");
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
const sessionQuota = buildMiniMaxWindow(
|
||
textModels,
|
||
getMiniMaxSessionTotal,
|
||
["current_interval_usage_count", "currentIntervalUsageCount"],
|
||
["current_interval_remaining_percent", "currentIntervalRemainingPercent"],
|
||
["remains_time", "remainsTime", "end_time", "endTime"],
|
||
capturedAtMs,
|
||
countMeansRemaining
|
||
);
|
||
if (sessionQuota) {
|
||
quotas["session (5h)"] = sessionQuota;
|
||
}
|
||
|
||
const weeklyQuota = buildMiniMaxWindow(
|
||
textModels,
|
||
getMiniMaxWeeklyTotal,
|
||
["current_weekly_usage_count", "currentWeeklyUsageCount"],
|
||
["current_weekly_remaining_percent", "currentWeeklyRemainingPercent"],
|
||
["weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"],
|
||
capturedAtMs,
|
||
countMeansRemaining
|
||
);
|
||
if (weeklyQuota) {
|
||
quotas["weekly (7d)"] = weeklyQuota;
|
||
}
|
||
|
||
if (Object.keys(quotas).length === 0) {
|
||
return { message: "MiniMax connected. Unable to extract text quota usage." };
|
||
}
|
||
|
||
return { plan: getMiniMaxPlanLabel(payload, textModels), quotas };
|
||
} catch (error) {
|
||
lastErrorMessage = (error as Error).message;
|
||
if (!canFallback) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
message: lastErrorMessage
|
||
? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}`
|
||
: "MiniMax connected. Unable to fetch usage.",
|
||
};
|
||
}
|
||
|
||
// CrofAI surfaces a tiny endpoint with two signals:
|
||
// GET https://crof.ai/usage_api/ → { usable_requests: number|null, credits: number }
|
||
// `usable_requests` is the daily request bucket on a subscription plan; `null`
|
||
// for pay-as-you-go. `credits` is the USD credit balance. We surface both as
|
||
// quotas so the Limits & Quotas page can render whichever the account uses.
|
||
async function getCrofUsage(apiKey: string) {
|
||
if (!apiKey) {
|
||
return { message: "CrofAI API key not available. Add a key to view usage." };
|
||
}
|
||
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(CROF_USAGE_URL, {
|
||
method: "GET",
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
Accept: "application/json",
|
||
},
|
||
});
|
||
} catch (error) {
|
||
return { message: `CrofAI connected. Unable to fetch usage: ${(error as Error).message}` };
|
||
}
|
||
|
||
const rawText = await response.text();
|
||
|
||
if (response.status === 401 || response.status === 403) {
|
||
return { message: "CrofAI connected. The API key was rejected by /usage_api/." };
|
||
}
|
||
|
||
if (!response.ok) {
|
||
return { message: `CrofAI connected. /usage_api/ returned HTTP ${response.status}.` };
|
||
}
|
||
|
||
let payload: JsonRecord = {};
|
||
if (rawText) {
|
||
try {
|
||
payload = toRecord(JSON.parse(rawText));
|
||
} catch {
|
||
return { message: "CrofAI connected. Unable to parse /usage_api/ response." };
|
||
}
|
||
}
|
||
|
||
const usableRequestsRaw = payload["usable_requests"];
|
||
const usableRequests =
|
||
usableRequestsRaw === null || usableRequestsRaw === undefined
|
||
? null
|
||
: toNumber(usableRequestsRaw, 0);
|
||
const credits = toNumber(payload["credits"], 0);
|
||
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
if (usableRequests !== null) {
|
||
// CrofAI's /usage_api/ returns only the remaining count; the daily
|
||
// allotment is not exposed. CrofAI Pro plan = 1,000 requests/day per
|
||
// their pricing page, so use that as the baseline total. If the user
|
||
// is on a plan with a higher cap we widen the total to whatever they
|
||
// currently report so we never compute a negative `used`.
|
||
// Without this, total=0 makes the dashboard's percentage formula read
|
||
// 0% (interpreted as "depleted" → red) even on a fresh bucket.
|
||
const CROF_DAILY_BASELINE = 1000;
|
||
const remaining = Math.max(0, usableRequests);
|
||
const total = Math.max(CROF_DAILY_BASELINE, remaining);
|
||
const used = Math.max(0, total - remaining);
|
||
|
||
// CrofAI also does not return a reset timestamp and the docs only say
|
||
// "requests left today". The Crof.ai dashboard shows the daily bucket
|
||
// resetting at ~05:00 UTC (verified against the live countdown on
|
||
// 2026-04-25), so synthesize the next 05:00 UTC instant to match.
|
||
// Swap for a real field if Crof ever exposes one.
|
||
const now = new Date();
|
||
const RESET_HOUR_UTC = 5;
|
||
const todayResetMs = Date.UTC(
|
||
now.getUTCFullYear(),
|
||
now.getUTCMonth(),
|
||
now.getUTCDate(),
|
||
RESET_HOUR_UTC
|
||
);
|
||
const nextResetMs =
|
||
todayResetMs > now.getTime() ? todayResetMs : todayResetMs + 24 * 60 * 60 * 1000;
|
||
const nextResetIso = new Date(nextResetMs).toISOString();
|
||
|
||
quotas["Requests Today"] = {
|
||
used,
|
||
total,
|
||
remaining,
|
||
resetAt: nextResetIso,
|
||
unlimited: false,
|
||
displayName: `Requests Today: ${remaining} left`,
|
||
};
|
||
}
|
||
|
||
// Credits are an open balance — render as unlimited so the UI shows the
|
||
// dollar value rather than a misleading 0/0 bar.
|
||
quotas["Credits"] = {
|
||
used: 0,
|
||
total: 0,
|
||
remaining: 0,
|
||
resetAt: null,
|
||
unlimited: true,
|
||
displayName: `Credits: $${credits.toFixed(4)}`,
|
||
};
|
||
|
||
return { quotas };
|
||
}
|
||
|
||
const GLM_QUOTA_ORDER = ["5 Hours Quota", "Weekly Quota", "Monthly Tools", "Tokens", "Time Limit"];
|
||
|
||
function getGlmQuotaLabel(type: unknown, unit: unknown): string | null {
|
||
const normalized = typeof type === "string" ? type.trim().toUpperCase() : "";
|
||
const unitValue = toNumber(unit, -1);
|
||
|
||
switch (normalized) {
|
||
case "TOKENS_LIMIT":
|
||
case "TOKEN_LIMIT":
|
||
if (unitValue === 3) return "5 Hours Quota";
|
||
if (unitValue === 6) return "Weekly Quota";
|
||
return "Tokens";
|
||
case "TIME_LIMIT":
|
||
case "TIME_USAGE_LIMIT":
|
||
if (unitValue === 5) return "Monthly Tools";
|
||
return "Time Limit";
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function orderGlmQuotas(quotas: Record<string, UsageQuota>): Record<string, UsageQuota> {
|
||
const ordered: Record<string, UsageQuota> = {};
|
||
|
||
for (const key of GLM_QUOTA_ORDER) {
|
||
if (quotas[key]) ordered[key] = quotas[key];
|
||
}
|
||
|
||
for (const [key, quota] of Object.entries(quotas)) {
|
||
if (!ordered[key]) ordered[key] = quota;
|
||
}
|
||
|
||
return ordered;
|
||
}
|
||
|
||
async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string, unknown>) {
|
||
if (!apiKey) {
|
||
return { message: "API key not available. Add a coding plan API key to view usage." };
|
||
}
|
||
|
||
const quotaUrl = getGlmQuotaUrl(providerSpecificData);
|
||
|
||
const res = await fetch(quotaUrl, {
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
Accept: "application/json",
|
||
},
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) throw new Error("Invalid API key");
|
||
throw new Error(`GLM quota API error (${res.status})`);
|
||
}
|
||
|
||
const json = await res.json();
|
||
if (toNumber(json.code, 200) === 401 || json.success === false) {
|
||
throw new Error("Invalid API key");
|
||
}
|
||
|
||
const data = toRecord(json.data);
|
||
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
for (const limit of limits) {
|
||
const src = toRecord(limit);
|
||
const type = String(src.type || "").toUpperCase();
|
||
const resetMs = toNumber(src.nextResetTime, 0);
|
||
const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null;
|
||
|
||
if (type === "TOKENS_LIMIT") {
|
||
const quotaName = getGlmTokenQuotaName(src, quotas);
|
||
const usedPercent = toPercentage(src.percentage);
|
||
const remaining = Math.max(0, 100 - usedPercent);
|
||
|
||
quotas[quotaName] = {
|
||
used: usedPercent,
|
||
total: 100,
|
||
remaining,
|
||
remainingPercentage: remaining,
|
||
resetAt,
|
||
displayName: getGlmQuotaDisplayName(quotaName),
|
||
details: Array.isArray(src.models)
|
||
? (src.models as unknown[]).map((m) => {
|
||
const modelInfo = toRecord(m);
|
||
return {
|
||
name: String(modelInfo.model || ""),
|
||
used: toNumber(modelInfo.percentage, 0),
|
||
};
|
||
})
|
||
: [],
|
||
unlimited: false,
|
||
};
|
||
continue;
|
||
}
|
||
|
||
if (type === "TIME_LIMIT") {
|
||
const total = toNumber(src.usage, toNumber(src.total, 0));
|
||
const remaining = toNumber(src.remaining, Math.max(0, 100 - toPercentage(src.percentage)));
|
||
const used = toNumber(src.currentValue, Math.max(0, total - remaining));
|
||
const remainingPercentage =
|
||
total > 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 0;
|
||
|
||
quotas["mcp_monthly"] = {
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage,
|
||
resetAt,
|
||
unlimited: false,
|
||
displayName: "Monthly",
|
||
details: Array.isArray(src.usageDetails)
|
||
? src.usageDetails.map((item) => {
|
||
const detail = toRecord(item);
|
||
return {
|
||
name: String(detail.modelCode || detail.name || "usage"),
|
||
used: toNumber(detail.usage, 0),
|
||
};
|
||
})
|
||
: undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
const levelRaw =
|
||
typeof data.planName === "string"
|
||
? data.planName
|
||
: typeof data.level === "string"
|
||
? data.level
|
||
: "";
|
||
const plan = levelRaw ? toTitleCase(levelRaw.replace(/\s*plan$/i, "")) : null;
|
||
|
||
return { plan, quotas: orderGlmQuotas(quotas) };
|
||
}
|
||
|
||
async function getOpenCodeGoUsage(apiKey: string) {
|
||
const token = normalizeOpenCodeGoQuotaToken(apiKey);
|
||
|
||
if (!token) {
|
||
return { message: "API key not available. Add an OpenCode Go API key to view usage." };
|
||
}
|
||
|
||
const res = await fetch(OPENCODE_GO_QUOTA_URL, {
|
||
headers: {
|
||
Authorization: token,
|
||
"Accept-Language": "en-US,en",
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401 || res.status === 403) throw new Error("Invalid OpenCode Go API key");
|
||
throw new Error(`OpenCode Go quota API error (${res.status})`);
|
||
}
|
||
|
||
const json = await res.json();
|
||
const code = toNumber(json.code, 200);
|
||
if (code === 401 || code === 403 || json.success === false) {
|
||
throw new Error("Invalid OpenCode Go API key");
|
||
}
|
||
|
||
const data = toRecord(json.data);
|
||
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
for (const limit of limits) {
|
||
const src = toRecord(limit);
|
||
const type = String(src.type || "").toUpperCase();
|
||
const resetAt = parseResetTime(src.nextResetTime);
|
||
|
||
if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") {
|
||
const quotaName = getOpenCodeGoTokenQuotaName(src, quotas);
|
||
|
||
quotas[quotaName] = buildOpenCodeGoDollarQuota(
|
||
quotaName,
|
||
src.percentage,
|
||
resetAt,
|
||
undefined,
|
||
Array.isArray(src.models)
|
||
? (src.models as unknown[]).map((model) => {
|
||
const modelInfo = toRecord(model);
|
||
return {
|
||
name: String(modelInfo.model || modelInfo.modelCode || "usage"),
|
||
used: toNumber(modelInfo.percentage, 0),
|
||
};
|
||
})
|
||
: undefined
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") {
|
||
quotas.mcp_monthly = buildOpenCodeGoDollarQuota(
|
||
"mcp_monthly",
|
||
src.percentage,
|
||
resetAt,
|
||
src.currentValue,
|
||
Array.isArray(src.usageDetails)
|
||
? src.usageDetails.map((item) => {
|
||
const detail = toRecord(item);
|
||
return {
|
||
name: String(detail.modelCode || detail.name || "usage"),
|
||
used: toNumber(detail.usage, 0),
|
||
};
|
||
})
|
||
: undefined
|
||
);
|
||
}
|
||
}
|
||
|
||
const levelRaw =
|
||
typeof data.planName === "string"
|
||
? data.planName
|
||
: typeof data.level === "string"
|
||
? data.level
|
||
: "";
|
||
const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, ""));
|
||
const plan = planLabel
|
||
? /^opencode\s+go\b/i.test(planLabel)
|
||
? planLabel
|
||
: `OpenCode Go ${planLabel}`
|
||
: null;
|
||
|
||
return { plan, quotas: orderOpenCodeGoQuotas(quotas) };
|
||
}
|
||
|
||
/**
|
||
* Bailian (Alibaba Coding Plan) Usage
|
||
* Fetches triple-window quota (5h, weekly, monthly) and returns worst-case.
|
||
*/
|
||
async function getBailianCodingPlanUsage(
|
||
connectionId: string,
|
||
apiKey: string,
|
||
providerSpecificData?: Record<string, unknown>
|
||
) {
|
||
try {
|
||
const connection = { apiKey, providerSpecificData };
|
||
const quota = await fetchBailianQuota(connectionId, connection);
|
||
|
||
if (!quota) {
|
||
return { message: "Bailian Coding Plan connected. Unable to fetch quota." };
|
||
}
|
||
|
||
const bailianQuota = quota as BailianTripleWindowQuota;
|
||
const used = bailianQuota.used;
|
||
const total = bailianQuota.total;
|
||
const remaining = Math.max(0, total - used);
|
||
const remainingPercentage = Math.round(remaining);
|
||
|
||
return {
|
||
plan: "Alibaba Coding Plan",
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage,
|
||
resetAt: bailianQuota.resetAt,
|
||
unlimited: false,
|
||
displayName: "Alibaba Coding Plan",
|
||
};
|
||
} catch (error) {
|
||
return { message: `Bailian Coding Plan error: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DeepSeek Usage
|
||
* Fetches balance from the DeepSeek balance API.
|
||
* Returns all balances (USD and CNY) as "credits" for credits-style UI display.
|
||
*/
|
||
async function getDeepseekUsage(connectionId: string, apiKey: string) {
|
||
try {
|
||
const connection = { apiKey };
|
||
const quota = await fetchDeepseekQuota(connectionId, connection);
|
||
|
||
if (!quota) {
|
||
return { message: "DeepSeek API key not available. Add a key to view usage." };
|
||
}
|
||
|
||
const deepseekQuota = quota as DeepseekQuota;
|
||
const { balances, isAvailable, limitReached } = deepseekQuota;
|
||
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
// Show all balances as credits-style entries (e.g., credits_usd, credits_cny)
|
||
// The UI will display them as "🪙 Balance (USD) $50.00"
|
||
for (const balanceInfo of balances) {
|
||
const key = `credits_${balanceInfo.currency.toLowerCase()}`;
|
||
quotas[key] = {
|
||
used: 0,
|
||
total: 0,
|
||
remaining: balanceInfo.balance,
|
||
remainingPercentage: 100,
|
||
resetAt: null,
|
||
unlimited: true,
|
||
currency: balanceInfo.currency,
|
||
grantedBalance: balanceInfo.grantedBalance,
|
||
toppedUpBalance: balanceInfo.toppedUpBalance,
|
||
};
|
||
}
|
||
|
||
const plan = isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)";
|
||
|
||
return {
|
||
plan,
|
||
quotas,
|
||
isAvailable,
|
||
limitReached,
|
||
};
|
||
} catch (error) {
|
||
return { message: `DeepSeek error: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
// Xiaomi MiMo Token Plan monthly limit (tokens). Keep in sync with the
|
||
// "xiaomi-mimo" preset in src/lib/quota/planRegistry.ts.
|
||
const XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT = 4_100_000_000;
|
||
|
||
/**
|
||
* Xiaomi MiMo — SELF-TRACKED monthly quota.
|
||
*
|
||
* Xiaomi exposes plan usage only behind the console session cookie (the API key
|
||
* cannot reach the `tokenPlan/usage` endpoint), so there is no upstream usage
|
||
* API to call. Instead we count the tokens OmniRoute itself routed to this
|
||
* connection in the current UTC month (from `usage_history`) and compare them
|
||
* to the known Token Plan monthly limit. This reflects only traffic that went
|
||
* through OmniRoute, not the provider's own dashboard figure.
|
||
*/
|
||
async function getXiaomiMimoUsage(connectionId: string) {
|
||
if (!connectionId) {
|
||
return { message: "Xiaomi MiMo: connection id unavailable for self-tracked quota." };
|
||
}
|
||
try {
|
||
const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats");
|
||
const used = getMonthlyProviderTokensForConnection("xiaomi-mimo", connectionId);
|
||
const total = XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT;
|
||
const now = new Date();
|
||
const resetAt = new Date(
|
||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)
|
||
).toISOString();
|
||
return {
|
||
plan: "Xiaomi MiMo Token Plan (OmniRoute-tracked)",
|
||
quotas: {
|
||
monthly: createQuotaFromUsage(used, total, resetAt),
|
||
},
|
||
};
|
||
} catch (error) {
|
||
return { message: `Xiaomi MiMo self-tracked usage error: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* OpenCode Go / OpenCode / OpenCode Zen Usage
|
||
* Delegates to the dedicated opencodeQuotaFetcher and shapes the result into
|
||
* the standard `{ plan, quotas }` usage response expected by the limits page.
|
||
*
|
||
* Three rolling windows are surfaced: $12/5h, $30/wk, $60/mo.
|
||
*/
|
||
async function getOpencodeUsage(connectionId: string, apiKey: string) {
|
||
if (!apiKey) {
|
||
return { message: "OpenCode API key not available. Add a key to view usage." };
|
||
}
|
||
|
||
try {
|
||
const quota = (await fetchOpencodeQuota(connectionId, { apiKey })) as OpencodeTripleWindowQuota | null;
|
||
|
||
if (!quota) {
|
||
return { message: "OpenCode connected. Unable to fetch quota data." };
|
||
}
|
||
|
||
const { window5h, windowWeekly, windowMonthly, limitReached } = quota;
|
||
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
// $12 / 5-hour rolling window
|
||
quotas["window_5h"] = {
|
||
used: window5h.percentUsed * 12,
|
||
total: 12,
|
||
remaining: (1 - window5h.percentUsed) * 12,
|
||
remainingPercentage: (1 - window5h.percentUsed) * 100,
|
||
resetAt: window5h.resetAt,
|
||
unlimited: false,
|
||
displayName: "$12 / 5-hour",
|
||
currency: "USD",
|
||
};
|
||
|
||
// $30 / weekly window
|
||
quotas["window_weekly"] = {
|
||
used: windowWeekly.percentUsed * 30,
|
||
total: 30,
|
||
remaining: (1 - windowWeekly.percentUsed) * 30,
|
||
remainingPercentage: (1 - windowWeekly.percentUsed) * 100,
|
||
resetAt: windowWeekly.resetAt,
|
||
unlimited: false,
|
||
displayName: "$30 / week",
|
||
currency: "USD",
|
||
};
|
||
|
||
// $60 / monthly window
|
||
quotas["window_monthly"] = {
|
||
used: windowMonthly.percentUsed * 60,
|
||
total: 60,
|
||
remaining: (1 - windowMonthly.percentUsed) * 60,
|
||
remainingPercentage: (1 - windowMonthly.percentUsed) * 100,
|
||
resetAt: windowMonthly.resetAt,
|
||
unlimited: false,
|
||
displayName: "$60 / month",
|
||
currency: "USD",
|
||
};
|
||
|
||
return {
|
||
plan: "OpenCode Go",
|
||
quotas,
|
||
limitReached,
|
||
};
|
||
} catch (error) {
|
||
return { message: `OpenCode error: ${sanitizeErrorMessage(error)}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* NanoGPT Usage
|
||
* Fetches subscription-level quota from the NanoGPT API.
|
||
* Returns daily/weekly token limits and daily image limits for PRO accounts.
|
||
*/
|
||
async function getNanoGptUsage(apiKey: string) {
|
||
if (!apiKey) {
|
||
return { message: "NanoGPT API key not available. Add a key to view usage." };
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(NANOGPT_CONFIG.usageUrl, {
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) return { message: "Invalid NanoGPT API key." };
|
||
return { message: `NanoGPT quota API error (${res.status})` };
|
||
}
|
||
|
||
const data = toRecord(await res.json());
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
// active -> PRO, otherwise FREE
|
||
const plan = data.active ? "PRO" : "FREE";
|
||
|
||
if (data.active) {
|
||
// 1. Tokens limit
|
||
// dailyInputTokens if exists, else weeklyInputTokens
|
||
let tokenQuota = toRecord(data.dailyInputTokens);
|
||
let tokenLabel = "Daily Tokens";
|
||
if (!tokenQuota.resetAt) {
|
||
const weeklyQuota = toRecord(data.weeklyInputTokens);
|
||
if (weeklyQuota.remaining !== undefined) {
|
||
tokenQuota = weeklyQuota;
|
||
tokenLabel = "Weekly Tokens";
|
||
}
|
||
}
|
||
|
||
if (tokenQuota.remaining !== undefined) {
|
||
const used = toNumber(tokenQuota.used, 0);
|
||
const remaining = toNumber(tokenQuota.remaining, 0);
|
||
const total = used + remaining;
|
||
quotas[tokenLabel] = {
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100),
|
||
resetAt: parseResetTime(tokenQuota.resetAt),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
// 2. Images limit
|
||
const imageQuota = toRecord(data.dailyImages);
|
||
if (imageQuota.remaining !== undefined) {
|
||
const used = toNumber(imageQuota.used, 0);
|
||
const remaining = toNumber(imageQuota.remaining, 0);
|
||
const total = used + remaining;
|
||
quotas["Daily Images"] = {
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100),
|
||
resetAt: parseResetTime(imageQuota.resetAt),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
if (Object.keys(quotas).length === 0) {
|
||
return { plan, message: "NanoGPT connected, but no active limits found." };
|
||
}
|
||
}
|
||
|
||
return { plan, quotas };
|
||
} catch (error) {
|
||
return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Decode the `sub` claim of a Cursor JWT (the WorkOS user id).
|
||
* Returns null if the token is not a parseable JWT.
|
||
*/
|
||
function decodeCursorJwtSub(token: string): string | null {
|
||
if (!token || typeof token !== "string") return null;
|
||
const parts = token.split(".");
|
||
if (parts.length !== 3) return null;
|
||
try {
|
||
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||
while (payload.length % 4 !== 0) payload += "=";
|
||
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||
const sub = decoded?.sub;
|
||
return typeof sub === "string" && sub.length > 0 ? sub : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cursor Pro Plan Usage
|
||
* Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three
|
||
* windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API.
|
||
*/
|
||
async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) {
|
||
if (!accessToken) {
|
||
return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." };
|
||
}
|
||
|
||
const storedUserId = (() => {
|
||
const raw = toRecord(providerSpecificData).userId;
|
||
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
||
})();
|
||
const userId = storedUserId || decodeCursorJwtSub(accessToken);
|
||
|
||
if (!userId) {
|
||
return {
|
||
message: "Cursor token missing user id. Re-import the connection from Cursor IDE.",
|
||
};
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, {
|
||
method: "POST",
|
||
redirect: "manual",
|
||
headers: {
|
||
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
|
||
Origin: CURSOR_USAGE_CONFIG.origin,
|
||
Referer: CURSOR_USAGE_CONFIG.referer,
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
|
||
},
|
||
body: "{}",
|
||
});
|
||
|
||
// 3xx redirect to WorkOS authkit means the session cookie was rejected.
|
||
if (response.status >= 300 && response.status < 400) {
|
||
return {
|
||
plan: "Cursor",
|
||
message: "Cursor session expired. Re-import the token from Cursor IDE.",
|
||
};
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorText = (await response.text()).slice(0, 200);
|
||
if (response.status === 401 || response.status === 403) {
|
||
return {
|
||
plan: "Cursor",
|
||
message: "Cursor session unauthorized. Re-import the token from Cursor IDE.",
|
||
};
|
||
}
|
||
return {
|
||
plan: "Cursor",
|
||
message: `Cursor usage endpoint error (${response.status}): ${errorText}`,
|
||
};
|
||
}
|
||
|
||
const data = toRecord(await response.json());
|
||
const planUsage = toRecord(data.planUsage);
|
||
|
||
if (Object.keys(planUsage).length === 0) {
|
||
return {
|
||
plan: "Cursor",
|
||
message: "Cursor connected. No active plan usage returned.",
|
||
};
|
||
}
|
||
|
||
const limitCents = Math.max(0, toNumber(planUsage.limit, 0));
|
||
const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0));
|
||
const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0));
|
||
const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0));
|
||
const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0));
|
||
|
||
// billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number.
|
||
const billingCycleEndMs = toNumber(data.billingCycleEnd, 0);
|
||
const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null;
|
||
|
||
// Convert cents → dollars rounded to 2 decimal places.
|
||
const toDollars = (cents: number) => Math.round(cents) / 100;
|
||
|
||
const limitDollars = toDollars(limitCents);
|
||
const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => {
|
||
const usedCents =
|
||
typeof usedCentsOverride === "number"
|
||
? usedCentsOverride
|
||
: Math.round((limitCents * percentUsed) / 100);
|
||
const used = toDollars(Math.min(usedCents, limitCents));
|
||
const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0));
|
||
return {
|
||
used,
|
||
total: limitDollars,
|
||
remaining,
|
||
remainingPercentage: clampPercentage(100 - percentUsed),
|
||
resetAt,
|
||
unlimited: false,
|
||
};
|
||
};
|
||
|
||
const quotas: Record<string, UsageQuota> = {
|
||
Total: buildWindow(totalPercentUsed, totalSpendCents),
|
||
"Auto + Composer": buildWindow(autoPercentUsed),
|
||
API: buildWindow(apiPercentUsed),
|
||
};
|
||
|
||
return {
|
||
plan: "Cursor Pro",
|
||
quotas,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
plan: "Cursor",
|
||
message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Single source of truth for which providers have a `getUsageForProvider`
|
||
* implementation. Consumers like `genericQuotaFetcher.ts` reference this so
|
||
* the registration list can't drift from the switch statement below.
|
||
*
|
||
* If you add a new provider to the switch, add it here too.
|
||
*/
|
||
export const USAGE_FETCHER_PROVIDERS = [
|
||
"github",
|
||
"gemini-cli",
|
||
"antigravity",
|
||
"agy",
|
||
"claude",
|
||
"codex",
|
||
"cursor",
|
||
"kiro",
|
||
"amazon-q",
|
||
"kimi-coding",
|
||
"qwen",
|
||
"qoder",
|
||
"glm",
|
||
"glm-cn",
|
||
"zai",
|
||
"glmt",
|
||
"opencode-go",
|
||
"minimax",
|
||
"minimax-cn",
|
||
"crof",
|
||
"bailian-coding-plan",
|
||
"nanogpt",
|
||
"deepseek",
|
||
"opencode",
|
||
"opencode-zen",
|
||
"xiaomi-mimo",
|
||
] as const;
|
||
|
||
export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number];
|
||
|
||
/**
|
||
* Get usage data for a provider connection
|
||
* @param {Object} connection - Provider connection with accessToken
|
||
* @returns {Promise<unknown>} Usage data with quotas
|
||
*/
|
||
export async function getUsageForProvider(
|
||
connection: UsageProviderConnection,
|
||
options: { forceRefresh?: boolean } = {}
|
||
) {
|
||
const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection;
|
||
|
||
switch (provider) {
|
||
case "github":
|
||
return await getGitHubUsage(accessToken, providerSpecificData);
|
||
case "gemini-cli":
|
||
return await getGeminiUsage(accessToken, providerSpecificData, projectId);
|
||
case "antigravity":
|
||
case "agy":
|
||
return await getAntigravityUsage(accessToken, providerSpecificData, projectId, id, options);
|
||
case "claude":
|
||
return await getClaudeUsage(accessToken);
|
||
case "codex":
|
||
return await getCodexUsage(accessToken, providerSpecificData);
|
||
case "cursor":
|
||
return await getCursorUsage(accessToken || "", providerSpecificData);
|
||
case "kiro":
|
||
case "amazon-q":
|
||
return await getKiroUsage(accessToken, providerSpecificData);
|
||
case "kimi-coding":
|
||
return await getKimiUsage(accessToken);
|
||
case "qwen":
|
||
return await getQwenUsage(accessToken, providerSpecificData);
|
||
case "qoder":
|
||
return await getQoderUsage(accessToken);
|
||
case "glm":
|
||
case "glm-cn":
|
||
case "zai":
|
||
case "glmt":
|
||
return await getGlmUsage(apiKey || "", {
|
||
...(providerSpecificData || {}),
|
||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||
});
|
||
case "opencode-go":
|
||
return await getOpenCodeGoUsage(apiKey || "");
|
||
case "minimax":
|
||
case "minimax-cn":
|
||
return await getMiniMaxUsage(apiKey || "", provider);
|
||
case "crof":
|
||
return await getCrofUsage(apiKey || "");
|
||
case "bailian-coding-plan":
|
||
return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData);
|
||
case "nanogpt":
|
||
return await getNanoGptUsage(apiKey || "");
|
||
case "deepseek":
|
||
return await getDeepseekUsage(id || "", apiKey || "");
|
||
case "opencode":
|
||
case "opencode-zen":
|
||
return await getOpencodeUsage(id || "", apiKey || "");
|
||
case "xiaomi-mimo":
|
||
return await getXiaomiMimoUsage(id || "");
|
||
default:
|
||
return { message: `Usage API not implemented for ${provider}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse reset date/time to ISO string
|
||
* Handles multiple formats: Unix timestamp (ms), ISO date string, etc.
|
||
*/
|
||
function parseResetTime(resetValue: unknown): string | null {
|
||
if (!resetValue) return null;
|
||
|
||
try {
|
||
let date: Date;
|
||
if (resetValue instanceof Date) {
|
||
date = resetValue;
|
||
} else if (typeof resetValue === "number") {
|
||
date = new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue);
|
||
} else if (typeof resetValue === "string") {
|
||
date = new Date(resetValue);
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
// Epoch-zero (1970-01-01) means no scheduled reset — treat as null
|
||
if (date.getTime() <= 0) return null;
|
||
|
||
return date.toISOString();
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GitHub Copilot Usage
|
||
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
|
||
*/
|
||
async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
||
try {
|
||
if (!accessToken) {
|
||
throw new Error("No GitHub access token available. Please re-authorize the connection.");
|
||
}
|
||
|
||
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
|
||
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
||
headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.text();
|
||
if (response.status === 401 || response.status === 403) {
|
||
return {
|
||
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
|
||
};
|
||
}
|
||
throw new Error(`GitHub API error: ${error}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const dataRecord = toRecord(data);
|
||
|
||
// Handle different response formats (paid vs free)
|
||
if (dataRecord.quota_snapshots) {
|
||
// Paid plan format
|
||
const snapshots = toRecord(dataRecord.quota_snapshots);
|
||
const resetAt = parseResetTime(
|
||
getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate")
|
||
);
|
||
const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt);
|
||
const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt);
|
||
const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt);
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
if (shouldDisplayGitHubQuota(premiumQuota)) {
|
||
quotas.premium_interactions = premiumQuota;
|
||
}
|
||
if (shouldDisplayGitHubQuota(chatQuota)) {
|
||
quotas.chat = chatQuota;
|
||
}
|
||
if (shouldDisplayGitHubQuota(completionsQuota)) {
|
||
quotas.completions = completionsQuota;
|
||
}
|
||
|
||
return {
|
||
plan: inferGitHubPlanName(dataRecord, premiumQuota),
|
||
resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"),
|
||
quotas,
|
||
};
|
||
} else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) {
|
||
// Free/limited plan format. NOTE (#2876): the upstream field
|
||
// `limited_user_quotas[name]` is the *remaining* count for the month
|
||
// (it counts down toward 0 and resets on `limited_user_reset_date`),
|
||
// NOT the used count. The pre-3.8.6 implementation inverted this and
|
||
// showed "0% when not used / 100% when fully used" on the dashboard.
|
||
// Confirmed against three independent upstream parsers:
|
||
// - robinebers/openusage docs/providers/copilot.md (Free Tier table)
|
||
// - raycast/extensions agent-usage/src/copilot/fetcher.ts (inline comment)
|
||
// - looplj/axonhub frontend/src/components/quota-badges.tsx
|
||
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
|
||
const remainingQuotas = toRecord(dataRecord.limited_user_quotas);
|
||
const resetDate = getFieldValue(
|
||
dataRecord,
|
||
"limited_user_reset_date",
|
||
"limitedUserResetDate"
|
||
);
|
||
const resetAt = parseResetTime(resetDate);
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
const addLimitedQuota = (name: string) => {
|
||
const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0);
|
||
if (total <= 0) return null;
|
||
const remainingRaw = Math.max(
|
||
0,
|
||
toNumber(getFieldValue(remainingQuotas, name, name), 0)
|
||
);
|
||
const remaining = Math.min(remainingRaw, total);
|
||
const used = Math.max(total - remaining, 0);
|
||
quotas[name] = {
|
||
used,
|
||
total,
|
||
remaining,
|
||
remainingPercentage: clampPercentage((remaining / total) * 100),
|
||
unlimited: false,
|
||
resetAt,
|
||
};
|
||
return quotas[name];
|
||
};
|
||
|
||
const premiumQuota = addLimitedQuota("premium_interactions");
|
||
addLimitedQuota("chat");
|
||
addLimitedQuota("completions");
|
||
|
||
return {
|
||
plan: inferGitHubPlanName(dataRecord, premiumQuota),
|
||
resetDate,
|
||
quotas,
|
||
};
|
||
}
|
||
|
||
return { message: "GitHub Copilot connected. Unable to parse quota data." };
|
||
} catch (error) {
|
||
throw new Error(`Failed to fetch GitHub usage: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function formatGitHubQuotaSnapshot(
|
||
quota: unknown,
|
||
resetAt: string | null = null
|
||
): UsageQuota | null {
|
||
const source = toRecord(quota);
|
||
if (Object.keys(source).length === 0) return null;
|
||
|
||
const unlimited = source.unlimited === true;
|
||
const entitlement = toNumber(source.entitlement, Number.NaN);
|
||
const totalValue = toNumber(source.total, Number.NaN);
|
||
const remainingValue = toNumber(source.remaining, Number.NaN);
|
||
const usedValue = toNumber(source.used, Number.NaN);
|
||
const percentRemainingValue = toNumber(
|
||
getFieldValue(source, "percent_remaining", "percentRemaining"),
|
||
Number.NaN
|
||
);
|
||
|
||
let total = Number.isFinite(totalValue)
|
||
? Math.max(0, totalValue)
|
||
: Number.isFinite(entitlement)
|
||
? Math.max(0, entitlement)
|
||
: 0;
|
||
let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined;
|
||
let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined;
|
||
let remainingPercentage = Number.isFinite(percentRemainingValue)
|
||
? clampPercentage(percentRemainingValue)
|
||
: undefined;
|
||
|
||
if (used === undefined && total > 0 && remaining !== undefined) {
|
||
used = Math.max(total - remaining, 0);
|
||
}
|
||
|
||
if (remaining === undefined && total > 0 && used !== undefined) {
|
||
remaining = Math.max(total - used, 0);
|
||
}
|
||
|
||
if (remainingPercentage === undefined && total > 0 && remaining !== undefined) {
|
||
remainingPercentage = clampPercentage((remaining / total) * 100);
|
||
}
|
||
|
||
if (total <= 0 && remainingPercentage !== undefined) {
|
||
total = 100;
|
||
used = 100 - remainingPercentage;
|
||
remaining = remainingPercentage;
|
||
}
|
||
|
||
return {
|
||
used: Math.max(0, used ?? 0),
|
||
total,
|
||
remaining,
|
||
remainingPercentage,
|
||
resetAt,
|
||
unlimited,
|
||
};
|
||
}
|
||
|
||
function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string {
|
||
const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan");
|
||
const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku");
|
||
const planText = typeof rawPlan === "string" ? rawPlan.trim() : "";
|
||
const skuText = typeof rawSku === "string" ? rawSku.trim() : "";
|
||
const combined = `${skuText} ${planText}`.trim().toUpperCase();
|
||
const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas"));
|
||
const premiumTotal =
|
||
premiumQuota?.total ||
|
||
toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0);
|
||
const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0);
|
||
|
||
if (combined.includes("PRO+") || combined.includes("PRO_PLUS") || combined.includes("PROPLUS")) {
|
||
return "Copilot Pro+";
|
||
}
|
||
if (combined.includes("ENTERPRISE")) return "Copilot Enterprise";
|
||
if (combined.includes("BUSINESS")) return "Copilot Business";
|
||
if (combined.includes("STUDENT")) return "Copilot Student";
|
||
if (combined.includes("FREE")) return "Copilot Free";
|
||
if (combined.includes("PRO")) return "Copilot Pro";
|
||
|
||
if (premiumTotal >= 1400) return "Copilot Pro+";
|
||
if (premiumTotal >= 900) return "Copilot Enterprise";
|
||
if (premiumTotal >= 250) {
|
||
if (combined.includes("INDIVIDUAL")) return "Copilot Pro";
|
||
return "Copilot Business";
|
||
}
|
||
if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free";
|
||
|
||
if (skuText) {
|
||
const label = toDisplayLabel(skuText);
|
||
return label ? `Copilot ${label}` : "GitHub Copilot";
|
||
}
|
||
if (planText) {
|
||
const label = toDisplayLabel(planText);
|
||
return label ? `Copilot ${label}` : "GitHub Copilot";
|
||
}
|
||
return "GitHub Copilot";
|
||
}
|
||
|
||
// ── Gemini CLI subscription info cache ──────────────────────────────────────
|
||
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||
// Key: accessToken → { data, fetchedAt }
|
||
const _geminiCliSubCache = new Map<string, SubscriptionCacheEntry>();
|
||
const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||
|
||
/**
|
||
* Gemini CLI Usage — fetch per-model quota from Cloud Code Assist API.
|
||
* Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com),
|
||
* so this follows the same pattern as getAntigravityUsage().
|
||
*/
|
||
async function getGeminiUsage(
|
||
accessToken?: string,
|
||
providerSpecificData?: JsonRecord,
|
||
connectionProjectId?: string
|
||
) {
|
||
if (!accessToken) {
|
||
return { plan: "Free", message: "Gemini CLI access token not available." };
|
||
}
|
||
|
||
try {
|
||
const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken);
|
||
const projectId =
|
||
connectionProjectId ||
|
||
providerSpecificData?.projectId ||
|
||
toRecord(subscriptionInfo).cloudaicompanionProject ||
|
||
null;
|
||
|
||
const plan = getGeminiCliPlanLabel(subscriptionInfo);
|
||
|
||
if (!projectId) {
|
||
return { plan, message: "Gemini CLI project ID not available." };
|
||
}
|
||
|
||
// Use retrieveUserQuota (same endpoint as Gemini CLI /stats command).
|
||
// Returns per-model buckets with remainingFraction and resetTime.
|
||
const response = await fetch(
|
||
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ project: projectId }),
|
||
signal: AbortSignal.timeout(10000),
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
return { plan, message: `Gemini CLI quota error (${response.status}).` };
|
||
}
|
||
|
||
const data = await response.json();
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
const dataRecord = toRecord(data);
|
||
if (Array.isArray(dataRecord.buckets)) {
|
||
for (const bucketValue of dataRecord.buckets) {
|
||
const bucket = toRecord(bucketValue);
|
||
if (!bucket.modelId || bucket.remainingFraction == null) continue;
|
||
|
||
const remainingFraction = toNumber(bucket.remainingFraction, 0);
|
||
const remainingPercentage = remainingFraction * 100;
|
||
const QUOTA_NORMALIZED_BASE = 1000;
|
||
const total = QUOTA_NORMALIZED_BASE;
|
||
const remaining = Math.round(total * remainingFraction);
|
||
const used = Math.max(0, total - remaining);
|
||
|
||
quotas[String(bucket.modelId)] = {
|
||
used,
|
||
total,
|
||
resetAt: parseResetTime(bucket.resetTime),
|
||
remainingPercentage,
|
||
unlimited: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
return { plan, quotas };
|
||
} catch (error) {
|
||
return { message: `Gemini CLI error: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get Gemini CLI subscription info (cached, 5 min TTL)
|
||
*/
|
||
async function getGeminiCliSubscriptionInfoCached(accessToken: string): Promise<unknown> {
|
||
const cacheKey = accessToken;
|
||
const cached = _geminiCliSubCache.get(cacheKey);
|
||
|
||
if (cached && Date.now() - cached.fetchedAt < GEMINI_CLI_CACHE_TTL_MS) {
|
||
return cached.data;
|
||
}
|
||
|
||
const data = await getGeminiCliSubscriptionInfo(accessToken);
|
||
_geminiCliSubCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Get Gemini CLI subscription info using correct headers.
|
||
*/
|
||
async function getGeminiCliSubscriptionInfo(accessToken: string): Promise<unknown | null> {
|
||
try {
|
||
const response = await fetch(GEMINI_CLI_USAGE_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
metadata: {
|
||
ideType: "IDE_UNSPECIFIED",
|
||
platform: "PLATFORM_UNSPECIFIED",
|
||
pluginType: "GEMINI",
|
||
},
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) return null;
|
||
|
||
return await response.json();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Map Gemini CLI subscription tier to display label (same tiers as Antigravity).
|
||
*/
|
||
function getGeminiCliPlanLabel(subscriptionInfo: unknown): string {
|
||
return mapCodeAssistSubscriptionToPlanLabel(subscriptionInfo);
|
||
}
|
||
|
||
// ── Antigravity subscription info cache ──────────────────────────────────────
|
||
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||
// Key: truncated accessToken → { data, fetchedAt }
|
||
const _antigravitySubCache = new Map<string, SubscriptionCacheEntry>();
|
||
const ANTIGRAVITY_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||
const ANTIGRAVITY_MODELS_CACHE_TTL_MS = 60 * 1000;
|
||
const ANTIGRAVITY_CREDIT_PROBE_TTL_MS = 5 * 60 * 1000;
|
||
const _antigravityAvailableModelsCache = new Map<string, { data: unknown; fetchedAt: number }>();
|
||
const _antigravityAvailableModelsInflight = new Map<string, Promise<unknown>>();
|
||
const _antigravityCreditProbeCache = new Map<string, { data: number | null; fetchedAt: number }>();
|
||
const _antigravityCreditProbeInflight = new Map<string, Promise<number | null>>();
|
||
|
||
// ── Proactive TTL purging for module-level caches ──────────────────────────
|
||
// All 4 data caches only evict on read (passive TTL). This interval proactively
|
||
// purges stale entries so keys accessed once and never again don't leak memory.
|
||
// The 2 inflight Maps (availableModelsInflight, creditProbeInflight) self-clean
|
||
// when the Promise resolves/rejects, so they are NOT touched here.
|
||
const _usageCacheCleanupTimer = setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [key, entry] of _geminiCliSubCache) {
|
||
if (now - entry.fetchedAt > GEMINI_CLI_CACHE_TTL_MS) _geminiCliSubCache.delete(key);
|
||
}
|
||
for (const [key, entry] of _antigravitySubCache) {
|
||
if (now - entry.fetchedAt > ANTIGRAVITY_CACHE_TTL_MS) _antigravitySubCache.delete(key);
|
||
}
|
||
for (const [key, entry] of _antigravityAvailableModelsCache) {
|
||
if (now - entry.fetchedAt > ANTIGRAVITY_MODELS_CACHE_TTL_MS) _antigravityAvailableModelsCache.delete(key);
|
||
}
|
||
for (const [key, entry] of _antigravityCreditProbeCache) {
|
||
if (now - entry.fetchedAt > ANTIGRAVITY_CREDIT_PROBE_TTL_MS) _antigravityCreditProbeCache.delete(key);
|
||
}
|
||
}, 5 * 60 * 1000); // every 5 minutes
|
||
_usageCacheCleanupTimer.unref?.(); // Don't prevent process exit
|
||
|
||
interface AntigravityUsageOptions {
|
||
forceRefresh?: boolean;
|
||
}
|
||
|
||
function buildAntigravityUsageCacheKey(accessToken: string, projectId?: string | null): string {
|
||
return `${accessToken.substring(0, 16)}:${projectId || "default"}`;
|
||
}
|
||
|
||
async function fetchAntigravityAvailableModelsCached(
|
||
accessToken: string,
|
||
projectId?: string | null,
|
||
options: AntigravityUsageOptions = {}
|
||
): Promise<unknown> {
|
||
if (!accessToken) throw new Error("Access token is required");
|
||
|
||
const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId);
|
||
const cached = _antigravityAvailableModelsCache.get(cacheKey);
|
||
if (
|
||
!options.forceRefresh &&
|
||
cached &&
|
||
Date.now() - cached.fetchedAt < ANTIGRAVITY_MODELS_CACHE_TTL_MS
|
||
) {
|
||
return cached.data;
|
||
}
|
||
|
||
const inflight = _antigravityAvailableModelsInflight.get(cacheKey);
|
||
if (inflight) return inflight;
|
||
|
||
const promise = (async () => {
|
||
let response: Response | null = null;
|
||
let lastError: Error | null = null;
|
||
|
||
for (const quotaApiUrl of ANTIGRAVITY_CONFIG.quotaApiUrls) {
|
||
try {
|
||
response = await fetch(quotaApiUrl, {
|
||
method: "POST",
|
||
headers: getAntigravityHeaders("fetchAvailableModels", accessToken),
|
||
body: JSON.stringify(projectId ? { project: projectId } : {}),
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
|
||
if (response.ok || response.status === 401 || response.status === 403) {
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
lastError = error as Error;
|
||
}
|
||
}
|
||
|
||
if (!response) {
|
||
throw lastError || new Error("Antigravity API unavailable");
|
||
}
|
||
|
||
if (response.status === 403) {
|
||
return { __antigravityForbidden: true };
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Antigravity API error: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
_antigravityAvailableModelsCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||
return data;
|
||
})().finally(() => {
|
||
_antigravityAvailableModelsInflight.delete(cacheKey);
|
||
});
|
||
|
||
_antigravityAvailableModelsInflight.set(cacheKey, promise);
|
||
return promise;
|
||
}
|
||
|
||
function extractCodeAssistTierId(subscription: JsonRecord): string {
|
||
const tierId = extractCodeAssistOnboardTierId(subscription);
|
||
if (tierId === "legacy-tier") return "";
|
||
const upper = tierId.toUpperCase();
|
||
return mapCodeAssistTierIdToLabel(upper) ? upper : "";
|
||
}
|
||
|
||
function mapCodeAssistTierIdToLabel(tierId: string): string | null {
|
||
const upper = tierId.toUpperCase();
|
||
if (upper.includes("ULTRA")) return "Ultra";
|
||
if (
|
||
upper.includes("PRO") ||
|
||
upper.includes("PREMIUM") ||
|
||
upper.includes("GOOGLE_ONE") ||
|
||
upper.includes("ONE_AI")
|
||
)
|
||
return "Pro";
|
||
if (upper.includes("ENTERPRISE")) return "Enterprise";
|
||
if (upper.includes("BUSINESS") || upper.includes("STANDARD")) return "Business";
|
||
if (upper.includes("PLUS")) return "Plus";
|
||
if (upper.includes("LITE") || upper.includes("LIGHT")) return "Lite";
|
||
if (upper.includes("FREE") || upper.includes("INDIVIDUAL") || upper.includes("LEGACY"))
|
||
return "Free";
|
||
return null;
|
||
}
|
||
|
||
function mapSubscriptionTierStringToPlanLabel(tierText: string): string | null {
|
||
const upper = tierText.toUpperCase();
|
||
if (upper.includes("ULTRA")) return "Ultra";
|
||
if (upper.includes("PRO") || upper.includes("PREMIUM") || upper.includes("GOOGLE ONE"))
|
||
return "Pro";
|
||
if (upper.includes("ENTERPRISE")) return "Enterprise";
|
||
if (upper.includes("STANDARD") || upper.includes("BUSINESS")) return "Business";
|
||
if (upper.includes("PLUS")) return "Plus";
|
||
if (upper.includes("LITE")) return "Lite";
|
||
if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free";
|
||
// Strip a trailing "(RESTRICTED)" marker. Match the fixed literal anywhere then
|
||
// trim, instead of /\s*\(RESTRICTED\)\s*$/ whose overlapping \s* runs backtrack
|
||
// polynomially on whitespace-heavy upstream input (js/polynomial-redos).
|
||
const normalizedId = upper.replace(/\(RESTRICTED\)/i, "").trim();
|
||
if (normalizedId) {
|
||
const mapped = mapCodeAssistTierIdToLabel(normalizedId);
|
||
if (mapped) return mapped;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function mapCodeAssistSubscriptionToPlanLabel(subscriptionInfo: unknown): string {
|
||
const subscription = toRecord(subscriptionInfo);
|
||
if (Object.keys(subscription).length === 0) return "Free";
|
||
|
||
const subscriptionTier = extractCodeAssistSubscriptionTier(subscriptionInfo);
|
||
if (subscriptionTier) {
|
||
const mapped = mapSubscriptionTierStringToPlanLabel(subscriptionTier);
|
||
if (mapped) return mapped;
|
||
if (subscriptionTier.toLowerCase() !== "free") {
|
||
return subscriptionTier.charAt(0).toUpperCase() + subscriptionTier.slice(1).toLowerCase();
|
||
}
|
||
}
|
||
|
||
const currentTier = toRecord(subscription.currentTier);
|
||
const tierName = String(
|
||
getFieldValue(currentTier, "name", "displayName") ||
|
||
subscription.subscriptionType ||
|
||
subscription.tier ||
|
||
""
|
||
);
|
||
const mappedName = tierName ? mapSubscriptionTierStringToPlanLabel(tierName) : null;
|
||
if (mappedName) return mappedName;
|
||
|
||
const tierId = extractCodeAssistTierId(subscription);
|
||
if (tierId) {
|
||
const mapped = mapCodeAssistTierIdToLabel(tierId);
|
||
if (mapped) return mapped;
|
||
}
|
||
if (currentTier.upgradeSubscriptionType) return "Free";
|
||
if (tierName) return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase();
|
||
return "Free";
|
||
}
|
||
|
||
const KNOWN_ANTIGRAVITY_PLAN_LABELS = new Set([
|
||
"Ultra",
|
||
"Pro",
|
||
"Enterprise",
|
||
"Business",
|
||
"Plus",
|
||
"Lite",
|
||
]);
|
||
|
||
/**
|
||
* Map raw loadCodeAssist tier data to short display labels (Antigravity Manager parity).
|
||
*/
|
||
function getAntigravityPlanLabel(subscriptionInfo: unknown, fallbackInfo?: unknown): string {
|
||
const livePlan = mapCodeAssistSubscriptionToPlanLabel(subscriptionInfo);
|
||
const fallbackPlan = mapCodeAssistSubscriptionToPlanLabel(fallbackInfo);
|
||
|
||
if (KNOWN_ANTIGRAVITY_PLAN_LABELS.has(livePlan)) return livePlan;
|
||
if (KNOWN_ANTIGRAVITY_PLAN_LABELS.has(fallbackPlan)) return fallbackPlan;
|
||
if (livePlan !== "Free") return livePlan;
|
||
return fallbackPlan !== "Free" ? fallbackPlan : livePlan;
|
||
}
|
||
|
||
/**
|
||
* Proactive credit balance probe for Antigravity.
|
||
*
|
||
* Fires a minimal streamGenerateContent request with GOOGLE_ONE_AI credits enabled
|
||
* and maxOutputTokens=1 to extract the `remainingCredits` field from the SSE stream.
|
||
* This uses ~1 credit but lets us show the balance on the dashboard without waiting
|
||
* for a real user request.
|
||
*
|
||
* Returns the credit balance, or null if the probe failed.
|
||
*/
|
||
async function probeAntigravityCreditBalance(
|
||
accessToken: string,
|
||
accountId: string,
|
||
projectId?: string | null,
|
||
options: AntigravityUsageOptions = {},
|
||
providerSpecificData: JsonRecord = {}
|
||
): Promise<number | null> {
|
||
if (!accessToken) return null;
|
||
|
||
const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId || accountId);
|
||
const cached = _antigravityCreditProbeCache.get(cacheKey);
|
||
if (
|
||
!options.forceRefresh &&
|
||
cached &&
|
||
Date.now() - cached.fetchedAt < ANTIGRAVITY_CREDIT_PROBE_TTL_MS
|
||
) {
|
||
return cached.data;
|
||
}
|
||
|
||
const inflight = _antigravityCreditProbeInflight.get(cacheKey);
|
||
if (inflight) return inflight;
|
||
|
||
const promise = probeAntigravityCreditBalanceUncached(
|
||
accessToken,
|
||
accountId,
|
||
projectId,
|
||
providerSpecificData
|
||
)
|
||
.then(
|
||
(data) => {
|
||
_antigravityCreditProbeCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||
return data;
|
||
},
|
||
(error) => {
|
||
_antigravityCreditProbeCache.set(cacheKey, { data: null, fetchedAt: Date.now() });
|
||
throw error;
|
||
}
|
||
)
|
||
.finally(() => {
|
||
_antigravityCreditProbeInflight.delete(cacheKey);
|
||
});
|
||
|
||
_antigravityCreditProbeInflight.set(cacheKey, promise);
|
||
return promise;
|
||
}
|
||
|
||
async function probeAntigravityCreditBalanceUncached(
|
||
accessToken: string,
|
||
accountId: string,
|
||
projectId?: string | null,
|
||
providerSpecificData: JsonRecord = {}
|
||
): Promise<number | null> {
|
||
try {
|
||
if (!projectId) return null;
|
||
|
||
// Try all base URLs (some accounts only work with specific endpoints)
|
||
for (const baseUrl of ANTIGRAVITY_BASE_URLS) {
|
||
const url = `${baseUrl}/v1internal:streamGenerateContent?alt=sse`;
|
||
|
||
const sessionId = getAntigravitySessionId({ connectionId: accountId, projectId });
|
||
const body = {
|
||
project: projectId,
|
||
model: "gemini-2-flash",
|
||
userAgent: "antigravity",
|
||
requestType: "agent",
|
||
requestId: generateAntigravityRequestId(),
|
||
enabledCreditTypes: ["GOOGLE_ONE_AI"],
|
||
request: {
|
||
model: "gemini-2-flash",
|
||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||
generationConfig: { maxOutputTokens: 1 },
|
||
sessionId,
|
||
},
|
||
};
|
||
|
||
const headers: Record<string, string> = {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
Accept: "text/event-stream",
|
||
};
|
||
applyAntigravityClientProfileHeaders(
|
||
headers,
|
||
{ connectionId: accountId, projectId, providerSpecificData },
|
||
body
|
||
);
|
||
|
||
try {
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
signal: AbortSignal.timeout(10_000),
|
||
});
|
||
|
||
if (!res.ok) continue;
|
||
|
||
// Read the full SSE response and scan for remainingCredits
|
||
const rawSSE = await res.text();
|
||
const lines = rawSSE.split("\n");
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith("data:")) continue;
|
||
const payload = trimmed.slice(5).trim();
|
||
if (payload === "[DONE]") break;
|
||
try {
|
||
const parsed = JSON.parse(payload);
|
||
if (Array.isArray(parsed?.remainingCredits)) {
|
||
const googleCredit = parsed.remainingCredits.find(
|
||
(c: { creditType?: string }) => c?.creditType === "GOOGLE_ONE_AI"
|
||
);
|
||
if (googleCredit) {
|
||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||
if (!isNaN(balance)) {
|
||
updateAntigravityRemainingCredits(accountId, balance);
|
||
return balance;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Skip malformed SSE lines
|
||
}
|
||
}
|
||
} catch {
|
||
// Individual endpoint failure; try next
|
||
}
|
||
}
|
||
|
||
return null;
|
||
} catch {
|
||
// Probe is best-effort — don't let it break the usage fetch
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Antigravity Usage - Fetch quota from Google Cloud Code API
|
||
* Uses fetchAvailableModels API which returns ALL models (including Claude)
|
||
* with per-model quotaInfo (remainingFraction, resetTime).
|
||
* retrieveUserQuota only returns Gemini models — not suitable for Antigravity.
|
||
*/
|
||
async function getAntigravityUsage(
|
||
accessToken?: string,
|
||
providerSpecificData?: JsonRecord,
|
||
connectionProjectId?: string,
|
||
connectionId?: string,
|
||
options: AntigravityUsageOptions = {}
|
||
) {
|
||
if (!accessToken) {
|
||
return { plan: "Free", message: "Antigravity access token not available." };
|
||
}
|
||
|
||
let subscriptionInfo: unknown = null;
|
||
try {
|
||
subscriptionInfo = await getAntigravitySubscriptionInfoCached(
|
||
accessToken,
|
||
providerSpecificData,
|
||
options
|
||
);
|
||
const savedProjectId =
|
||
typeof providerSpecificData?.projectId === "string" && providerSpecificData.projectId.trim()
|
||
? providerSpecificData.projectId.trim()
|
||
: null;
|
||
const subscriptionProject = toRecord(subscriptionInfo).cloudaicompanionProject;
|
||
const projectId =
|
||
savedProjectId ||
|
||
connectionProjectId ||
|
||
(typeof subscriptionProject === "string"
|
||
? subscriptionProject
|
||
: typeof toRecord(subscriptionProject).id === "string"
|
||
? (toRecord(subscriptionProject).id as string)
|
||
: null);
|
||
|
||
// Derive accountId for credit balance cache.
|
||
// Must match executor key: credentials.connectionId
|
||
const accountId: string = connectionId || "unknown";
|
||
|
||
// Read cached credit balance (hydrated from DB on first access)
|
||
let creditBalance = getAntigravityRemainingCredits(accountId);
|
||
|
||
// If no cached balance and credits mode is enabled, fire a minimal probe
|
||
const creditsMode = getCreditsMode();
|
||
if ((options.forceRefresh || creditBalance === null) && creditsMode !== "off") {
|
||
creditBalance = await probeAntigravityCreditBalance(
|
||
accessToken,
|
||
accountId,
|
||
projectId,
|
||
options,
|
||
providerSpecificData || {}
|
||
);
|
||
}
|
||
|
||
const data = await fetchAntigravityAvailableModelsCached(accessToken, projectId, options);
|
||
const dataObj = toRecord(data);
|
||
if (dataObj.__antigravityForbidden === true) {
|
||
return { message: "Antigravity access forbidden. Check subscription." };
|
||
}
|
||
const modelEntries = toRecord(dataObj.models);
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
// Parse per-model quota info from fetchAvailableModels response.
|
||
for (const [modelKey, infoValue] of Object.entries(modelEntries)) {
|
||
const info = toRecord(infoValue);
|
||
const quotaInfo = toRecord(info.quotaInfo);
|
||
|
||
// Skip internal, excluded, and models without quota info
|
||
if (
|
||
info.isInternal === true ||
|
||
!isUserCallableAntigravityModelId(modelKey) ||
|
||
Object.keys(quotaInfo).length === 0
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
const rawFraction = toNumber(quotaInfo.remainingFraction, -1);
|
||
const resetAt = parseResetTime(quotaInfo.resetTime);
|
||
// Distinguish "upstream did not report remainingFraction" from "remaining is 0%".
|
||
// A schema drift in Antigravity's quota API (very plausible — internal Google product)
|
||
// would otherwise silently mark every model as exhausted across the dashboard.
|
||
const fractionReported = rawFraction >= 0;
|
||
if (!fractionReported) {
|
||
console.warn(
|
||
`[Antigravity] model ${modelKey} returned no remainingFraction — quota unknown`
|
||
);
|
||
}
|
||
const remainingFraction = fractionReported ? Math.max(0, Math.min(1, rawFraction)) : 0;
|
||
// Models with no resetTime AND a reported full fraction are unlimited
|
||
// (e.g. tab-completion models). Unreported fraction is NEVER unlimited.
|
||
const isUnlimited = fractionReported && !resetAt && remainingFraction >= 1;
|
||
const remainingPercentage = remainingFraction * 100;
|
||
const QUOTA_NORMALIZED_BASE = 1000;
|
||
const total = QUOTA_NORMALIZED_BASE;
|
||
const remaining = Math.round(total * remainingFraction);
|
||
const used = isUnlimited ? 0 : Math.max(0, total - remaining);
|
||
|
||
quotas[modelKey] = {
|
||
used,
|
||
total: isUnlimited ? 0 : total,
|
||
resetAt,
|
||
remainingPercentage: isUnlimited ? 100 : remainingPercentage,
|
||
unlimited: isUnlimited,
|
||
fractionReported,
|
||
};
|
||
}
|
||
|
||
return {
|
||
plan: getAntigravityPlanLabel(subscriptionInfo, providerSpecificData),
|
||
quotas: {
|
||
...quotas,
|
||
...(creditBalance !== null && {
|
||
credits: {
|
||
used: 0,
|
||
total: 0,
|
||
remaining: creditBalance,
|
||
unlimited: false,
|
||
resetAt: null,
|
||
},
|
||
}),
|
||
},
|
||
subscriptionInfo,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
plan: getAntigravityPlanLabel(subscriptionInfo, providerSpecificData),
|
||
subscriptionInfo,
|
||
message: `Antigravity error: ${(error as Error).message}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get Antigravity subscription info (cached, 5 min TTL)
|
||
* Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||
*/
|
||
async function getAntigravitySubscriptionInfoCached(
|
||
accessToken: string,
|
||
providerSpecificData?: JsonRecord,
|
||
options: AntigravityUsageOptions = {}
|
||
): Promise<unknown> {
|
||
const profile = getAntigravityClientProfile({ providerSpecificData });
|
||
const cacheKey = `${accessToken.substring(0, 16)}:${profile}`;
|
||
|
||
if (options.forceRefresh) {
|
||
_antigravitySubCache.delete(cacheKey);
|
||
} else {
|
||
const cached = _antigravitySubCache.get(cacheKey);
|
||
if (cached && Date.now() - cached.fetchedAt < ANTIGRAVITY_CACHE_TTL_MS) {
|
||
return cached.data;
|
||
}
|
||
}
|
||
|
||
const data = await getAntigravitySubscriptionInfo(accessToken, providerSpecificData);
|
||
if (data != null) {
|
||
_antigravitySubCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Get Antigravity subscription info using correct Antigravity headers.
|
||
* Must match the headers used in providers.js postExchange (not CLI headers).
|
||
*/
|
||
async function getAntigravitySubscriptionInfo(
|
||
accessToken: string,
|
||
providerSpecificData?: JsonRecord
|
||
): Promise<unknown | null> {
|
||
try {
|
||
const profile = getAntigravityClientProfile({ providerSpecificData });
|
||
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||
method: "POST",
|
||
headers:
|
||
profile === "harness"
|
||
? getAntigravityBootstrapHeaders(profile, accessToken)
|
||
: getAntigravityHeaders("loadCodeAssist", accessToken),
|
||
body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata() }),
|
||
});
|
||
|
||
if (!response.ok) return null;
|
||
|
||
return await response.json();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Claude Usage - Try to fetch from Anthropic API
|
||
*/
|
||
async function getClaudeUsage(accessToken?: string) {
|
||
if (!accessToken) {
|
||
return { message: "Claude connected. Access token not available.", bootstrap: null };
|
||
}
|
||
|
||
// Refresh bootstrap in parallel; best-effort, failure non-fatal.
|
||
const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null);
|
||
try {
|
||
// Real CLI uses axios here, not Stainless — UA is `claude-code/<version>`
|
||
// (not `claude-cli/...`) and the shape is simpler than /v1/messages.
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
||
let oauthResponse;
|
||
try {
|
||
oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
Accept: "application/json, text/plain, */*",
|
||
"Accept-Encoding": "gzip, compress, deflate, br",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
"User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`,
|
||
"anthropic-beta": "oauth-2025-04-20",
|
||
},
|
||
signal: ctrl.signal,
|
||
});
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
|
||
if (oauthResponse.ok) {
|
||
const data = toRecord(await oauthResponse.json());
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
// utilization = percentage USED (e.g., 90 means 90% used, 10% remaining)
|
||
// Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining.
|
||
const hasUtilization = (window: JsonRecord) =>
|
||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||
|
||
const createQuotaObject = (window: JsonRecord) => {
|
||
const used = safePercentage(window.utilization) as number; // utilization = % used
|
||
const remaining = Math.max(0, 100 - used);
|
||
return {
|
||
used,
|
||
total: 100,
|
||
remaining,
|
||
resetAt: parseResetTime(window.resets_at),
|
||
remainingPercentage: remaining,
|
||
unlimited: false,
|
||
};
|
||
};
|
||
|
||
const fiveHour = toRecord(data.five_hour);
|
||
if (hasUtilization(fiveHour)) {
|
||
quotas["session (5h)"] = createQuotaObject(fiveHour);
|
||
}
|
||
|
||
const sevenDay = toRecord(data.seven_day);
|
||
if (hasUtilization(sevenDay)) {
|
||
quotas["weekly (7d)"] = createQuotaObject(sevenDay);
|
||
}
|
||
|
||
// Map Anthropic's internal codenames (e.g., omelette → Designer) for display.
|
||
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
||
omelette: "designer",
|
||
};
|
||
for (const [key, value] of Object.entries(data)) {
|
||
const valueRecord = toRecord(value);
|
||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
||
const codename = key.replace("seven_day_", "");
|
||
const modelName = MODEL_DISPLAY_NAMES[codename] || codename;
|
||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
||
}
|
||
}
|
||
|
||
const bootstrap = await bootstrapPromise;
|
||
const plan =
|
||
getClaudePlanLabel(
|
||
typeof data.tier === "string" ? data.tier : null,
|
||
typeof data.plan === "string" ? data.plan : null,
|
||
typeof data.subscription_type === "string" ? data.subscription_type : null,
|
||
bootstrap?.organization_rate_limit_tier
|
||
) ?? undefined;
|
||
|
||
return {
|
||
...(plan ? { plan } : {}),
|
||
quotas,
|
||
extraUsage: data.extra_usage ?? null,
|
||
bootstrap,
|
||
};
|
||
}
|
||
|
||
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
|
||
console.warn(
|
||
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`
|
||
);
|
||
const legacy = await getClaudeUsageLegacy(accessToken);
|
||
return { ...legacy, bootstrap: await bootstrapPromise };
|
||
} catch (error) {
|
||
return {
|
||
message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`,
|
||
bootstrap: await bootstrapPromise,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Legacy Claude usage fetcher for API key / org admin users.
|
||
* Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints.
|
||
*/
|
||
async function getClaudeUsageLegacy(accessToken?: string) {
|
||
try {
|
||
const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||
},
|
||
});
|
||
|
||
if (settingsResponse.ok) {
|
||
const settings = toRecord(await settingsResponse.json());
|
||
|
||
const organizationId =
|
||
typeof settings.organization_id === "string" ? settings.organization_id : "";
|
||
if (organizationId) {
|
||
const usageResponse = await fetch(
|
||
CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId),
|
||
{
|
||
method: "GET",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||
},
|
||
}
|
||
);
|
||
|
||
if (usageResponse.ok) {
|
||
const usage = await usageResponse.json();
|
||
return {
|
||
plan: settings.plan || "Unknown",
|
||
organization: settings.organization_name,
|
||
quotas: usage,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
plan: settings.plan || "Unknown",
|
||
organization: settings.organization_name,
|
||
message: "Claude connected. Usage details require admin access.",
|
||
};
|
||
}
|
||
|
||
return { message: "Claude connected. Usage API requires admin permissions." };
|
||
} catch (error) {
|
||
return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
|
||
* IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding.
|
||
* No fallback to other workspaces - strict binding to user's selected workspace.
|
||
*/
|
||
async function getCodexUsage(
|
||
accessToken?: string,
|
||
providerSpecificData: Record<string, unknown> = {}
|
||
) {
|
||
try {
|
||
// Use persisted workspace ID from OAuth - NO FALLBACK
|
||
const accountId =
|
||
typeof providerSpecificData.workspaceId === "string"
|
||
? providerSpecificData.workspaceId
|
||
: null;
|
||
|
||
const headers: Record<string, string> = {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
};
|
||
if (accountId) {
|
||
headers["chatgpt-account-id"] = accountId;
|
||
}
|
||
|
||
const response = await fetch(CODEX_CONFIG.usageUrl, {
|
||
method: "GET",
|
||
headers,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401 || response.status === 403) {
|
||
return {
|
||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||
};
|
||
}
|
||
throw new Error(`Codex API error: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Parse rate limit info (supports both snake_case and camelCase)
|
||
const rateLimit = toRecord(getFieldValue(data, "rate_limit", "rateLimit"));
|
||
const primaryWindow = toRecord(getFieldValue(rateLimit, "primary_window", "primaryWindow"));
|
||
const secondaryWindow = toRecord(
|
||
getFieldValue(rateLimit, "secondary_window", "secondaryWindow")
|
||
);
|
||
|
||
// Parse reset times (reset_at is Unix timestamp in seconds)
|
||
const parseWindowReset = (window: unknown) => {
|
||
const resetAt = toNumber(getFieldValue(window, "reset_at", "resetAt"), 0);
|
||
const resetAfterSeconds = toNumber(
|
||
getFieldValue(window, "reset_after_seconds", "resetAfterSeconds"),
|
||
0
|
||
);
|
||
if (resetAt > 0) return parseResetTime(resetAt * 1000);
|
||
if (resetAfterSeconds > 0) return parseResetTime(Date.now() + resetAfterSeconds * 1000);
|
||
return null;
|
||
};
|
||
|
||
// Build quota windows
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
|
||
// Primary window (5-hour)
|
||
if (Object.keys(primaryWindow).length > 0) {
|
||
const usedPercent = toNumber(getFieldValue(primaryWindow, "used_percent", "usedPercent"), 0);
|
||
quotas.session = {
|
||
used: usedPercent,
|
||
total: 100,
|
||
remaining: 100 - usedPercent,
|
||
resetAt: parseWindowReset(primaryWindow),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
// Secondary window (weekly)
|
||
if (Object.keys(secondaryWindow).length > 0) {
|
||
const usedPercent = toNumber(
|
||
getFieldValue(secondaryWindow, "used_percent", "usedPercent"),
|
||
0
|
||
);
|
||
quotas.weekly = {
|
||
used: usedPercent,
|
||
total: 100,
|
||
remaining: 100 - usedPercent,
|
||
resetAt: parseWindowReset(secondaryWindow),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
// Code review rate limit (3rd window — differs per plan: Plus/Pro/Team)
|
||
const codeReviewRateLimit = toRecord(
|
||
getFieldValue(data, "code_review_rate_limit", "codeReviewRateLimit")
|
||
);
|
||
const codeReviewWindow = toRecord(
|
||
getFieldValue(codeReviewRateLimit, "primary_window", "primaryWindow")
|
||
);
|
||
|
||
// Only include code review quota if the API returned data for it
|
||
const codeReviewUsedRaw = getFieldValue(codeReviewWindow, "used_percent", "usedPercent");
|
||
const codeReviewRemainingRaw = getFieldValue(
|
||
codeReviewWindow,
|
||
"remaining_count",
|
||
"remainingCount"
|
||
);
|
||
if (codeReviewUsedRaw !== null || codeReviewRemainingRaw !== null) {
|
||
const codeReviewUsedPercent = toNumber(codeReviewUsedRaw, 0);
|
||
quotas.code_review = {
|
||
used: codeReviewUsedPercent,
|
||
total: 100,
|
||
remaining: 100 - codeReviewUsedPercent,
|
||
resetAt: parseWindowReset(codeReviewWindow),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"),
|
||
limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")),
|
||
quotas,
|
||
};
|
||
} catch (error) {
|
||
return { message: `Failed to fetch Codex usage: ${(error as Error).message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kiro (AWS CodeWhisperer) Usage
|
||
*/
|
||
async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
||
try {
|
||
const profileArn = providerSpecificData?.profileArn;
|
||
if (!profileArn) {
|
||
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
|
||
}
|
||
|
||
// Kiro uses AWS CodeWhisperer GetUsageLimits API
|
||
const payload = {
|
||
origin: "AI_EDITOR",
|
||
profileArn: profileArn,
|
||
resourceType: "AGENTIC_REQUEST",
|
||
};
|
||
|
||
const response = await fetch(CODEWHISPERER_BASE_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/x-amz-json-1.0",
|
||
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
|
||
}
|
||
|
||
const data = toRecord(await response.json());
|
||
|
||
// Parse usage data from usageBreakdownList
|
||
const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : [];
|
||
const quotaInfo: Record<string, UsageQuota> = {};
|
||
|
||
// Parse reset time - supports multiple formats (nextDateReset, resetDate, etc.)
|
||
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
|
||
|
||
usageList.forEach((breakdownValue: unknown) => {
|
||
const breakdown = toRecord(breakdownValue);
|
||
const resourceType =
|
||
typeof breakdown.resourceType === "string"
|
||
? breakdown.resourceType.toLowerCase()
|
||
: "unknown";
|
||
const used = toNumber(breakdown.currentUsageWithPrecision, 0);
|
||
const total = toNumber(breakdown.usageLimitWithPrecision, 0);
|
||
|
||
quotaInfo[resourceType] = {
|
||
used,
|
||
total,
|
||
remaining: total - used,
|
||
resetAt,
|
||
unlimited: false,
|
||
};
|
||
|
||
// Add free trial if available
|
||
const freeTrialInfo = toRecord(breakdown.freeTrialInfo);
|
||
if (Object.keys(freeTrialInfo).length > 0) {
|
||
const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0);
|
||
const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0);
|
||
|
||
quotaInfo[`${resourceType}_freetrial`] = {
|
||
used: freeUsed,
|
||
total: freeTotal,
|
||
remaining: freeTotal - freeUsed,
|
||
resetAt,
|
||
unlimited: false,
|
||
};
|
||
}
|
||
});
|
||
|
||
return {
|
||
plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro",
|
||
quotas: quotaInfo,
|
||
};
|
||
} catch (error) {
|
||
throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Map Kimi membership level to display name
|
||
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
|
||
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
|
||
*/
|
||
function getKimiPlanName(level: unknown): string {
|
||
if (!level) return "";
|
||
const normalizedLevel = String(level);
|
||
|
||
const levelMap = {
|
||
LEVEL_BASIC: "Moderato",
|
||
LEVEL_INTERMEDIATE: "Allegretto",
|
||
LEVEL_ADVANCED: "Allegro",
|
||
LEVEL_STANDARD: "Vivace",
|
||
};
|
||
|
||
return (
|
||
levelMap[normalizedLevel as keyof typeof levelMap] ||
|
||
normalizedLevel.replace("LEVEL_", "").toLowerCase()
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Kimi Coding Usage - Fetch quota from Kimi API
|
||
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
|
||
*/
|
||
async function getKimiUsage(accessToken?: string) {
|
||
// Generate device info for headers (same as OAuth flow)
|
||
const deviceId = "kimi-usage-" + Date.now();
|
||
const platform = "omniroute";
|
||
const version = "2.1.2";
|
||
const deviceModel =
|
||
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
|
||
|
||
try {
|
||
const response = await fetch(KIMI_CONFIG.usageUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
"X-Msh-Platform": platform,
|
||
"X-Msh-Version": version,
|
||
"X-Msh-Device-Model": deviceModel,
|
||
"X-Msh-Device-Id": deviceId,
|
||
},
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
|
||
if (!response.ok) {
|
||
return {
|
||
plan: "Kimi Coding",
|
||
message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`,
|
||
};
|
||
}
|
||
|
||
let data;
|
||
try {
|
||
data = JSON.parse(responseText);
|
||
} catch {
|
||
return {
|
||
plan: "Kimi Coding",
|
||
message: "Kimi Coding connected. Invalid JSON response from API.",
|
||
};
|
||
}
|
||
|
||
const quotas: Record<string, UsageQuota> = {};
|
||
const dataObj = toRecord(data);
|
||
|
||
// Parse Kimi usage response format
|
||
// Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] }
|
||
const usageObj = toRecord(dataObj.usage);
|
||
|
||
// Check for Kimi's actual usage fields (strings, not numbers)
|
||
const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0);
|
||
const usageUsed = toNumber(usageObj.used || usageObj.Used, 0);
|
||
const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0);
|
||
const usageResetTime =
|
||
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
|
||
|
||
if (usageLimit > 0) {
|
||
const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0;
|
||
|
||
quotas["Weekly"] = {
|
||
used: usageUsed,
|
||
total: usageLimit,
|
||
remaining: usageRemaining,
|
||
remainingPercentage: percentRemaining,
|
||
resetAt: parseResetTime(usageResetTime),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
|
||
// Also parse limits array for rate limits
|
||
const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : [];
|
||
for (let i = 0; i < limitsArray.length; i++) {
|
||
const limitItem = toRecord(limitsArray[i]);
|
||
const window = toRecord(limitItem.window);
|
||
const detail = toRecord(limitItem.detail);
|
||
|
||
const limit = toNumber(detail.limit || detail.Limit, 0);
|
||
const remaining = toNumber(detail.remaining || detail.Remaining, 0);
|
||
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
|
||
|
||
if (limit > 0) {
|
||
quotas["Ratelimit"] = {
|
||
used: limit - remaining,
|
||
total: limit,
|
||
remaining,
|
||
remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0,
|
||
resetAt: parseResetTime(resetTime),
|
||
unlimited: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Check for quota windows (Claude-like format with utilization) as fallback
|
||
const hasUtilization = (window: JsonRecord) =>
|
||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||
|
||
const createQuotaObject = (window: JsonRecord) => {
|
||
const remaining = safePercentage(window.utilization) as number;
|
||
const used = 100 - remaining;
|
||
return {
|
||
used,
|
||
total: 100,
|
||
remaining,
|
||
resetAt: parseResetTime(window.resets_at),
|
||
remainingPercentage: remaining,
|
||
unlimited: false,
|
||
};
|
||
};
|
||
|
||
if (hasUtilization(toRecord(dataObj.five_hour))) {
|
||
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
||
}
|
||
|
||
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
||
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
|
||
}
|
||
|
||
// Check for model-specific quotas
|
||
for (const [key, value] of Object.entries(dataObj)) {
|
||
const valueRecord = toRecord(value);
|
||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
||
const modelName = key.replace("seven_day_", "");
|
||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
||
}
|
||
}
|
||
|
||
if (Object.keys(quotas).length > 0) {
|
||
const userRecord = toRecord(dataObj.user);
|
||
const membershipLevel = toRecord(userRecord.membership).level;
|
||
const planName = getKimiPlanName(membershipLevel);
|
||
return {
|
||
plan: planName || "Kimi Coding",
|
||
quotas,
|
||
};
|
||
}
|
||
|
||
// No quota data in response
|
||
const userRecord = toRecord(dataObj.user);
|
||
const membershipLevel = toRecord(userRecord.membership).level;
|
||
const planName = getKimiPlanName(membershipLevel);
|
||
return {
|
||
plan: planName || "Kimi Coding",
|
||
message: "Kimi Coding connected. Usage tracked per request.",
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Qwen Usage
|
||
*/
|
||
async function getQwenUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
||
void accessToken;
|
||
try {
|
||
const resourceUrl = providerSpecificData?.resourceUrl;
|
||
if (!resourceUrl) {
|
||
return { message: "Qwen connected. No resource URL available." };
|
||
}
|
||
|
||
// Qwen may have usage endpoint at resource URL
|
||
return { message: "Qwen connected. Usage tracked per request." };
|
||
} catch (error) {
|
||
return { message: "Unable to fetch Qwen usage." };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Qoder Usage
|
||
*/
|
||
async function getQoderUsage(accessToken?: string) {
|
||
void accessToken;
|
||
try {
|
||
// Qoder may have usage endpoint
|
||
return { message: "Qoder connected. Usage tracked per request." };
|
||
} catch (error) {
|
||
return { message: "Unable to fetch Qoder usage." };
|
||
}
|
||
}
|
||
|
||
export const __testing = {
|
||
parseResetTime,
|
||
formatGitHubQuotaSnapshot,
|
||
inferGitHubPlanName,
|
||
getGeminiCliPlanLabel,
|
||
getAntigravityPlanLabel,
|
||
extractCodeAssistSubscriptionTier,
|
||
extractCodeAssistOnboardTierId,
|
||
getMiniMaxPlanLabel,
|
||
inferMiniMaxPlanLabelFromTotals,
|
||
getOpencodeUsage,
|
||
getClaudePlanLabel,
|
||
createQuotaFromUsage,
|
||
getMiniMaxQuotaResetAt,
|
||
isMiniMaxTextQuotaModel,
|
||
getMiniMaxSessionTotal,
|
||
getMiniMaxWeeklyTotal,
|
||
createMiniMaxQuotaFromCount,
|
||
createMiniMaxQuotaFromPercent,
|
||
getMiniMaxRemainingPercent,
|
||
getMiniMaxUsage,
|
||
getXiaomiMimoUsage,
|
||
getMiniMaxAuthErrorMessage,
|
||
getMiniMaxErrorSummary,
|
||
mapCodeAssistSubscriptionToPlanLabel,
|
||
mapCodeAssistTierIdToLabel,
|
||
mapSubscriptionTierStringToPlanLabel,
|
||
toDisplayLabel,
|
||
};
|