Files
OmniRoute/open-sse/services/usage.ts
Diego Rodrigues de Sa e Souza f59f8daa94 Release v3.8.6 (#2804)
* fix(gemini): preserve structured tool calls for antigravity

* fix(gemini): parse prefixed textual tool calls

* fix(antigravity): preserve textual SSE tool calls

* fix(stream): normalize textual passthrough tool calls

* fix(stream): normalize split textual tool calls

* fix(stream): suppress malformed textual tool calls

* fix(stream): suppress compact malformed tool calls

* fix(stream): emit structured textual tool calls

* fix(stream): suppress unknown textual tool calls

* fix(stream): normalize responses textual tool calls

* chore: ignore .claude/settings.local.json (per-user Claude Code permissions)

* fix(opencode-go): route qwen3.x via claude messages + repair fixMissingToolResponses for Claude-shape upstreams (#2791)

Integrated into release/v3.8.6

* fix: resolve npm install warnings — remove dead deps, relax engine constraint (#2792)

Integrated into release/v3.8.6

* fix: register missing web-cookie validators (claude-web, gemini-web, copilot-web, t3-web) (#2793)

Integrated into release/v3.8.6

* fix: Error: Unable to inspect existing database #2771 (#2795)

Integrated into release/v3.8.6

* fix(oauth): repair Google loopback callback flow (#2796)

Integrated into release/v3.8.6

* feat(logs): add clean history button (#2799)

Integrated into release/v3.8.6

* [codex] home: restore settings-driven home layout and quota auto-refresh (#2800)

Integrated into release/v3.8.6

* fix(gemini): emit signaturelessToolCallMode:text for GEMINI format models (#2801)

Integrated into release/v3.8.6

* feat(modelSpecs): align opencode-go family with upstream provider limits (#2802)

Integrated into release/v3.8.6

* chore: apply unit test fixes, polyfills, and environment precedence fixes

* docs(agents): atualiza fluxos de release e triagem

Expande os workflows de release para incluir auditoria de segurança,
CHANGELOG completo por commits, quality gate obrigatório, homologação em
VPS local, publicação oficial, deploy em Akamai e validação de artefatos.

Reorganiza a triagem de features com arquivos permanentes por bucket,
suporte a itens em andamento, regra de reclaim após 15 dias e novo
tratamento para ideias viáveis catalogadas.

Corrige a orientação de revisão de discussões para usar a ordem
cronológica real dos comentários e respostas ao identificar a última
atividade.

* fix(lockout): classify Gemini Antigravity resource exhaustion as quota_exhausted

* fix(reasoning): gate replay by interleaved field

* docs(rule-16): permit human Co-authored-by, restrict only AI/bot trailers

Rule #16 previously banned all `Co-Authored-By` trailers absolutely.
That blocked the upstream-port workflows (`/port-upstream-features` and
`/port-upstream-issues`), which must credit human upstream PR authors
and issue reporters in OmniRoute commits.

Refine the rule to ban only AI/bot-attributed trailers (Claude, GPT,
Copilot, Bot; anthropic.com / openai.com / bot-owned noreply.github.com
emails) while allowing standard human `Co-authored-by: Name <email>`
attribution.

Sync the rule across the source CLAUDE.md, the E2E shakedown doc note,
and 41 i18n translations.

* fix(gitlawb): add specialty validators for connection test — bypass /models probe

GitLawB OpenGateway API (xiaomi-mimo compatible) does not expose a /models
endpoint, causing validateOpenAILikeProvider to 404 on the initial probe
and report 'Provider validation endpoint not supported'.

Add specialty validators for both gitlawb and gitlawb-gmi that follow the
same pattern as the existing xiaomi-mimo validator: skip GET /models,
validate directly via POST /chat/completions with a minimal test message.
Any 401/403 response means an invalid key; all other responses mean auth
is OK.

Fixes test-connection returning 404 for GitLawB providers.

* test(gitlawb): add 12 unit tests for gitlawb and gitlawb-gmi specialty validators

Covers success, auth failure (401/403), non-auth acceptance (400/422/429),
network errors, and custom baseUrl overrides for both providers.

* feat(gitlawb): serve models from static registry without API-unavailable warning

GitLawB's OpenGateway API does not expose a /models endpoint per
provider-path. Previously the models route fell through to the generic
fallback which returned static catalog models with the misleading
'API unavailable — using local catalog' warning.

Now gitlawb and gitlawb-gmi are handled as static model providers
(same pattern as reka and qwen OAuth) — models are served from the
provider registry without any warning, since all registered models
are functional via POST /chat/completions.

* refactor(gitlawb): extract shared opengateway validator factory, fix docs path in test

- Extract gitlawb/gitlawb-gmi validators into buildOpengatewayValidator factory
- Fix dockerignore-docs-coverage test: update stale docs/AUTO-COMBO.md -> docs/routing/AUTO-COMBO.md

* fix(reasoning): guard interleaved capability lookup

* feat(gitlawb): dynamic model fetch with gmi-cloud fallback

Hybrid approach:
- gitlawb (xiaomi-mimo): dynamic /models endpoint → 356 models
- gitlawb-gmi (gmi-cloud): 404 fallback → local catalog gracefully
Mimics Gitlawb/openclaude's model-routing pattern

* i18n(pt-BR): complete missing translations and sync with en.json

* feat(build): nix multi-OS package manager install (#2806)

Integrated into release/v3.8.6

* fix(i18n): translate 144 new __MISSING__ pt-BR strings (#2816)

Integrated into release/v3.8.6

* chore(docs): set coverage gate to 40/40/40/40 in CLAUDE.md

Aligns the documented coverage gate with the v3.8.6 release decision
(lowered from 75/75/75/70). Matches the threshold already set in
package.json by the large feature PRs (planos 11-22).

* fix(cli): respect PORT env var in serve command (#2845)

Integrated into release/v3.8.6.

* fix(deepseek-web): return 400 when client sends tools[] - chat.deepseek.com has no tool support (#2854)

Integrated into release/v3.8.6.

* fix(qoder): reject invalid/expired PATs returning Cosy 500 error (#2860)

Integrated into release/v3.8.6.

* fix(cli): register openclaw in tool-detector (#2833) (#2850)

Integrated into release/v3.8.6.

* fix(api): include noAuth providers in /v1/models catalog (#2798) (#2814)

Integrated into release/v3.8.6.

* fix(combo): resolve custom provider targets via combo name (#2778) (#2812)

Integrated into release/v3.8.6.

* fix(translator): strip safety_identifier in openai-responses cleanup (#2770) (#2809)

Integrated into release/v3.8.6.

* fix(quota): honor explicit per-connection preflight opt-out (#2831) (#2844)

Integrated into release/v3.8.6.

* fix(usage): un-invert GitHub Copilot Free/limited quota — limited_user_quotas is remaining (#2876) (#2881)

Integrated into release/v3.8.6.

* fix(nous-research): correct baseUrl to include /chat/completions (#2826) (#2835)

Integrated into release/v3.8.6.

* fix(opencode): qwen3.x max/plus models lack vision support (#2822) (#2836)

Integrated into release/v3.8.6.

* fix(translator): pass-through tool_search built-in tool type (#2766) (#2811)

Integrated into release/v3.8.6.

* fix(github): route claude-opus-4.6 via chat completions (#2821)

Integrated into release/v3.8.6.

* docs(oauth): add Windsurf login fix design (Phase 1 hotfix + Phase 2 Firebase OAuth)

Two-phase plan to fix the broken Windsurf OAuth flow:
- Phase 1: drop the dead app.devin.ai/editor/signin PKCE path, promote
  import-token from windsurf.com/show-auth-token as the primary path
- Phase 2: port Firebase OAuth + RegisterUser flow from
  fendoushaonian/WindSurf-gRPC-API for full browser-based automation

Spec only - no code changes yet.

* docs(plan): Phase 1 windsurf login hotfix implementation plan

10 tasks covering:
- TDD assertions for flowType + 410 Gone responses
- Provider switch to import_token
- Route handler retiring authorize/start-callback-server/poll-callback
- OAuthModal UI override
- i18n sync
- Verification + PR steps

* fix(cli): replace cli-table3 with hand-rolled formatter (#2752) (#2813)

Integrated into release/v3.8.6.

* fix(skills): skip interception for unregistered client-native tools (#2815) (#2817)

Integrated into release/v3.8.6.

* feat(sse): add RTK filters for kubectl, docker-build, composer, gh (#2824)

Integrated into release/v3.8.6.

* fix(geminiHelper): support rec.image content shape + warn on dropped remote URLs (refs #2807) (#2855)

Integrated into release/v3.8.6.

* fix(cli): allow nullable/optional apiKey in cliMitmStartSchema (#2857)

Integrated into release/v3.8.6.

* fix(combo): preserve system messages during context handoff summary generation (#2865)

Integrated into release/v3.8.6.

* fix: wire CLIProxyAPI fallback settings into chatCore routing engine (#2866)

Integrated into release/v3.8.6.

* fix(usage): add opencode quota fetcher (#2852) (#2867)

Integrated into release/v3.8.6.

* feat(claude): default xhigh support for newer Opus models (#2874)

Integrated into release/v3.8.6.

* fix(cli): restore omniroute logs command stream (#2756) (#2810)

Integrated into release/v3.8.6.

* fix(combo): normalize upstream Headers for Node 24 undici interop (#2751) (#2823)

Integrated into release/v3.8.6.

* Rename proxy log Public IP to Client IP (#2880)

Integrated into release/v3.8.6.

* fix(claude): preserve max effort for supported models (#2875)

Integrated into release/v3.8.6.

* fix(oauth): switch windsurf provider to import_token flow

The PKCE auth URL targeting app.devin.ai/editor/signin returns 404
post-rebrand. Until Phase 2 ports Firebase OAuth + RegisterUser, the
only supported path is import-token via windsurf.com/show-auth-token.

- windsurf.ts: drop buildAuthUrl, set flowType=import_token
- generateAuthData returns supported:false + helpful error for windsurf/devin-cli
- tests: assert flowType + disabled stub

* fix(oauth): return 410 Gone for retired windsurf/devin-cli PKCE actions

start-callback-server, authorize, and poll-callback (GET + POST) now
return 410 Gone with a pointer to /import-token. The 410 short-circuit
runs before auth so the response is honest about the action being
permanently gone, not gated. Codex PKCE flow unchanged.

Tests: 5 new assertions cover GET + POST 410 paths and a Codex
regression check.

* refactor(oauth): annotate retired PKCE fields in WINDSURF_CONFIG

No behaviour change - comment-only update documenting that authorizeUrl,
codeChallengeMethod, callbackPort, callbackPath, apiServerUrl, and
exchangePath are no longer consumed. Active fields (inferenceUrl,
showAuthTokenUrl, firebaseApiKey, ideName) called out separately.

* fix(cli,docs): use requireCliToolsAuth in logs route + document OPENCODE quota env

Post-merge contract fixes for v3.8.6:
- src/app/api/cli-tools/logs/route.ts (#2810) now uses the shared
  requireCliToolsAuth guard (param renamed req->request) to satisfy the
  cli-tools-auth-hardening contract test.
- Document OMNIROUTE_OPENCODE_QUOTA_URL (#2867) in docs/reference/ENVIRONMENT.md
  to satisfy the env/docs sync contract.

* fix(dashboard): force import-token panel for windsurf/devin-cli

Phase 1 hotfix: hide the 'Browser Login' tab and start in Paste API Key
mode. Removes windsurf/devin-cli from PKCE_CALLBACK_SERVER_PROVIDERS so
no callback server is started for them. Codex still uses the PKCE flow.

The 'Get token' link continues to point at windsurf.com/show-auth-token
via the existing supportsTokenPaste form copy.

* fix(oauth): windsurf import-token mapTokens signature mismatch

The route at `src/app/api/oauth/[provider]/[action]/route.ts` invokes
`providerData.mapTokens({ accessToken: token })` (object), matching the
cursor/kiro signature. The windsurf provider was declared with
`mapTokens(token: string)` instead, so the entire object was stored as
`accessToken`. When the connection record reached the SQLite layer it
crashed with:

  SQLite3 can only bind numbers, strings, bigints, buffers, and null

Fix by aligning windsurf's `mapTokens` signature with the route caller
and the cursor/kiro convention. Also dedupe a copy-pasted second
`if (action === "import-token")` block in the route handler — the
second block was unreachable but identical to the first.

Adds two regression tests asserting that
`provider.mapTokens({ accessToken })` returns a string `accessToken` for
both windsurf and devin-cli, so a future signature drift trips the gate
instead of the SQLite bind error in production.

* feat(compression): expand pt-BR pack with troglodita rules (15 → 49) (#2818)

Integrated into release/v3.8.6

* fix(sse): repair RTK engine defaults so dedup and direct calls work (#2825)

Integrated into release/v3.8.6

* fix(mcp): redirect console.log/warn to stderr in --mcp stdio mode (#2840)

Integrated into release/v3.8.6

* fix(gemini-cli): prefer real project IDs over default-project (#2841)

Integrated into release/v3.8.6

* fix(opencode-go): add provider limits quota fetcher (#2861)

Integrated into release/v3.8.6

* Audit & add web cookie providers: fix 4 missing registry entries + DuckDuckGo (#2862)

Integrated into release/v3.8.6

* fix(antigravity): harden signatureless tool history (#2878)

Integrated into release/v3.8.6

* fix: provider model sync pruning and dynamic antigravity MITM proxy mappings (#2886)

Integrated into release/v3.8.6

* feat(usage): per-API-key token limits scoped to model/provider/global (#2888)

Integrated into release/v3.8.6

* fix(audio): build multipart body manually to preserve Content-Type (#2842)

Integrated into release/v3.8.6

* refactor: remove agent skill documentation files and streamline maintenance workflows

* test(stabilization): resolve unit test failures in blackbox-web, schema-coercion, translator-helper-branches, usage-service-hardening, and audio-transcription

* fix(security): mitigate Socket.dev supply-chain findings + secrets opt-in + minimal build profile (#2863) (#2871)

Two real security gaps closed and four cosmetic Socket.dev fingerprints removed.
See docs/security/SOCKET_DEV_FINDINGS.md for the per-finding maintainer
attestation.

Real bugs fixed:
- cloudSync: HMAC verification of `X-Cloud-Sig` + opt-in
  `OMNIROUTE_CLOUD_SYNC_SECRETS=true` before overwriting `accessToken` /
  `refreshToken` / `providerSpecificData` from a remote response. Closes the
  silent-credential-swap surface (a misconfigured or hostile CLOUD_URL could
  previously replace local tokens unverified).
- Zed import: split into 2-step `/discover` + `/import` flow. `/import` now
  requires `confirmedAccounts: [{ service, account, fingerprint }]` and
  re-reads the keychain server-side to filter by fingerprint, so a tampered
  discover response cannot trick the endpoint into saving an unrelated token.

Cosmetic Socket.dev mitigations:
- runElevatedPowerShell writes the elevated payload to a per-call temp `.ps1`
  file (mode 0o600) and references it via `-File`. Removes the textbook
  `-EncodedCommand <base64utf16le>` pattern flagged as malware by Socket's AI
  classifier.
- Maintainer attestation `SECURITY-AUDITOR-NOTE:` blocks added at every
  flagged call site pointing to `docs/security/SOCKET_DEV_FINDINGS.md`.

Build-time hardening:
- `OMNIROUTE_BUILD_PROFILE=minimal` (`npm run build:secure`) physically
  removes the four sensitive modules from the standalone bundle via webpack
  `NormalModuleReplacementPlugin`. Stubs throw `FeatureDisabledError` at
  runtime. Intended for the `omniroute-secure` artifact.

Tests:
- 24 new unit tests in `tests/unit/security/` covering the wrapper builder,
  HMAC verification (4 cases), credential fingerprint determinism (5 cases),
  confirmedAccounts validation + fingerprint filtering (6 cases), and the
  minimal-build stubs (5 cases).

Docs:
- New `docs/security/SOCKET_DEV_FINDINGS.md` — per-finding attestation.
- New `socket.yml` — Socket.dev v2 config pointing at the attestation.
- Updated `SECURITY.md` — supply-chain scanner section.
- Updated `.env.example` — three new env vars documented.

Backwards compatibility:
- Cloud sync token overwrite is OFF by default. Users who relied on
  it must set `OMNIROUTE_CLOUD_SYNC_SECRETS=true`. Breaking change documented
  in CHANGELOG.
- Zed import 2-step is the new default; legacy 1-step preserved behind
  `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP=true` and will be removed in v3.9.

Closes #2863

* fix(security): redact public Firebase Web key from windsurf spec; doc SHA-256 cache-key rationale (#2894)

Two security-scanning findings on release/v3.8.6:

- Secret-scanning alert 7 (google_api_key): the windsurf login-fix design spec
  embedded the literal public Firebase Web API key on two lines. Firebase Web
  API keys are non-sensitive by design (they identify the project; access is
  gated by Firebase Security Rules + key restrictions), but the literal trips
  secret scanning. Redacted to a placeholder; the embedded default still goes
  through resolvePublicCred per rule #11.

- Code-scanning alert 261 (js/insufficient-password-hash): tokenCacheKey() uses
  SHA-256 to derive an in-memory cache key from the session token, not for
  password-at-rest storage. Added a comment documenting why CWE-916 KDFs do not
  apply (false positive).

* fix(ci): resolve release/v3.8.6 gate failures (docs-sync, any-budget, pack-artifact) (#2895)

* fix(ci): resolve release/v3.8.6 gate failures (docs-sync, any-budget, pack-artifact)

Three CI gates failed on release/v3.8.6 (run 26630300877):

- docs-sync: CHANGELOG had a spurious "## [3.8.6-patch]" section above
  "## [3.8.6]", so the latest release no longer matched package.json (3.8.6)
  and the 41 i18n CHANGELOG mirrors were flagged as missing that section.
  Fold the lone #2752 entry into [3.8.6] and drop the patch heading.
- any-budget:t11: open-sse/handlers/chatCore.ts regressed to 1 explicit `any`
  (budget 0). Type the persist callback arg as Record<string, unknown>, which
  matches runWithOnPersist's RefreshPersistFn contract exactly.
- pack-artifact: open-sse/utils/setupPolyfill.ts ships via package.json "files"
  (bin/omniroute.mjs imports it at startup) but was missing from the pack
  policy allowlist. Allow it and add a regression test.

* fix(security): redact public Firebase Web key from windsurf spec

Redact the literal public Firebase Web API key (secret-scanning #7) to a
placeholder, mirroring the redaction on release/v3.8.6 (PR #2894) and the
windsurf fix branch. Non-sensitive public Web key; trips secret scanning.

* feat(combo): Zero-Latency Combos (Hedging, Proactive Compression, Predictive TTFT) (#2868)

* feat(combo): implement zero-latency combo optimizations (hedging, proactive compression, predictive TTFT)

* fix(combo): fix predictive TTFT skip logic and unhandled promise rejections

---------

Co-authored-by: Automation <automation@omniroute>

* feat: implement automated skill workflows and update system configuration and validation schemas

* test: eliminate dynamic cast warnings in cloud-sync unit test

* test: isolate services-branch-hardening database directory to avoid concurrency issues

* feat(providers): add 7 new web-cookie providers + research catalog + discovery tool

New providers:
- huggingchat: free LLM chat via huggingface.co/chat (no subscription)
- phind: free dev-focused AI chat via phind.com/api/agent
- poe-web: multi-model chat via poe.com GraphQL (p-b cookie)
- venice-web: privacy-focused AI chat via venice.ai (session cookie)
- v0-vercel-web: Vercel v0 code gen via v0.dev (session cookie)
- kimi-web: Moonshot Kimi chat via kimi.moonshot.cn (session cookie)
- doubao-web: ByteDance Doubao chat via doubao.com (session cookie)

Additional:
- Research catalog: docs/research/UNLIMITED_LLM_ACCESS.md
- Discovery tool design + stub: src/lib/discovery/ + migration 073
- Unit tests: 33 tests for all 7 providers
- Shared helpers consolidated in error.ts (slop cleanup)
- All registered in WEB_COOKIE_PROVIDERS + providerRegistry + webSessionCredentials

Closes #2885

* fix(typecheck): resolve typecheck errors in combo spec and compression modules

* feat(api,oauth): add `agy` (Antigravity CLI) standalone provider with CLI token import (#2899)

Add a standalone OAuth provider `agy` (Antigravity CLI) next to gemini-cli/antigravity.
It reuses the antigravity inference backend (identical Google client_id +
daily-cloudcode-pa.googleapis.com endpoint, executor and token-refresh) but ships its own
model catalog — including the Claude models the backend exposes (claude-opus-4-6-thinking,
claude-sonnet-4-6) — its own account pool, and four ways to connect:

- token-file import (paste/upload the agy oauth token JSON)
- auto-detect a local CLI login (~/.gemini/antigravity-cli/antigravity-oauth-token)
- browser OAuth (via the shared OAuthModal Google loopback flow)
- bulk / ZIP import

New routes: POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}.
Catalog pinned from the live :fetchAvailableModels endpoint. Docs (openapi.yaml,
ENVIRONMENT.md, .env.example, CHANGELOG) updated; new unit tests for registration,
the token parser, and route auth-hardening.

* fix(security): redact public Firebase Web key from windsurf spec (#2896)

Redact the literal public Firebase Web API key (secret-scanning #7) to a
placeholder. Firebase Web API keys are non-sensitive by design but the literal
trips GitHub secret scanning. Mirrors the redaction landed on release/v3.8.6
(PR #2894). Embedded default still flows through resolvePublicCred (rule #11).

* Pr 2871 (#2897)

* fix(security): mitigate Socket.dev supply-chain findings + secrets opt-in + minimal build profile (#2863)

Two real security gaps closed and four cosmetic Socket.dev fingerprints removed.
See docs/security/SOCKET_DEV_FINDINGS.md for the per-finding maintainer
attestation.

Real bugs fixed:
- cloudSync: HMAC verification of `X-Cloud-Sig` + opt-in
  `OMNIROUTE_CLOUD_SYNC_SECRETS=true` before overwriting `accessToken` /
  `refreshToken` / `providerSpecificData` from a remote response. Closes the
  silent-credential-swap surface (a misconfigured or hostile CLOUD_URL could
  previously replace local tokens unverified).
- Zed import: split into 2-step `/discover` + `/import` flow. `/import` now
  requires `confirmedAccounts: [{ service, account, fingerprint }]` and
  re-reads the keychain server-side to filter by fingerprint, so a tampered
  discover response cannot trick the endpoint into saving an unrelated token.

Cosmetic Socket.dev mitigations:
- runElevatedPowerShell writes the elevated payload to a per-call temp `.ps1`
  file (mode 0o600) and references it via `-File`. Removes the textbook
  `-EncodedCommand <base64utf16le>` pattern flagged as malware by Socket's AI
  classifier.
- Maintainer attestation `SECURITY-AUDITOR-NOTE:` blocks added at every
  flagged call site pointing to `docs/security/SOCKET_DEV_FINDINGS.md`.

Build-time hardening:
- `OMNIROUTE_BUILD_PROFILE=minimal` (`npm run build:secure`) physically
  removes the four sensitive modules from the standalone bundle via webpack
  `NormalModuleReplacementPlugin`. Stubs throw `FeatureDisabledError` at
  runtime. Intended for the `omniroute-secure` artifact.

Tests:
- 24 new unit tests in `tests/unit/security/` covering the wrapper builder,
  HMAC verification (4 cases), credential fingerprint determinism (5 cases),
  confirmedAccounts validation + fingerprint filtering (6 cases), and the
  minimal-build stubs (5 cases).

Docs:
- New `docs/security/SOCKET_DEV_FINDINGS.md` — per-finding attestation.
- New `socket.yml` — Socket.dev v2 config pointing at the attestation.
- Updated `SECURITY.md` — supply-chain scanner section.
- Updated `.env.example` — three new env vars documented.

Backwards compatibility:
- Cloud sync token overwrite is OFF by default. Users who relied on
  it must set `OMNIROUTE_CLOUD_SYNC_SECRETS=true`. Breaking change documented
  in CHANGELOG.
- Zed import 2-step is the new default; legacy 1-step preserved behind
  `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP=true` and will be removed in v3.9.

Closes #2863

* feat: implement automated skill workflows and update system configuration and validation schemas

* test: eliminate dynamic cast warnings in cloud-sync unit test

* test: isolate services-branch-hardening database directory to avoid concurrency issues

* chore(docs): refresh generated docs collection index

Update the generated Fumadocs browser collection mapping to keep
documentation imports in sync with the current docs structure.

* docs: update generated browser docs collection manifest

Refresh the generated Fumadocs browser collection mapping so the docs site can resolve the current documentation files correctly.

---------

Co-authored-by: OpenClaw <openclaw@kuzhomesrv.local>
Co-authored-by: Dmitry Kuznetsov <139351986+dmitry@users.noreply.local>
Co-authored-by: KuzyaBot <kuzya@local>
Co-authored-by: JeferssonLemes <jeferssondev@gmail.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: akarray <akarray@users.noreply.github.com>
Co-authored-by: Apostol Apostolov <theapoapostolov@gmail.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: Dmitry Kuznetsov <dmitry@kuznetsov.me>
Co-authored-by: Nikolay Alafuzov <alafuzov_nn@rusklimat.ru>
Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: Ronaldo Davi <alltomatos@users.noreply.github.com>
Co-authored-by: levonk <277861+levonk@users.noreply.github.com>
Co-authored-by: Lenine Júnior <lenine@engrene.com.br>
Co-authored-by: Annas Alghoffar <aag.annas@gmail.com>
Co-authored-by: Tushar Agarwal <76201310+Tushar49@users.noreply.github.com>
Co-authored-by: GreatLiu <eurasiaxz@qq.com>
Co-authored-by: yuna amelia <230527278+yunaamelia@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Container <78986709+disonjer@users.noreply.github.com>
Co-authored-by: nickwizard <35692452+nickwizard@users.noreply.github.com>
Co-authored-by: Rajvardhan Patil <rajvardhanpatil7890@gmail.com>
Co-authored-by: Raxxoor <manker_lol@hotmail.com>
Co-authored-by: Muhammad Mugni Hadi <mugnimaestra3@gmail.com>
Co-authored-by: mi <123757457+soyelmismo@users.noreply.github.com>
Co-authored-by: Automation <automation@omniroute>
2026-05-29 12:44:29 -03:00

2921 lines
94 KiB
TypeScript

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