Files
OmniRoute/open-sse/services/usage.ts
Diego Rodrigues de Sa e Souza 19d91d82e2 Release v3.8.34 (#4614)
* chore(release): open v3.8.34 development cycle

* chore(quality): release-green pre-flight validator + nightly signal (C+D) (#4622)

C — scripts/quality/validate-release-green.mjs (npm run check:release-green):
reproduces the release-equivalent validation (typecheck, eslint, db-rules,
public-creds, full unit, vitest, ratchets, optional --with-build package-artifact)
against the current working tree and classifies each red as HARD (real defect,
exit 1) vs DRIFT (ratchet — reported, never affects exit / never blocks). Pure
helpers exported + orchestration behind a direct-run guard; unit-tested.

D — .github/workflows/nightly-release-green.yml: runs C on the active release
branch nightly (and on workflow_dispatch) and opens/updates a single tracking
issue on HARD failures. Never a required check, never touches a contributor PR.

Closes the gap where the full gate (ci.yml) only ran on the release PR, so reds
accrued silently on release/** and surfaced in 40-min layers at release time.
Non-blocking by construction; drift is the maintainer's to rebaseline at release.

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>

* fix(providers): show revealed connection API keys (#4583)

Integrated into release/v3.8.34

* fix(resilience): respect upstream retry hint toggle (#4585)

Integrated into release/v3.8.34

* feat(settings): expose stream recovery feature flags (#4586)

Integrated into release/v3.8.34

* fix(logs): make active request stale sweep configurable (#4599)

Integrated into release/v3.8.34

* fix(plugin): auto-prefix providerId with 'opencode-' for OC 1.17.8+ native gate (#4527)

Integrated into release/v3.8.34 (supersedes #4445)

* fix(models): treat unknown output caps as unset (#4584)

Integrated into release/v3.8.34

* fix(executors): strip temperature for GitHub Copilot gpt-5.4 family (#4564)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(oauth): update Qwen OAuth URLs from chat.qwen.ai to qwen.ai (#4561)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(api/settings): prevent cached /api/settings responses (port from 9router#951) (#4566)

Integrated into release/v3.8.34 (rebuilt onto tip)

* feat(audio): MiniMax T2A v2 TTS dispatch in audioSpeech (port #1043) (#4553)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails (#4562)

Integrated into release/v3.8.34 (rebuilt onto tip)

* feat(providers): optional model ID for custom API-key validation (#4555)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(cli): align data dir and env loading with runtime (#4607)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(quota): expose Bailian quota windows (#4610)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix: retain provider cooldowns for configured max window (#4588)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix: reject invalid provider cooldown bounds (#4589)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix: preserve production combo metrics on shadow eviction (#4590)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix(stream): estimate input tokens when upstream reports prompt_tokens=0 (#4615)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(catalog): shorten no-thinking gateway prefix to no-think/ (#4525)

Integrated into release/v3.8.34 (rebuilt — kept only the prefix rename, dropped stale-base reverts)

* fix(relay): apply IP rate limit to bifrost sidecar (#4593)

Integrated into release/v3.8.34 (rebuilt onto tip; merge before #4612)

* fix(bifrost): finalize SSE relay usage after stream (#4612)

Integrated into release/v3.8.34 (rebuilt + reconciled with #4593)

* feat(compression): per-request `x-omniroute-compression` header (Phase 3) (#4645)

* docs(compression): Phase 3 per-request header design spec

Approved brainstorming output for the x-omniroute-compression header:
header-first precedence, name-first combo matching (Decision A), explicit
value bypasses auto-trigger (Decision B), DerivedPlan.source, and the
X-OmniRoute-Compression response header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compression): Phase 3 per-request header implementation plan

4-task TDD plan (resolver header-first + source, parser, chatCore wiring +
response header, docs/file-size) with full code and exact commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): header-first resolver + plan source (Phase 3 core)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): resolveCompressionHeader parser (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): wire x-omniroute-compression header + response header (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(compression): extract plan-resolution leaf (planResolution.ts) under size cap (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compression): document x-omniroute-compression header (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compression): harden named-combo map + trim engine: header id (Phase 3 review)

Addresses gemini-code-assist review on #4645:
- Extract buildNamedComboLookup (pure) so a blank/whitespace/null combo name
  contributes only its id key (no '' key, no throw that disables all combos).
- Trim the engine:<id> header value so 'engine: rtk' resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix: exclude exhausted connections from auto scoring (#4592)

Integrated into release/v3.8.34 (rebuilt + opt-in gate fix)

* fix(dashboard): memoize compatible provider groups (#4613)

Integrated into release/v3.8.34 (rebuilt + test added)

* fix(dashboard): isolate quota widget refresh clock (#4611)

Integrated into release/v3.8.34 (rebuilt + jsdom test)

* fix(dashboard): gate topology side effects behind widget visibility (#4606)

Integrated into release/v3.8.34 (rebuilt + jsdom test)

* fix(dashboard): keep play_arrow spinning on provider Test All buttons (#4563)

Integrated into release/v3.8.34 (rebuilt onto tip; UI-cosmetic per owner)

* fix(db): schedule retention cleanup + fix cleanup table/column names (extracted from #4428) (#4691)

Integrated into release/v3.8.34 (cleanup core extracted from #4428, credit @oyi77)

* fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable (#4604) (#4687)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(api): serve GET /v1/models/{model} as JSON, not the HTML dashboard (#4674) (#4677)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(opencode): add go deepseek reasoning variants (#4647)

Integrated into release/v3.8.34

* fix(executors): robust deepseek-web tool-call parsing and agentic context retention (#4644)

Integrated into release/v3.8.34

* fix(cli): authenticate `omniroute logs` and honor active context (#4638)

Integrated into release/v3.8.34 (authored by Rahul Sharma, AI co-author trailer stripped per project policy)

* fix(proxy): apply pipelining:0 + connections cap to the direct dispatcher (#4580) (#4684)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(executors): Firecrawl web_fetch 500 with include_metadata=true (#4692)

Integrated into release/v3.8.34

* fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template (#4621)

Integrated into release/v3.8.34 (dead getFirstRegistryModelId dropped, rebuilt onto tip)

* fix(dashboard): gate home topology live-WS networking (#4596) (#4618)

Integrated into release/v3.8.34 (adapted onto #4606's extracted topology section: default-hidden flip + enabled gate on useLiveDashboard)

* fix(cli): align `omniroute` env loading with the runtime data dir (#4597) (#4619)

Integrated into release/v3.8.34 (data-dir.mjs refactor reconciled with #4607; loadEnvFile aligned to getDefaultDataDir)

* chore(quality): reconcile file-size baseline for #4644 (deepseek-web.ts 1117->1125) (#4695)

file-size reconcile for #4644

* Support quota scraping for OpenCode Go and Ollama Cloud (#4642)

Integrated into release/v3.8.34 (Ollama Cloud + OpenCode Go dashboard quota scraping; rebuilt onto tip, gates green: typecheck/public-creds/file-size/lint/docs-sync + 31 tests)

* feat(executors): land M365 Copilot pure framing + connection helpers (#4042) (#4696)

Land M365 pure modules ahead of draft #4400

* deps: bump production + development groups; migrate js-yaml to v5 ESM (#4697)

Incorporates Dependabot #4667 + #4668 + js-yaml v5 ESM migration into release/v3.8.34

* fix: noAuth provider validation + kimi executor routing (#4699)

Integrated into release/v3.8.34 (noAuth in NOAUTH_PROVIDERS dynamic check + remove misrouted kimi web alias; 9 tests)

* refactor(imageGeneration): extract 8 provider families to co-located files (#4609)

Integrated into release/v3.8.34 (extraction completed: added missing imports/exports per module, main imports handlers locally; 145 image-gen tests pass, typecheck/cycles/file-size green)

* chore(release): v3.8.34 — finalize changelog, rebaseline drift, fix release-green reds

- Finalize CHANGELOG [3.8.34] (43 bullets, full contributor attribution) + seed i18n mirrors
- Rebaseline inherited cycle drift surfaced by release-green pre-flight: eslint warnings
  3900->3907, cognitive-complexity 797->801 (release-finalize touches no prod code; all
  drift is from this cycle's contributor merges)
- fix(providers): keep reka-flash-3 as the Reka provider default. #4621 inserted reka-flash
  at the head of the model list, silently changing the default from reka-flash-3 (the
  free-tier model) to reka-flash; reorder so reka-flash-3 stays default, reka-flash retained.
- test: align provider-models-config / provider-models-route / web-cookie-providers-new with
  #4621 (reka-flash now in the Reka catalog) and #4699 (the `kimi` API-key provider correctly
  falls through to DefaultExecutor instead of KimiWebExecutor)
- chore(quality): allowlist the COMPRESSION_GUIDE doc name in check-fabricated-docs
  (false-positive env-var match; docs/compression/COMPRESSION_GUIDE.md exists)

* fix(release-green): resolve release-PR full-CI reds for v3.8.34

Surfaced only on the release PR (these gates don't run on PR->release fast-gates):

- fix(quota): complete HTML-comment sanitization in opencodeOllamaUsage SSR reset-time
  parsing — strip any <!--...--> generically instead of the two literal React hydration
  markers, so no partial "<!--" can survive (CodeQL js/incomplete-multi-character-
  sanitization, HIGH, introduced by #4642). Regression test added.
- test(codex): correct the Codex-fingerprint body key order assertion to match the
  canonical bodyFieldOrder (prompt_cache_key precedes include); #4584 flipped the two
  and integration tests don't run on fast-gates so it never executed until the release PR.
- chore(quality): rebaseline inherited cycle drift surfaced by full CI —
  zizmorFindings 152->155 (+3 unpinned-uses in nightly-release-green.yml from #4622,
  same @vN convention as ci.yml) and openapiCoverage.pct 38.4->37.8 (-0.6, contributor
  routes added faster than openapi docs). Release-finalize touches no prod routes.

* fix(release-green): complete CodeQL sanitization + rebaseline complexity drift

- fix(quota): handle unterminated HTML comments in opencodeOllamaUsage SSR reset-time
  parsing — the `(?:-->|$)` arm consumes a trailing "<!--" with no closing "-->", so no
  partial "<!--" can survive (CodeQL js/incomplete-multi-character-sanitization persisted
  with the plain <!--...--> form because an unclosed comment could still leave "<!--").
- chore(quality): rebaseline cyclomatic complexity 1915->1916 (+1) — inherited v3.8.34
  cycle drift (contributor feature branches); check:complexity does not run on PR->release
  fast-gates so it surfaced only on the release PR. Release-finalize adds 0 complexity
  (measured 1916 with/without the regex tweak). dead-code/cognitive/type-coverage/
  compression-budget/codeql ratchets all pass.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Abhishek Divekar <adivekar@utexas.edu>
Co-authored-by: Rahul sharma <sharmaR0810@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
Co-authored-by: Ronald Estacion <DevEstacion@users.noreply.github.com>
Co-authored-by: Igor <60442260+BugsBag@users.noreply.github.com>
Co-authored-by: Oonishi <275808243+ponkcore@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
2026-06-23 03:08:29 -03:00

3257 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
toClientAntigravityQuotaModelId,
} from "../config/antigravityModelAliases.ts";
import { isUserCallableAgyModelId } from "../config/agyModels.ts";
import { buildCodexUsageQuotas } from "./codexUsageQuotas.ts";
import { getGlmQuotaUrl } from "../config/glmProvider.ts";
import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts";
import { safePercentage } from "@/shared/utils/formatting";
import { getDbInstance } from "@/lib/db/core";
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts";
import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.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",
};
// 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;
quotaSource?: "retrieveUserQuota" | "fetchAvailableModels" | "localUsageHistory";
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 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 isKiroOverageEnabled(data: JsonRecord): boolean {
const overageConfiguration = toRecord(data.overageConfiguration);
const overageStatus = String(overageConfiguration.overageStatus || "")
.trim()
.toUpperCase();
return (
overageStatus === "ENABLED" ||
data.overageEnabled === true ||
overageConfiguration.overageEnabled === true
);
}
function buildKiroQuota(
used: number,
total: number,
resetAt: string | null,
overageEnabled: boolean
): UsageQuota {
const remaining = total - used;
if (!overageEnabled) {
return { used, total, remaining, resetAt, unlimited: false };
}
return {
used,
total,
remaining,
remainingPercentage: 100,
resetAt,
unlimited: true,
};
}
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 0100 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 0100 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;
}
/**
* Remaining-percentage for a GLM/z.ai TIME_LIMIT ("Monthly") quota. With an absolute
* monthly cap (`total > 0`) it is `remaining / total`. Coding plans that have no
* monthly cap (only 5-hour windows) report `total = 0`; in that case fall back to the
* percentage-derived remaining so "no monthly cap" renders as full/100% instead of a
* misleading 0% (#3580).
*/
export function glmMonthlyRemainingPercentage(total: number, remaining: number): number {
if (total > 0) {
return Math.max(0, Math.min(100, Math.round((remaining / total) * 100)));
}
return Math.max(0, Math.min(100, Math.round(remaining)));
}
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 = glmMonthlyRemainingPercentage(total, remaining);
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) };
}
/**
* 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",
"kimi-coding-apikey",
"qwen",
"qoder",
"glm",
"glm-cn",
"zai",
"glmt",
"opencode-go",
"minimax",
"minimax-cn",
"crof",
"bailian-coding-plan",
"nanogpt",
"deepseek",
"opencode",
"opencode-zen",
"xiaomi-mimo",
"vertex",
"vertex-partner",
] 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(
provider,
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 "vertex":
case "vertex-partner":
return await getVertexUsage(id || "", provider);
case "kimi-coding":
return await getKimiUsage(accessToken);
case "kimi-coding-apikey":
return await getKimiUsage(undefined, apiKey);
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 || "", providerSpecificData);
case "ollama-cloud":
return await getOllamaCloudUsage(providerSpecificData);
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") {
// Numeric strings are Unix timestamps too (seconds or milliseconds).
// `new Date("1700000000")` otherwise returns Invalid Date.
if (/^\d+$/.test(resetValue)) {
const ts = Number(resetValue);
date = new Date(ts < 1e12 ? ts * 1000 : ts);
} else {
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
/**
* Normalize a Cloud Code project value into a trimmed string (or null).
* The upstream `loadCodeAssist` endpoint returns the project either as a bare
* string or as an object of the form `{ id: "..." }`, and stored connection
* project ids can carry stray whitespace. Centralized here so the Gemini CLI
* usage path matches the executor/oauth normalization already shipped in
* `open-sse/executors/gemini-cli.ts` and `src/lib/oauth/services/gemini.ts`.
*/
function normalizeCloudCodeProjectId(project: unknown): string | null {
if (typeof project === "string") return project.trim() || null;
if (project && typeof project === "object") {
const candidate = (project as { id?: unknown }).id;
if (typeof candidate === "string") return candidate.trim() || null;
}
return null;
}
/**
* 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 {
// #1271: the OAuth save path stores `projectId` on the connection (not always in
// `providerSpecificData`), and `loadCodeAssist` may return the project either as a
// bare string or wrapped in `{ id: "..." }`. Normalize both so the quota lookup
// reuses the stored project id and skips a redundant `loadCodeAssist` round-trip
// when it is already known.
let projectId =
normalizeCloudCodeProjectId(connectionProjectId) ||
normalizeCloudCodeProjectId(providerSpecificData?.projectId);
let plan = "Free";
if (!projectId) {
const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken);
projectId = normalizeCloudCodeProjectId(toRecord(subscriptionInfo).cloudaicompanionProject);
plan = getGeminiCliPlanLabel(subscriptionInfo);
}
if (!projectId) {
return {
plan,
message:
"Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.",
};
}
// 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 _antigravityUserQuotaCache = new Map<string, { data: unknown; fetchedAt: number }>();
const _antigravityUserQuotaInflight = 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 _antigravityUserQuotaCache) {
if (now - entry.fetchedAt > ANTIGRAVITY_MODELS_CACHE_TTL_MS)
_antigravityUserQuotaCache.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;
}
const ANTIGRAVITY_LOCAL_USAGE_WINDOW_MS = 5 * 60 * 60 * 1000;
const ANTIGRAVITY_LOCAL_USAGE_TOKENS_PER_UNIT = 1000;
// `toClientAntigravityQuotaModelId` was an inline if-ladder here; it is now the single
// source of truth in open-sse/config/antigravityModelAliases.ts (imported above), shared
// with the provider-limits cache sanitizer. (#3821-review LEDGER-5)
function getAntigravityLocalUsageUnits(
provider: "antigravity" | "agy",
connectionId: string | undefined,
modelId: string,
resetAt: string | null
): number {
if (!connectionId || !modelId || !resetAt) return 0;
const resetMs = Date.parse(resetAt);
if (!Number.isFinite(resetMs)) return 0;
const windowStart = new Date(resetMs - ANTIGRAVITY_LOCAL_USAGE_WINDOW_MS).toISOString();
const windowEnd = new Date(resetMs).toISOString();
try {
const db = getDbInstance() as unknown as {
prepare: (sql: string) => { get: (...params: unknown[]) => unknown };
};
const row = db
.prepare(
`SELECT COALESCE(SUM(
COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0)
), 0) AS tokens
FROM usage_history
WHERE provider = ?
AND connection_id = ?
AND model = ?
AND success = 1
AND timestamp >= ?
AND timestamp < ?`
)
.get(provider, connectionId, modelId, windowStart, windowEnd) as
| { tokens?: unknown }
| undefined;
const tokens = Number(row?.tokens || 0);
if (!Number.isFinite(tokens) || tokens <= 0) return 0;
return Math.max(1, Math.ceil(tokens / ANTIGRAVITY_LOCAL_USAGE_TOKENS_PER_UNIT));
} catch {
return 0;
}
}
function applyLocalUsageFallback(
quota: UsageQuota,
provider: "antigravity" | "agy",
connectionId: string | undefined,
modelId: string
): UsageQuota {
if (quota.quotaSource !== "fetchAvailableModels" || quota.used > 0 || quota.unlimited) {
return quota;
}
const localUsed = getAntigravityLocalUsageUnits(provider, connectionId, modelId, quota.resetAt);
if (localUsed <= 0 || quota.total <= 0) return quota;
const used = Math.min(quota.total, localUsed);
return {
...quota,
used,
remainingPercentage: Math.max(0, ((quota.total - used) / quota.total) * 100),
quotaSource: "localUsageHistory",
};
}
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;
}
async function fetchAntigravityUserQuotaCached(
accessToken: string,
projectId?: string | null,
options: AntigravityUsageOptions = {}
): Promise<unknown | null> {
if (!accessToken || !projectId) return null;
const cacheKey = buildAntigravityUsageCacheKey(accessToken, projectId);
const cached = _antigravityUserQuotaCache.get(cacheKey);
if (
!options.forceRefresh &&
cached &&
Date.now() - cached.fetchedAt < ANTIGRAVITY_MODELS_CACHE_TTL_MS
) {
return cached.data;
}
const inflight = _antigravityUserQuotaInflight.get(cacheKey);
if (inflight) return inflight;
const promise = (async () => {
try {
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 null;
const data = await response.json();
_antigravityUserQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() });
return data;
} catch {
return null;
}
})().finally(() => {
_antigravityUserQuotaInflight.delete(cacheKey);
});
_antigravityUserQuotaInflight.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.
* fetchAvailableModels is catalog/eligibility data and may keep reporting full buckets
* after real usage. retrieveUserQuota is the consumption signal for Gemini-family
* buckets, so prefer it when present and fall back to fetchAvailableModels only for
* models that have no retrieveUserQuota entry (for example Claude/GPT OSS buckets).
*/
async function getAntigravityUsage(
provider: "antigravity" | "agy",
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, userQuotaData] = await Promise.all([
fetchAntigravityAvailableModelsCached(accessToken, projectId, options),
fetchAntigravityUserQuotaCached(accessToken, projectId, options),
]);
const dataObj = toRecord(data);
if (dataObj.__antigravityForbidden === true) {
return { message: "Antigravity access forbidden. Check subscription." };
}
const modelEntries = toRecord(dataObj.models);
const userQuotaEntries = new Map<string, JsonRecord>();
const userQuotaObj = toRecord(userQuotaData);
if (Array.isArray(userQuotaObj.buckets)) {
for (const bucketValue of userQuotaObj.buckets) {
const bucket = toRecord(bucketValue);
const modelId = toClientAntigravityQuotaModelId(String(bucket.modelId || "").trim());
if (!modelId) continue;
userQuotaEntries.set(modelId, bucket);
}
}
const quotas: Record<string, UsageQuota> = {};
// Parse per-model quota info from fetchAvailableModels response.
for (const [rawModelKey, infoValue] of Object.entries(modelEntries)) {
const info = toRecord(infoValue);
const quotaInfo = toRecord(info.quotaInfo);
const modelKey = toClientAntigravityQuotaModelId(rawModelKey);
// Skip internal, excluded, and models without quota info
if (
!modelKey ||
info.isInternal === true ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
: isUserCallableAntigravityModelId(modelKey)) ||
Object.keys(quotaInfo).length === 0
) {
continue;
}
const liveQuota = userQuotaEntries.get(modelKey);
const quotaSource = liveQuota || quotaInfo;
const rawFraction = toNumber(quotaSource.remainingFraction, -1);
const resetAt = parseResetTime(quotaSource.resetTime);
// Distinguish "upstream did not report remainingFraction" from "remaining is 0%".
// fetchAvailableModels is a catalog view and can be stale/full; retrieveUserQuota is
// the source of truth for actual Gemini consumption when it includes the model.
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] = applyLocalUsageFallback(
{
used,
total: isUnlimited ? 0 : total,
resetAt,
remainingPercentage: isUnlimited ? 100 : remainingPercentage,
unlimited: isUnlimited,
fractionReported,
quotaSource: liveQuota ? "retrieveUserQuota" : "fetchAvailableModels",
},
provider,
connectionId,
modelKey
);
}
// Include retrieveUserQuota buckets not listed in the static/public Antigravity catalog yet.
// This keeps Provider Limits honest when Google adds a new Gemini tier before our catalog is
// updated. Hidden/internal catalog entries above are still filtered by the public pass.
for (const [modelKey, bucket] of userQuotaEntries) {
if (
quotas[modelKey] ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
: isUserCallableAntigravityModelId(modelKey))
) {
continue;
}
const rawFraction = toNumber(bucket.remainingFraction, -1);
if (rawFraction < 0) continue;
const remainingFraction = Math.max(0, Math.min(1, rawFraction));
const resetAt = parseResetTime(bucket.resetTime);
const isUnlimited = !resetAt && remainingFraction >= 1;
const QUOTA_NORMALIZED_BASE = 1000;
const total = QUOTA_NORMALIZED_BASE;
const remaining = Math.round(total * remainingFraction);
quotas[modelKey] = {
used: isUnlimited ? 0 : Math.max(0, total - remaining),
total: isUnlimited ? 0 : total,
resetAt,
remainingPercentage: isUnlimited ? 100 : remainingFraction * 100,
unlimited: isUnlimited,
fractionReported: true,
quotaSource: "retrieveUserQuota",
};
}
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();
const { rateLimit, quotas } = buildCodexUsageQuotas(data);
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}` };
}
}
/**
* Build the Kiro usage result from a GetUsageLimits response. When the account returns no
* usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via
* GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the
* dashboard renders a blank quota card with no explanation (#3506). Exported for testing.
*/
export function buildKiroUsageResult(
data: JsonRecord
): { plan: string; quotas: Record<string, UsageQuota> } | { message: string } {
const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : [];
const quotaInfo: Record<string, UsageQuota> = {};
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
const overageEnabled = isKiroOverageEnabled(data);
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] = buildKiroQuota(used, total, resetAt, overageEnabled);
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`] = buildKiroQuota(
freeUsed,
freeTotal,
resetAt,
overageEnabled
);
}
});
if (Object.keys(quotaInfo).length === 0) {
return {
message:
"Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.",
};
}
return {
plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro",
quotas: quotaInfo,
};
}
/**
* Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for
* AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the
* region-matched endpoint and prefers a profile whose ARN is in the same region. Returns
* undefined when no profile is available (e.g. the org/token has no Kiro entitlement).
* Exported for testing.
*/
export async function discoverKiroProfileArn(
accessToken: string,
usageBaseUrl: string,
region: string
): Promise<string | undefined> {
try {
const response = await fetch(usageBaseUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
Accept: "application/json",
},
body: JSON.stringify({ maxResults: 10 }),
// Don't let a hung profile lookup block the usage/quota refresh indefinitely.
signal: AbortSignal.timeout(10000),
});
if (!response.ok) return undefined;
const data = toRecord(await response.json());
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
const normalizedRegion = region.toLowerCase();
const matched =
profiles.find((profile: unknown) => {
const arn = toRecord(profile).arn;
return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`);
}) || profiles[0];
const arn = toRecord(matched).arn;
return typeof arn === "string" && arn.length > 0 ? arn : undefined;
} catch {
return undefined;
}
}
/**
* Kiro (AWS CodeWhisperer) Usage
*/
async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
try {
let profileArn =
typeof providerSpecificData?.profileArn === "string"
? providerSpecificData.profileArn
: undefined;
// Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and
// endpoint must all match the region. Derive the region from the stored region (preferred)
// or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the
// legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions).
const regionFromArn = profileArn
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
: undefined;
const region =
(typeof providerSpecificData?.region === "string" &&
providerSpecificData.region.trim().toLowerCase()) ||
regionFromArn ||
"us-east-1";
const usageBaseUrl =
region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`;
// IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which
// previously caused the quota card to show nothing ("0 used"). Discover it on demand from
// ListAvailableProfiles (region-matched) so usage still resolves for those accounts.
if (!profileArn && accessToken) {
profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region);
}
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(usageBaseUrl, {
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) {
// Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider
// Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits
// routinely rejects with 401/403, even when /messages still works. Surface a clear
// "auth expired, chat may still work" message instead of a generic upstream-error blob
// so the quota card matches what users with legacy social-auth accounts already see.
// Inspired by https://github.com/decolua/9router/pull/620.
if (
(response.status === 401 || response.status === 403) &&
isSocialAuthKiroAccount(providerSpecificData)
) {
return {
message: "Kiro quota API authentication expired. Chat may still work.",
quotas: {},
};
}
const errorText = await response.text();
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
}
const data = toRecord(await response.json());
return buildKiroUsageResult(data);
} catch (error) {
throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
}
}
/**
* Was this Kiro connection added via the Google/GitHub social-auth device flow
* (POST /api/oauth/kiro/social-exchange)? That route persists
* `{ authMethod: "imported", provider: "Google" | "Github" }` on the connection.
* Builder-ID / IDC / kiro-cli imports use different markers and should keep the
* existing throw-on-failure behavior.
*/
function isSocialAuthKiroAccount(providerSpecificData?: JsonRecord): boolean {
if (!providerSpecificData || providerSpecificData.authMethod !== "imported") return false;
const provider =
typeof providerSpecificData.provider === "string"
? providerSpecificData.provider.toLowerCase()
: "";
return provider === "google" || provider === "github";
}
/**
* Vertex AI — SELF-TRACKED spend.
*
* Vertex AI exposes no usage/quota API for an API key or Service Account (billing/credit balance
* lives behind the Cloud Billing API, which the proxy credential can't reach). Instead we report
* the USD that OmniRoute has spent through this connection since the account was added — summed
* from `usage_history` and priced via the backend pricing table. Returns a `message` (with the $
* figure) plus a `spend` quota entry so the limits cache persists it (a message-only result is
* treated as a transient error and not cached).
*/
async function getVertexUsage(connectionId: string, provider: string) {
if (!connectionId) {
return { message: "Vertex connected. Connection id unavailable for usage tracking." };
}
try {
const { getConnectionSpendUsdSinceAdded } = await import("@/lib/usage/usageStats");
const { costUsd, requests } = await getConnectionSpendUsdSinceAdded(provider, connectionId);
const spend: JsonRecord = {
used: Number(costUsd.toFixed(6)),
displayName: "Spend (USD)",
quotaSource: "localUsageHistory",
resetAt: null,
unlimited: false,
};
if (requests === 0) {
return {
plan: "Vertex AI",
message: "Vertex connected. No usage recorded through OmniRoute yet for this account.",
quotas: { spend },
};
}
const costStr = costUsd >= 1 ? costUsd.toFixed(2) : costUsd.toFixed(4);
return {
plan: "Vertex AI",
message: `$${costStr} used since this account was added \u00b7 ${requests} request${
requests === 1 ? "" : "s"
}`,
quotas: { spend },
};
} catch (error) {
return { message: `Vertex usage tracking error: ${(error as 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, apiKey?: 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";
// API key auth takes precedence — Kimi's /usages endpoint accepts the same
// API key used for /messages (verified live: responds with
// authentication.method = METHOD_API_KEY). OAuth flow falls through to the
// Bearer + device-headers shape used by Kimi Coding OAuth.
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
const authHeaders: Record<string, string> = useApiKey
? { "x-api-key": apiKey as string }
: {
Authorization: `Bearer ${accessToken}`,
"X-Msh-Platform": platform,
"X-Msh-Version": version,
"X-Msh-Device-Model": deviceModel,
"X-Msh-Device-Id": deviceId,
};
try {
const response = await fetch(KIMI_CONFIG.usageUrl, {
method: "GET",
headers: {
...authHeaders,
"Content-Type": "application/json",
},
});
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,
getVertexUsage,
getMiniMaxAuthErrorMessage,
getMiniMaxErrorSummary,
mapCodeAssistSubscriptionToPlanLabel,
mapCodeAssistTierIdToLabel,
mapSubscriptionTierStringToPlanLabel,
toDisplayLabel,
getKiroUsage,
};