mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
* chore(release): open v3.8.36 development cycle * refactor(chatCore): extrai resolveCompressionSettings (#3501) (#4826) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 1/13) * refactor(chatCore): extrai predicados puros de combo de compressão (#3501) (#4824) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 2/13) * refactor(chatCore): extrai emitOutputStyleTelemetry (#3501) (#4811) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 3/13) * refactor(chatCore): extrai writeCompressionAnalytics (bloco analytics completo, #3501) (#4817) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 4/13) * refactor(chatCore): extrai runPluginOnRequestHook (#3501) (#4827) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 5/13) * refactor(chatCore): extrai applyClientUsageBuffer (buffer/estimate de usage non-streaming, #3501) (#4832) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 6/13) * refactor(chatCore): extrai buildPostCallGuardrailContext (contexto guardrail post-call, #3501) (#4831) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 7/13) * refactor(chatCore): extrai storeSemanticCacheResponse (cache-store non-streaming, #3501) (#4828) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 8/13) * refactor(chatCore): extrai buildNonStreamingResponseHeaders (headers de resposta non-streaming, #3501) (#4835) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 9/13) * refactor(chatCore): extrai maybeConvertJsonBodyToSse (#3089 JSON→SSE streaming, #3501) (#4833) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 10/13) * refactor(chatCore): extrai assembleStreamingResponseHeaders (headers de resposta streaming, #3501) (#4836) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 11/13) * refactor(chatCore): extrai storeStreamingSemanticCacheResponse (cache-store streaming, #3501) (#4829) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 12/13) * refactor(chatCore): extrai assembleStreamingPipeline (chain de transforms streaming, #3501) (#4837) Integrated into release/v3.8.36 (#3501 chatCore extraction stack 13/13) * ci(quality): shift heavy validations to the PR→release fast-path (release-acceleration) (#4857) * feat(quality): add check:test-runner-api gate (vitest-only dirs must use vitest API) * feat(release): reusable CHANGELOG i18n-mirror sync script * chore(ops): add prune-stale-worktrees.sh (dry-run by default) * ci(quality): run test-runner-api + docs-all + vitest + full unit suite on PR->release fast-path --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(quota): cota exclusiva lista qtSd/ no /v1/models (#4806) + limite EPSILON não bloqueia (#4830) Integrated into release/v3.8.36 — quota-exclusive qtSd/ listing (#4806) + EPSILON placeholder no longer blocks; rebuilt from stale base (3 defining commits cherry-picked clean over release tip) * feat(sse): add Google Flow video-generation provider (#4569) (#4769) Integrated into release/v3.8.36 — Google Flow video-generation provider (#4569), release-green validated (typecheck + 21 tests + file-size) * fix(api): auth on compression run-telemetry + document OMNIROUTE_EVAL_CREDENTIALS (#4694, #4720) (#4796) Integrated into release/v3.8.36 — auth on compression run-telemetry + OMNIROUTE_EVAL_CREDENTIALS doc, release-green validated (typecheck + 3 tests + env-doc-sync) * fix(translator): strip top-level client_metadata on the OpenAI passthrough (port from 9router#1157) (#4624) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(translator): normalize `developer` role to `system` for OpenAI-format providers (#4625) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(translator): emit </think> close marker for Anthropic thinking blocks (#4633) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(translator): normalize tools to Anthropic-native shape for non-Anthropic providers (#4650) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(gemini): preserve `pattern` in antigravity tool schema sanitizer (#4651) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(perplexity): validate API keys via /v1/models endpoint (#4654) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(image): prevent compatible nodes from shadowing provider aliases (#4656) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(cli-tools): tolerate JSONC (comments, trailing commas) in tool settings (#4659) Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated) * fix(security): validate kiro region to prevent SSRF (GHSA-6mwv-4mrm-5p3m) (#4629) Integrated into release/v3.8.36 — kiro region SSRF guard (GHSA-6mwv-4mrm-5p3m), port rebuilt clean over release tip * fix(cli): harden the systray2 tray runtime (port of 9router#1080) (#4628) Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated * fix(test): validate anthropic-compatible connections via POST /v1/messages (#4657) Integrated into release/v3.8.36 — anthropic-compat validation via POST /v1/messages (port 584cf66a), rebuilt clean + baseline; release-green * fix(executors): strip params unsupported by the target provider/model (#4658) Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated * fix(claude-oauth): respect 429 backoff on usage endpoint to reduce spam (#4655) Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated * feat(api/v1): include alias-backed models in /v1/models listing (#4630) Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated * chore(quality): rebaseline catalog.ts 1574->1577 (#4630 aliases sobre quota-exclusive da release) (#4879) rebaseline * feat(compression): Kiro/CodeWhisperer tool-result compression engine (#4635) Integrated into release/v3.8.36 — port rebuilt clean, release-green * fix(security): don't trust loopback socket as local when behind reverse proxy (#4632) Integrated into release/v3.8.36 — port rebuilt clean, release-green * fix(opencode): preserve DeepSeek reasoning content in streamed responses (#4631) Integrated into release/v3.8.36 — DeepSeek reasoning_content injection (port #1099); release-green * fix(copilot,antigravity): cap maxOutputTokens at 16384 to stop "Invalid Argument" 400 (#4636) Integrated into release/v3.8.36 — cap maxOutputTokens 16384 antigravity (port #779); release-green * fix(dashboard): show custom vision models in LLM selector (#4653) Integrated into release/v3.8.36 — custom vision models in LLM selector (port 5e5e78d3); release-green * fix(claude): omit adaptive thinking + output_config.effort for haiku (#4661) Integrated into release/v3.8.36 — haiku adaptive-thinking omit (port); release-green * feat(provider): CodeBuddy CN (copilot.tencent.com) — full stack (#4664) Integrated into release/v3.8.36 — CodeBuddy CN provider (port efd20be8); usage.ts import + public-creds allowlist line reconciled; release-green * feat(combo): Fusion strategy — parallel panel + judge synthesis (16th strategy) (#4652) Integrated into release/v3.8.36 — Fusion combo strategy (16th, port 87e5c1c6); combo.ts baseline reconciled; release-green * feat(proxy-pool): Deno Deploy relays + group action buttons (#4643) Integrated into release/v3.8.36 — Deno Deploy relays (port #1437); proxies.ts baseline reconciled + env docs restored; release-green * fix(security): pin image fetch DNS resolution to prevent SSRF rebinding (GHSA-cmhj-wh2f-9cgx) (#4634) Integrated into release/v3.8.36 — pin DNS for image fetch SSRF rebinding guard (GHSA-cmhj-wh2f-9cgx, port c7d07448); caller DNS stubs + test-file baseline reconciled; release-green * fix(github): route Copilot Codex models to /responses (port from 9router#102) (#4626) Integrated into release/v3.8.36 — route Copilot Codex models to /responses (port #102); release-green * fix(copilot): never route Gemini/Claude variants to /responses (chat-completions only) (#4627) Integrated into release/v3.8.36 — never route Gemini/Claude to /responses (port #1536); fused with #4626 codex routing via supportsResponsesEndpoint gate; release-green * docs(ops): add canonical incident response runbook (#4868) Integrated into release/v3.8.36 * docs(perf): add per-endpoint p50/p95/p99 latency + cost budgets (#4867) Integrated into release/v3.8.36 * fix(proxy): fan out direct dispatcher streams (#4803) Integrated into release/v3.8.36 * fix(antigravity): exclude standard Gemini rate limit message from quota exhaustion keywords (#4810) Integrated into release/v3.8.36 * fix(sse): skip third-party tool-name cloak for Anthropic server tools (#4808) Integrated into release/v3.8.36 * fix(install): make transformers optional for CUDA-host installs (#4807) Integrated into release/v3.8.36 * fix(combo): propagate selected connection ID to fallback error responses for correct model lockout (#4809) Integrated into release/v3.8.36 * fix db storage tuning settings (#4834) Integrated into release/v3.8.36 * fix(sse): drop ccp pin when pinned provider is durably unhealthy (failover + anti-flap) (#4864) Integrated into release/v3.8.36 * fix(claude): skip mcp__ tool-name cloak + guard missing connectionId (#4861) Integrated into release/v3.8.36 * chore(quality): reconcile env-doc + file-size base-reds in release/v3.8.36 (#4886) - env-doc-sync: document PIN_DROP_BACKOFF_LEVEL / PIN_DROP_GRACE_MS (added by the ccp-pin health gate #4864) in .env.example + ENVIRONMENT.md. - file-size: rebaseline image-generation-handler.test.ts 1996 -> 2019 to its actual size (pre-existing drift). Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(codex): drop non-standard codex.* events that break responses.stream (env-gated, #4602) (#4715) Integrated into release/v3.8.36 * feat(routing): honor X-Route-Model header to override body.model (#4863) Integrated into release/v3.8.36 * feat(live-ws): allow non-loopback clients via LIVE_WS_ALLOWED_HOSTS (closes #4873) (#4877) Integrated into release/v3.8.36 (live-ws + combo-api commits; Tailscale CGNAT commit held pending opt-in/opt-out decision) * chore(claude,codex): bump pinned CLI identity — Claude 2.1.158→2.1.187, Codex 0.132.0→0.142.0 (#4883) Integrated into release/v3.8.36 * fix(security): SSRF allowlist bypass via x-relay-path nos relays Deno/Vercel (#4899) Integrated into release/v3.8.36 * feat(quota): recuperação proativa de conexões em cooldown (cron heal) [Fase 3 #8] (#4900) Integrated into release/v3.8.36 * fix(quota): policy inválida não vaza allow + guard connectionIds vazio [Fase 3 #10] (#4901) Integrated into release/v3.8.36 * feat(quota): saturação real do Claude no fair-share via /api/oauth/usage (#4885) Integrated into release/v3.8.36 * chore(dashboard): rename Qoder display label from "Qoder AI" to "Qoder" (#4733) Integrated into release/v3.8.36 * fix(ci): include coverage/lcov.info in coverage-report artifact for SonarQube (#4670) Integrated into release/v3.8.36 * fix(cli): bump better-sqlite3 runtime pin to 12.10.1 for Node 26 (#4685) Integrated into release/v3.8.36 * docs: clarify Kiro is ~50 credits/month per account, not unlimited (#4690) Integrated into release/v3.8.36 * docs(agentbridge): document Electron NODE_EXTRA_CA_CERTS, real model IDs, identity caveat (#4718) Integrated into release/v3.8.36 * docs(ops): document the release-green family (green-prs, check:release-green, babysit, nightly) (#4679) Integrated into release/v3.8.36 * fix(translator): replay reasoning_content on plain Xiaomi MiMo turns (port from 9router#1321) (#4639) Integrated into release/v3.8.36 * feat(opencode-go): advertise glm-5.2 and kimi-k2.7-code (align with official Go endpoints) (#4711) Integrated into release/v3.8.36 * feat(db): track API endpoint dimension on usage_history (#4676) Integrated into release/v3.8.36 (migration renumbered 103→105; endpoint plumbed through extracted usage-stats helpers) * fix(cli): SIGKILL systray child PID before IPC close to avoid macOS NSStatusItem orphan (#4732) Integrated into release/v3.8.36 * feat(proxy-pool): Cloudflare Workers proxy deployer + pool integration (#4640) Integrated into release/v3.8.36 (relay type added to RELAY_TYPES set; dropdown UX preserved + Cloudflare item added; proxies.ts file-size rebaselined 1057→1060) * chore(quality): conserta base-red de release/v3.8.36 (gates + 7 testes + build MDX) (#4915) A base tinha base-red sistêmica herdada de PRs de outras sessões, bloqueando TODOS os PRs do ciclo (o TIA roda a suíte full em fail-safe p/ diffs hub). 4 Fast Quality Gates: - test-discovery (#4877): live-server-allowlist.test.ts em tests/unit/server/ (não-coletado) + vitest → nunca rodava. Convertido p/ node:test em tests/unit/security/. - any-budget:t11 (#4664): 3 explicit-any em tokenRefresh.ts tipados (sem crescer file-size). - docs-symbols (#4868): rotas inexistentes → /api/system/version e PUT /api/providers/{id} {isActive:false}. - docs-all fabricated-claim (#4868 + #4718): 5 bin/*.sh reais criados (rollback, snapshot-data, restore-data, restore-policies, cold-start-bench) + _ops-common.sh (snapshot VACUUM INTO, guards de confirmação/TTY, testes de contrato); NODE_EXTRA_CA_CERTS (env de runtime Node) na allowlist do checker. 7 testes unit base-red (de features alheias à quota): - oauth-providers-config (#4664): teste alinhado ao provider codebuddy-cn do registry. - antigravity-model-aliases (#4636): maxOutputTokens esperado 32769→16384 (cap intencional). - provider-request-capture #4091 (#4861): exemplo do teste trocado de mcp__ (que #4861 isenta de cloak por causa dos 400s de assimetria de histórico) para um tool de terceiro cloakável — preserva o invariante de #4091 SEM reverter #4861. - combo-error-response: convertido de vitest p/ node:test (era coletado pelo glob node:test e crashava); api/** e server/** removidos do vitest.config (config morta). Build MDX (dast-smoke, #4679): - docs/ops/RELEASE_GREEN.md não tinha frontmatter `title` → fumadocs-mdx rejeitava no webpack compile ("invalid frontmatter: title expected string"), quebrando o next build (e o deploy). Frontmatter title adicionado (único doc do collection sem ele). 17/17 Fast Quality Gates + suíte unit completa (17737 testes, 0 fail) + vitest verdes localmente. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(quota): saturação proativa por headers de tokens (universal) [Fase 3 #2] (#4907) storeRateLimitHeaders só capturava os headers de REQUESTS (RPM/min), que não refletem a pressão de TOKENS. Agora também parseia os headers de tokens (em toda resposta, sucesso também) para throttle proativo antes do 429: - Anthropic: anthropic-ratelimit-tokens-{limit,remaining,reset} (+ input/output), RFC3339. - OpenAI: x-ratelimit-{limit,remaining,reset}-tokens, reset em duração (6m0s). saturation = 1 − remaining/limit; resetAt normalizado a epoch (parse de duração ReDoS-safe). getTokenHeaderSaturation por (provider, connectionId). fetchGeneric- Saturation passa a usar esse sinal (complementa o oauth/usage do #1, que segue primário p/ Claude). Fail-open, cache mantido, request-path inalterado. 16 testes novos + regressão (oauth/usage #1 8/8, signals 6/6) = 30/30; typecheck:core + eslint limpos. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(quota): estratégia de combo "headroom" — seleção por folga de cota [Fase 3 #4] (#4908) Nova estratégia de roteamento que escolhe a conexão com MAIS folga de plano: headroom = 1 − max(util_5h, util_7d) (técnica do dario), via getSaturation (melhorado p/ Claude no #1). Proativo em vez de só fill-first reativo. - Helper PURO headroomRanking.ts (computeHeadroom + rankByHeadroom; saturação injetada, não-mutante, tie-break estável, fail-open). - Orderer async em combo/quotaStrategies.ts (reusa a maquinaria reset-aware de expansão de conexões + concorrência limitada; seam injetável). - Registrada como "headroom" em routingStrategies (combo-only); fill-first segue default — nenhuma estratégia existente tocada. - baseline file-size combo.ts 3168->3180 (só +12L de dispatch; lógica fora do god-file). 16 testes novos + combo-strategies 15/15 = 31/31; typecheck:core + eslint + file-size limpos. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(quota): cap per-(key,model) — quota_allocation_model_caps [Fase 3 #7] (#4927) * feat(quota): cap per-(key,model) com tabela quota_allocation_model_caps [Fase 3 #7] Fecha o buraco onde uma API key pode drenar o pool inteiro consumindo um único modelo. Tabela nova: quota_allocation_model_caps(pool_id, api_key_id, model, cap_value, cap_unit) PK composta (pool_id, api_key_id, model). cap_unit alinhado ao QuotaUnit existente. Comportamento: keyA acima do cap para modelo M → bloqueada somente em M; ainda permitida em qualquer outro modelo no mesmo pool. Cap <= EPSILON → ignorado (seed). Consumo por-(key,model) usa bucket segregado no quota_consumption existente (poolId mangled ':model:<model>') com window fixa 'hourly'; nenhuma nova tabela ou método de store necessário. Módulo novo: src/lib/db/quotaModelCaps.ts (getModelCap/setModelCap/deleteModelCap/listModelCaps) enforce.ts ganha o pre-check em enforceQuotaShare + recording em recordConsumption. EnforceInput e RecordConsumptionInput ganham model?: string (backward-compatible). localDb.ts re-exporta os 4 helpers (Hard Rule #2). TDD: tests/unit/quota-per-key-model.test.ts — 4 cenários (bloqueia em M, permite em M2, sem cap → sem bloqueio, EPSILON → ignorado). Todos os gates de qualidade passam. * feat(quota): plumba model resolvido no hot path para ativar o per-(key,model) cap [Fase 3 #7] A tabela/enforce do commit anterior estavam INERTES: o hot path não passava `model` ao enforce nem ao record, então nenhum model-cap disparava em produção. Plumbagem (model resolvido = mesma var usada no log/roteamento, pós background-redirect/alias): - chatCore.ts: enforceQuotaShare ganha `model`; scheduleQuotaShareConsumption recebe `model`. - chatCore/quotaShareConsumption.ts: threade `model` no RecordConsumptionInput (non-streaming). - spendRecorder.ts: recordStreamingConsumption já recebia `model` — agora o coloca no RecordConsumptionInput (streaming accrue por-modelo). - embeddings.ts: enforce + record ganham `model`. Namespace do cap = id do modelo RESOLVIDO (o mesmo de modelForScope/pendingScope/getUnsupportedParams), não o requestedModel cru nem o finalModelToUpstream (sem prefixo de provider). Operador configura o cap contra esse id. `model || undefined` em todos os pontos: vazio/null → check pulado (fail-safe, zero latência — só um campo no objeto). Teste de integração novo (tests/unit/quota-per-key-model-hotpath.test.ts): prova end-to-end que N consumos via scheduleQuotaShareConsumption({model}) → enforceQuotaShare({model}) bloqueia, e que outro modelo no mesmo pool ainda passa; + guard de que enforce SEM model nunca dispara model-cap. --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(quota): session stickiness p/ integridade de prompt-cache [Fase 3 #5] (#4929) * feat(quota): session stickiness p/ integridade de prompt-cache [Fase 3 #5] Adiciona stickiness de sessão ao roteamento de combo: uma conversa multi-turno é roteada para a MESMA conexão enquanto ela permanecer saudável, evitando a perda do prompt-cache do provider (custo 5-10× sem stickiness, efeito conhecido no dario/clewdr). Implementação: - `open-sse/services/combo/sessionStickiness.ts` (novo, <800 linhas): mapa em memória (messageHash → connectionId) com TTL 15 min + cap 500 entradas; `applySessionStickiness` promove a conexão sticky ao índice 0 dos targets ordenados pelo strategy, guardado por `computeHeadroom > 0.15` (threshold); quando saturada (headroom ≤ 0.15), o binding é limpo e a seleção normal reage. Hash da sessão = SHA-256 dos primeiros chars da 1ª mensagem user → 16 hex chars. Seam de teste: `__setStickinessHeadroomFetcherForTests`. - `open-sse/services/combo.ts`: import + 2 pontos de integração (pré-eval-scores e pós-success), dentro do orçamento congelado de 3180 linhas. - `tests/unit/combo-session-stickiness.test.ts`: 19 testes node:test + assert/strict, todos via injeção de fetcher (zero rede/DB). Threshold 0.15: conexão a >85% de utilização está a um burst de rate-limit; o benefício de cache não compensa manter-se numa conexão degradada. Valor alinhado com a zona de soft-penalty do restante do engine de quota-share. * test(combo): isola combo-strategies da session stickiness (#5) selectedConnectionFor reusa o mesmo body, então o sticky map (#5) fixava a connection após a 1ª chamada e quebrava o round-robin tie-break do teste reset-aware. Limpa o sticky map no início da helper — a stickiness tem suíte própria (combo-session-stickiness). Sem enfraquecer asserts. --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(quota): buckets multi-janela por conexão (5h/7d/per-model) [Fase 3 #3] (#4928) Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * refactor(providers): decompõe catálogo providers.ts em módulos de dados (godfile sweep, #3501) (#4917) Integrado em release/v3.8.36 (godfile sweep providers.ts, #3501) * refactor(pricing): decompõe pricing.ts em shared-tiers + DEFAULT_PRICING particionado (godfile sweep, #3501) (#4918) Integrado em release/v3.8.36 (godfile sweep pricing.ts, #3501) * refactor(api): extrai camada-folha pura de validation.ts (URL/headers/transport) (#4921) Integrado em release/v3.8.36 (validation.ts split fatia 1 — leaf layer) * refactor(api): extrai validators web-cookie + Meta AI de validation.ts (#4922) Integrado em release/v3.8.36 (validation.ts split fatia 2 — web-cookie + Meta AI) * refactor(api): extrai validators enterprise-cloud + probe compartilhado de validation.ts (#4923) Integrado em release/v3.8.36 (validation.ts split fatia 3 — enterprise-cloud + probe) * refactor(api): extrai validators áudio/speech + misc apikey de validation.ts (#4930) Integrado em release/v3.8.36 (validation.ts split fatia 4 — áudio/speech + misc apikey) * feat(quota): estratégia dedicada de quota-share (DRR + P2C in-flight + gating per-model) [Fase 3 #9] (#4939) * feat(quota): estratégia dedicada de quota-share (DRR + P2C in-flight + gating per-model) [Fase 3 #9] Estratégia interna "quota-share" isolada num módulo dedicado — NÃO toca a seleção/ fair-share genérica (decisão do dono: não mexer no que já funciona). Os combos qtSd/ (quotaCombos.ts) passam de fill-first para essa strategy; combo.ts ganha só 1 branch de dispatch que delega 100% ao módulo (nenhum case existente alterado). - quotaShareStrategy.ts: gating per-model (isBucketSaturated do #3) + DRR (quantum proporcional ao weight) + P2C sobre carga in-flight. - quotaShareInflight.ts: contador in-flight com TTL/lease de 120s — fallback do decrement-on-abort sem precisar instrumentar o combo genérico. - "quota-share" registrada como strategy INTERNA (não exposta na UI). - testes de síntese (quota-combo-balancing, quota-multiprovider) alinhados: a strategy esperada dos combos qtSd/ passa de "fill-first" para "quota-share" (alinhamento ao novo comportamento intencional, não mascaramento — os 73 testes de qtSd/ seguem verdes). * test(quota-share): alinha 2 scope-guards ao godfile sweep (base-reds que bloqueavam o CI) Dois testes de "arquivo contém X" quebraram por decomposições de godfile que outras sessões mergearam no release DURANTE a validação de #9 — NÃO são regressão de #9 (que não toca validation/oauth). Alinhados ao novo layout, asserts preservados: - proxy-bypass-scope-guard #3226: bypassProxyPatch foi extraído de validation.ts para validation/headers.ts (split #4921–#4930) → o teste lê a camada de validação. - sse-error-passthrough #3324: a windsurf authHint foi extraída de providers.ts para providers/oauth.ts → o teste lê o novo local. --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * refactor(api): extrai validators search + embedding/rerank de validation.ts (#4932) Integrated into release/v3.8.36 * refactor(api): extrai format-validators (OpenAI/Anthropic) de validation.ts (#4933) Integrated into release/v3.8.36 * refactor(db): extrai model-permission matching de db/apiKeys.ts (#4936) Integrated into release/v3.8.36 * refactor(db): extrai row-parsers + tipos compartilhados de db/apiKeys.ts (#4943) Integrated into release/v3.8.36 * refactor(db): extrai column-mapping (snake↔camel) de db/core.ts (#4947) Integrated into release/v3.8.36 * refactor(db): extrai schema-column reconciliation de db/core.ts (#4948) Integrated into release/v3.8.36 * refactor(sse): extrai scalar/format helpers de services/usage.ts (#4949) Integrated into release/v3.8.36 * refactor(sse): extrai quota-core (UsageQuota + builders) de services/usage.ts (#4950) Integrated into release/v3.8.36 * fix(translator): regroup parallel tool results adjacent to their assistant (#4714) (#4882) Integrated into release/v3.8.36 (fixes #4714) * fix(qoder): exchange PAT for jt-* job token before Cosy chat (#4683) (#4884) Integrated into release/v3.8.36 (fixes #4683) * refactor(sse): dedup fallback tool_call id helper (#4736) Integrated into release/v3.8.36 * refactor(open-sse): extract safeParseJSON util, dedup tryParseJSON (#4735) Integrated into release/v3.8.36 * fix(compression): eliminate ReDoS in math_inline preservation pattern (#4795) (#4838) Integrated into release/v3.8.36 (fixes #4795) * fix(combo): fetch models dynamically from custom provider endpoints (#4860) Integrated into release/v3.8.36 * feat(providers): update volcengine-ark model list with DeepSeek V4 (#4905) Integrated into release/v3.8.36 * fix(translator): provider thinking compatibility (DeepSeek/Gemini) (#4946) Integrated into release/v3.8.36 * feat(combo): task-aware routing strategy (#4945) Integrated into release/v3.8.36 * refactor(sse): extrai a família MiniMax de services/usage.ts (#4952) Integrated into release/v3.8.36 * refactor(sse): extrai a família GLM de services/usage.ts (#4953) Integrated into release/v3.8.36 * refactor(sse): extrai a família Antigravity de services/usage.ts (#4956) Integrated into release/v3.8.36 * fix(dashboard): show custom provider given-name instead of internal id across dashboard pages (#4603) (#4960) Integrated into release/v3.8.36 (fixes #4603) * fix(api): evict stale in-memory rate-limit windows to stop slow heap leak (#4041) (#4957) Integrated into release/v3.8.36 (fixes #4041) * fix(api): parse /v1/responses body once instead of 3-4x on the hot path (#4041) (#4958) Integrated into release/v3.8.36 (fixes #4041) * fix(translator): preserve legitimate empty-string tool arguments in openai-to-claude streaming (#4951) (#4959) Integrated into release/v3.8.36 (fixes #4951) * chore(quality): reconcile file-size baseline for #4960 provider-display-name (#4961) Integrated into release/v3.8.36 * fix(dashboard): restore home provider-topology card hidden by #4596 default (#4963) Integrated into release/v3.8.36 — restores home topology card (#4596 regression) * fix(build): drop @omniroute/open-sse from optimizePackageImports (build OOM) (#4968) Integrated into release/v3.8.36 — fixes build OOM (optimizePackageImports open-sse) * fix(quota): migração 107 ativa estratégia quota-share nos combos qtSd/ existentes [Fase 3 #9] (#4962) Integrated into release/v3.8.36 * feat(quota): respeita max_concurrent por conexão no roteamento (#4965) Integrated into release/v3.8.36 * feat(quota): combo quota-share espera cooldown curto e re-despacha (Variante A) (#4967) Integrated into release/v3.8.36 * fix(quality): resolve base-reds da release — db-rules allowlist + task-aware router precedence (#4973) Dois base-reds pré-existentes que reprovavam o CI da release v3.8.36 (Fast Quality Gates + Unit Tests fast-path), independentes de qualquer feature em voo: 1. check:db-rules / allowlist: os módulos db-internal caseMapping (#4947) e schemaColumns (#4948), extraídos de db/core.ts e importados só por ele, não estavam em INTENTIONALLY_INTERNAL. Registrados na allowlist (correção canônica — são internos legítimos, não re-exportados pelo localDb). 2. auto-strategy honra LKGP/cost (combo-routing-engine.test.ts, 2 testes): o task-aware reordering (#4945, reorderByTaskWeight) roda para strategy "auto" e era aplicado DEPOIS do router explícito (selectWithStrategy: lkgp/cost), sobrescrevendo o orderedTargets[0] que o operador escolheu. Instrumentação provou: post-filter [0]=claude (LKGP) → post-task [0]=gpt-oss. Correção: quando o auto usa router explícito, preserva o [0] dele e deixa o task-aware refinar só a cauda de fallback. gpt-oss-120b PERMANECE tool-capable (não é mudança de catálogo; o model-capabilities-registry test segue verde). Validado: 121 testes (combo-routing-engine + combo-task-aware + registry) verdes, red-check confirmado, db-rules/file-size/typecheck/lint/prettier OK. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(quota): serializa concorrência por conexão no caminho quota-share (FASE 2.1) (#4970) O gating de quota-share em selectQuotaShareTarget é fail-open: uma conexão at-cap só é despriorizada, nunca bloqueada. Com 1 conexão por conta de assinatura (caso comum), chamadas concorrentes ainda floodam a conta (→ 429 + cooldown) — provado live na .15: 3 chamadas concorrentes com max_concurrent=1 despacharam todas em 94ms. Adiciona um semáforo POR CONEXÃO em torno do dispatch quota-share: chamadas excedentes esperam na fila em vez de floodar (key qsconn:<connectionId>, cap = max_concurrent da conexão). Fail-open em fila saturada/timeout para nunca piorar disponibilidade. Gated por strategy===quota-share + kill-switch resilienceSettings.quotaShareConcurrencyLimit (default on; UI no ResilienceTab). Lógica extraível isolada no leaf puro combo/quotaShareConcurrency.ts (unit-testado: estabilidade da key, no-op sem cap, serialização real, fail-open). Settings + schema + UI espelham comboCooldownWait. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * docs(resilience): document Quota-Share Concurrency Control (max_concurrent + serialization + cooldown-wait) (#4980) Documents the v3.8.36 quota-share concurrency layers in RESILIENCE_GUIDE.md: per-connection max_concurrent cap, the quota-share request serialization semaphore (FASE 2.1, qsconn:<connectionId>, fail-open, kill-switch), and the combo cooldown-aware retry — so operators know how to cap a subscription account's concurrency and why the routing gate alone cannot contain a single-connection flood. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(dashboard): proxy-pool success gating, sync timestamp, opt-in Redis (#4878) (#4988) Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(sse): fail over on 400 responses carrying rate-limit text (#4976) (#4986) * fix(sse): fail over on 400 responses carrying rate-limit text (#4976) * chore(quality): rebaseline accountFallback.ts file-size for #4976 fix --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(compression): stop RTK over-truncating file-read tool results (#4559) (#4987) * fix(compression): stop RTK over-truncating file-read tool results (#4559) * chore(quality): trim #4559 comment to keep rtk/index.ts within size cap --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954) (#4989) * fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954) * chore(quality): rebaseline auth.ts file-size for #4954 (+39: synthetic no-auth providerSpecificData hydration of fingerprints/accountProxies; irreducible credential-path wiring, covered by opencode-proxy-rotation-4954.test.ts + 159 auth/noauth regression) --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(sse): soft-penalize exhausted providers in auto-combo scoring (#4540) (#4990) * fix(sse): soft-penalize exhausted providers in auto-combo scoring (#4540) * chore(quality): document STATUS_SOFT_DEPRIORITIZE_FACTOR + rebaseline combo.ts for #4540 --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(dashboard): switch to visible filter after auto-hiding failed models in test-all (#4887) (#4991) * fix(dashboard): switch to visible filter after auto-hiding failed models in OAuth provider test-all (#4887) * test(dashboard): move #4887 test into tests/unit/ui so a CI runner collects it --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(pollinations): only enable jsonMode when JSON output is requested (#3981) (#5009) Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(antigravity): default safetySettings to all-OFF for parity with native Gemini paths (#5003) (#5008) * fix(antigravity): default safetySettings to all-OFF for parity with native Gemini paths (#5003) * docs(changelog): restore #3981 pollinations entry eaten by merge --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * fix(chatgpt-web): map advertised gpt-5.5/5.4-pro/5.2-pro slugs to prevent silent model substitution (#4665) (#5010) * fix(chatgpt-web): map advertised gpt-5.5/5.4-pro/5.2-pro slugs to prevent silent model substitution (#4665) MODEL_MAP was missing the advertised catalog ids gpt-5.5, gpt-5.5-pro, gpt-5.4-pro and gpt-5.2-pro, so MODEL_MAP[model] ?? model sent the dot-form id verbatim to the ChatGPT backend-api, which silently rejected it and served the default Plus model. Map each to its dash-form slug. gpt-4-5 is already dash-form and falls through correctly, so it is intentionally left unmapped. Extends the executor MODEL_MAP test with the four ids and adds a drift guard asserting every advertised dot-form catalog id reaches the backend in dash-form (never verbatim), guarding future catalog<->map drift. file-size: tests/unit/chatgpt-web.test.ts frozen baseline 2809->2855 (+46) for the added test cases and drift-guard test; executor source unchanged in baseline. * docs(changelog): restore #3981/#5003 entries eaten by merge --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * feat(combos): add editable per-combo description field persisted via /api/combos (#5005) (#5011) * feat(combos): add editable per-combo description field persisted via /api/combos (#5005) * docs(changelog): restore #3981/#5003/#4665 entries eaten by merge --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> * Fix Ollama Cloud max reasoning effort (#4993) Integrated into release/v3.8.36 * fix(copilot): replace execSync with execFile to prevent command injection (#5024) Integrated into release/v3.8.36 * fix(plugin): auth.json dual-key fallback for auto-prefix migration (#5027) Integrated into release/v3.8.36 * feat(endpoint): per-endpoint custom system prompt injection (#5022) Integrated into release/v3.8.36 * fix(headroom): translate openai-responses input through OpenAI for compression (#5023) Integrated into release/v3.8.36 * docs(changelog): add entries for #4993, #5024, #5027 (release notes credit) * fix(api): stop /api/system/env/repair 500 on packaged install (#5006) (#5028) * fix(api): stop /api/system/env/repair 500 on packaged install — lazy createRequire in sync-env.mjs (#5006) scripts/dev/sync-env.mjs ran createRequire(import.meta.url) at module top-level. When webpack bundles it into the standalone env-repair route, import.meta.url is frozen to the build-machine path (file:///home/runner/...) and createRequire throws during module evaluation, so the whole route module fails to load and every GET returns HTTP 500 — breaking the onboarding wizard on packaged/global installs. - Move createRequire into the guarded better-sqlite3 block (only place that needs it); a bad import.meta.url now returns the safe default. - resolveRootDir() falls back to process.cwd() when fileURLToPath throws. - route.ts passes an explicit rootDir (process.cwd()) so the helper never derives the root from the frozen import.meta.url, matching the .env target used by createEnvBackup(). - Regression guard: assert sync-env.mjs has no top-level createRequire + getEnvSyncPlan(oauth) works with explicit rootDir without throwing. * docs(changelog): restore #4993/#5023/#5024/#5027 + custom-system-prompt/headroom entries eaten by release merge * chore(quality): rebaseline 3 inherited base-reds from release merge Files NOT touched by this PR — grew on release/v3.8.36 via --admin merges and inherited here through 'git merge origin/release': - open-sse/executors/base.ts 1414->1416 (#4993 Ollama Cloud max-effort) - src/lib/db/settings.ts 1149->1151 (#5023 custom system prompt) - src/app/(dashboard)/.../endpoint/EndpointPageClient.tsx 2570->2612 (custom system prompt UI) * chore(release): finalize v3.8.36 CHANGELOG + docs (2026-06-25) --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Makcim Ivanov <makcimbx@gmail.com> Co-authored-by: Chewji <126886556+Chewji9875@users.noreply.github.com> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: Demiurge The Single <megamen932@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Éder Costa <eder.almeida.costa@gmail.com> Co-authored-by: Jefferson Felizardo <jeffer1312@gmail.com> Co-authored-by: Arthur Bodera <abodera@gmail.com> Co-authored-by: Hamsa_M <116961508+hamsa0x7@users.noreply.github.com> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
1916 lines
65 KiB
TypeScript
1916 lines
65 KiB
TypeScript
/**
|
|
* Usage Fetcher - Get usage data from provider APIs
|
|
*/
|
|
|
|
import { buildCodexUsageQuotas } from "./codexUsageQuotas.ts";
|
|
import { getGlmQuotaUrl } from "../config/glmProvider.ts";
|
|
import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts";
|
|
import { safePercentage } from "@/shared/utils/formatting";
|
|
import { getDbInstance } from "@/lib/db/core";
|
|
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
|
|
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
|
|
import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts";
|
|
import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts";
|
|
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts";
|
|
import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts";
|
|
import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "./claudeUsageCooldown.ts";
|
|
import {
|
|
extractCodeAssistOnboardTierId,
|
|
extractCodeAssistSubscriptionTier,
|
|
} from "./codeAssistSubscription.ts";
|
|
import { sanitizeErrorMessage } from "../utils/error.ts";
|
|
import {
|
|
toRecord,
|
|
toNumber,
|
|
toPercentage,
|
|
toTitleCase,
|
|
getFieldValue,
|
|
clampPercentage,
|
|
roundCurrency,
|
|
toDisplayLabel,
|
|
pickFirstNonEmptyString,
|
|
} from "./usage/scalars.ts";
|
|
import { type UsageQuota, parseResetTime, createQuotaFromUsage } from "./usage/quota.ts";
|
|
import {
|
|
getMiniMaxUsage,
|
|
getMiniMaxPlanLabel,
|
|
getMiniMaxSessionTotal,
|
|
inferMiniMaxPlanLabelFromTotals,
|
|
getMiniMaxQuotaResetAt,
|
|
isMiniMaxTextQuotaModel,
|
|
getMiniMaxWeeklyTotal,
|
|
createMiniMaxQuotaFromCount,
|
|
createMiniMaxQuotaFromPercent,
|
|
getMiniMaxRemainingPercent,
|
|
getMiniMaxAuthErrorMessage,
|
|
getMiniMaxErrorSummary,
|
|
} from "./usage/minimax.ts";
|
|
import { getGlmUsage } from "./usage/glm.ts";
|
|
// Re-exported para o teste glm-coding-plan-monthly (importa de services/usage).
|
|
export { glmMonthlyRemainingPercentage } from "./usage/glm.ts";
|
|
import {
|
|
getAntigravityUsage,
|
|
getAntigravityPlanLabel,
|
|
mapCodeAssistSubscriptionToPlanLabel,
|
|
mapCodeAssistTierIdToLabel,
|
|
mapSubscriptionTierStringToPlanLabel,
|
|
} from "./usage/antigravity.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";
|
|
|
|
// Codex (OpenAI) API config
|
|
const CODEX_CONFIG = {
|
|
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
|
|
};
|
|
|
|
// Claude API config
|
|
const CLAUDE_CONFIG = {
|
|
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
|
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
|
settingsUrl: "https://api.anthropic.com/v1/settings",
|
|
apiVersion: "2023-06-01",
|
|
};
|
|
|
|
// Kimi Coding API config
|
|
const KIMI_CONFIG = {
|
|
baseUrl: "https://api.kimi.com/coding/v1",
|
|
usageUrl: "https://api.kimi.com/coding/v1/usages",
|
|
apiVersion: "2023-06-01",
|
|
};
|
|
|
|
const NANOGPT_CONFIG = {
|
|
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
|
};
|
|
|
|
// Cursor dashboard usage API config
|
|
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
|
|
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
|
|
// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request).
|
|
const CURSOR_USAGE_CONFIG = {
|
|
usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage",
|
|
origin: "https://cursor.com",
|
|
referer: "https://cursor.com/dashboard/spending",
|
|
userAgent:
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
};
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type UsageProviderConnection = JsonRecord & {
|
|
id?: string;
|
|
provider?: string;
|
|
accessToken?: string;
|
|
apiKey?: string;
|
|
providerSpecificData?: JsonRecord;
|
|
projectId?: string;
|
|
email?: string;
|
|
};
|
|
type SubscriptionCacheEntry = {
|
|
data: unknown;
|
|
fetchedAt: number;
|
|
};
|
|
|
|
function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota {
|
|
if (!quota) return false;
|
|
if (quota.unlimited && quota.total <= 0) return false;
|
|
return quota.total > 0 || quota.remainingPercentage !== undefined;
|
|
}
|
|
|
|
function isKiroOverageEnabled(data: JsonRecord): boolean {
|
|
const overageConfiguration = toRecord(data.overageConfiguration);
|
|
const overageStatus = String(overageConfiguration.overageStatus || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
return (
|
|
overageStatus === "ENABLED" ||
|
|
data.overageEnabled === true ||
|
|
overageConfiguration.overageEnabled === true
|
|
);
|
|
}
|
|
|
|
function buildKiroQuota(
|
|
used: number,
|
|
total: number,
|
|
resetAt: string | null,
|
|
overageEnabled: boolean
|
|
): UsageQuota {
|
|
const remaining = total - used;
|
|
|
|
if (!overageEnabled) {
|
|
return { used, total, remaining, resetAt, unlimited: false };
|
|
}
|
|
|
|
return {
|
|
used,
|
|
total,
|
|
remaining,
|
|
remainingPercentage: 100,
|
|
resetAt,
|
|
unlimited: true,
|
|
};
|
|
}
|
|
|
|
function 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;
|
|
}
|
|
|
|
// 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 };
|
|
}
|
|
|
|
/**
|
|
* Bailian (Alibaba Coding Plan) Usage
|
|
* Fetches triple-window quota (5h, weekly, monthly) and returns worst-case.
|
|
*/
|
|
async function getBailianCodingPlanUsage(
|
|
connectionId: string,
|
|
apiKey: string,
|
|
providerSpecificData?: Record<string, unknown>
|
|
) {
|
|
try {
|
|
const connection = { apiKey, providerSpecificData };
|
|
const quota = await fetchBailianQuota(connectionId, connection);
|
|
|
|
if (!quota) {
|
|
return { message: "Bailian Coding Plan connected. Unable to fetch quota." };
|
|
}
|
|
|
|
const bailianQuota = quota as BailianTripleWindowQuota;
|
|
const used = bailianQuota.used;
|
|
const total = bailianQuota.total;
|
|
const remaining = Math.max(0, total - used);
|
|
const remainingPercentage = Math.round(remaining);
|
|
|
|
return {
|
|
plan: "Alibaba Coding Plan",
|
|
used,
|
|
total,
|
|
remaining,
|
|
remainingPercentage,
|
|
resetAt: bailianQuota.resetAt,
|
|
unlimited: false,
|
|
displayName: "Alibaba Coding Plan",
|
|
};
|
|
} catch (error) {
|
|
return { message: `Bailian Coding Plan error: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DeepSeek Usage
|
|
* Fetches balance from the DeepSeek balance API.
|
|
* Returns all balances (USD and CNY) as "credits" for credits-style UI display.
|
|
*/
|
|
async function getDeepseekUsage(connectionId: string, apiKey: string) {
|
|
try {
|
|
const connection = { apiKey };
|
|
const quota = await fetchDeepseekQuota(connectionId, connection);
|
|
|
|
if (!quota) {
|
|
return { message: "DeepSeek API key not available. Add a key to view usage." };
|
|
}
|
|
|
|
const deepseekQuota = quota as DeepseekQuota;
|
|
const { balances, isAvailable, limitReached } = deepseekQuota;
|
|
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
|
|
// Show all balances as credits-style entries (e.g., credits_usd, credits_cny)
|
|
// The UI will display them as "🪙 Balance (USD) $50.00"
|
|
for (const balanceInfo of balances) {
|
|
const key = `credits_${balanceInfo.currency.toLowerCase()}`;
|
|
quotas[key] = {
|
|
used: 0,
|
|
total: 0,
|
|
remaining: balanceInfo.balance,
|
|
remainingPercentage: 100,
|
|
resetAt: null,
|
|
unlimited: true,
|
|
currency: balanceInfo.currency,
|
|
grantedBalance: balanceInfo.grantedBalance,
|
|
toppedUpBalance: balanceInfo.toppedUpBalance,
|
|
};
|
|
}
|
|
|
|
const plan = isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)";
|
|
|
|
return {
|
|
plan,
|
|
quotas,
|
|
isAvailable,
|
|
limitReached,
|
|
};
|
|
} catch (error) {
|
|
return { message: `DeepSeek error: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
// Xiaomi MiMo Token Plan monthly limit (tokens). Keep in sync with the
|
|
// "xiaomi-mimo" preset in src/lib/quota/planRegistry.ts.
|
|
const XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT = 4_100_000_000;
|
|
|
|
/**
|
|
* Xiaomi MiMo — SELF-TRACKED monthly quota.
|
|
*
|
|
* Xiaomi exposes plan usage only behind the console session cookie (the API key
|
|
* cannot reach the `tokenPlan/usage` endpoint), so there is no upstream usage
|
|
* API to call. Instead we count the tokens OmniRoute itself routed to this
|
|
* connection in the current UTC month (from `usage_history`) and compare them
|
|
* to the known Token Plan monthly limit. This reflects only traffic that went
|
|
* through OmniRoute, not the provider's own dashboard figure.
|
|
*/
|
|
async function getXiaomiMimoUsage(connectionId: string) {
|
|
if (!connectionId) {
|
|
return { message: "Xiaomi MiMo: connection id unavailable for self-tracked quota." };
|
|
}
|
|
try {
|
|
const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats");
|
|
const used = getMonthlyProviderTokensForConnection("xiaomi-mimo", connectionId);
|
|
const total = XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT;
|
|
const now = new Date();
|
|
const resetAt = new Date(
|
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)
|
|
).toISOString();
|
|
return {
|
|
plan: "Xiaomi MiMo Token Plan (OmniRoute-tracked)",
|
|
quotas: {
|
|
monthly: createQuotaFromUsage(used, total, resetAt),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return { message: `Xiaomi MiMo self-tracked usage error: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OpenCode Go / OpenCode / OpenCode Zen Usage
|
|
* Delegates to the dedicated opencodeQuotaFetcher and shapes the result into
|
|
* the standard `{ plan, quotas }` usage response expected by the limits page.
|
|
*
|
|
* Three rolling windows are surfaced: $12/5h, $30/wk, $60/mo.
|
|
*/
|
|
async function getOpencodeUsage(connectionId: string, apiKey: string) {
|
|
if (!apiKey) {
|
|
return { message: "OpenCode API key not available. Add a key to view usage." };
|
|
}
|
|
|
|
try {
|
|
const quota = (await fetchOpencodeQuota(connectionId, {
|
|
apiKey,
|
|
})) as OpencodeTripleWindowQuota | null;
|
|
|
|
if (!quota) {
|
|
return { message: "OpenCode connected. Unable to fetch quota data." };
|
|
}
|
|
|
|
const { window5h, windowWeekly, windowMonthly, limitReached } = quota;
|
|
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
|
|
// $12 / 5-hour rolling window
|
|
quotas["window_5h"] = {
|
|
used: window5h.percentUsed * 12,
|
|
total: 12,
|
|
remaining: (1 - window5h.percentUsed) * 12,
|
|
remainingPercentage: (1 - window5h.percentUsed) * 100,
|
|
resetAt: window5h.resetAt,
|
|
unlimited: false,
|
|
displayName: "$12 / 5-hour",
|
|
currency: "USD",
|
|
};
|
|
|
|
// $30 / weekly window
|
|
quotas["window_weekly"] = {
|
|
used: windowWeekly.percentUsed * 30,
|
|
total: 30,
|
|
remaining: (1 - windowWeekly.percentUsed) * 30,
|
|
remainingPercentage: (1 - windowWeekly.percentUsed) * 100,
|
|
resetAt: windowWeekly.resetAt,
|
|
unlimited: false,
|
|
displayName: "$30 / week",
|
|
currency: "USD",
|
|
};
|
|
|
|
// $60 / monthly window
|
|
quotas["window_monthly"] = {
|
|
used: windowMonthly.percentUsed * 60,
|
|
total: 60,
|
|
remaining: (1 - windowMonthly.percentUsed) * 60,
|
|
remainingPercentage: (1 - windowMonthly.percentUsed) * 100,
|
|
resetAt: windowMonthly.resetAt,
|
|
unlimited: false,
|
|
displayName: "$60 / month",
|
|
currency: "USD",
|
|
};
|
|
|
|
return {
|
|
plan: "OpenCode Go",
|
|
quotas,
|
|
limitReached,
|
|
};
|
|
} catch (error) {
|
|
return { message: `OpenCode error: ${sanitizeErrorMessage(error)}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* NanoGPT Usage
|
|
* Fetches subscription-level quota from the NanoGPT API.
|
|
* Returns daily/weekly token limits and daily image limits for PRO accounts.
|
|
*/
|
|
async function getNanoGptUsage(apiKey: string) {
|
|
if (!apiKey) {
|
|
return { message: "NanoGPT API key not available. Add a key to view usage." };
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(NANOGPT_CONFIG.usageUrl, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) return { message: "Invalid NanoGPT API key." };
|
|
return { message: `NanoGPT quota API error (${res.status})` };
|
|
}
|
|
|
|
const data = toRecord(await res.json());
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
|
|
// active -> PRO, otherwise FREE
|
|
const plan = data.active ? "PRO" : "FREE";
|
|
|
|
if (data.active) {
|
|
// 1. Tokens limit
|
|
// dailyInputTokens if exists, else weeklyInputTokens
|
|
let tokenQuota = toRecord(data.dailyInputTokens);
|
|
let tokenLabel = "Daily Tokens";
|
|
if (!tokenQuota.resetAt) {
|
|
const weeklyQuota = toRecord(data.weeklyInputTokens);
|
|
if (weeklyQuota.remaining !== undefined) {
|
|
tokenQuota = weeklyQuota;
|
|
tokenLabel = "Weekly Tokens";
|
|
}
|
|
}
|
|
|
|
if (tokenQuota.remaining !== undefined) {
|
|
const used = toNumber(tokenQuota.used, 0);
|
|
const remaining = toNumber(tokenQuota.remaining, 0);
|
|
const total = used + remaining;
|
|
quotas[tokenLabel] = {
|
|
used,
|
|
total,
|
|
remaining,
|
|
remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100),
|
|
resetAt: parseResetTime(tokenQuota.resetAt),
|
|
unlimited: false,
|
|
};
|
|
}
|
|
|
|
// 2. Images limit
|
|
const imageQuota = toRecord(data.dailyImages);
|
|
if (imageQuota.remaining !== undefined) {
|
|
const used = toNumber(imageQuota.used, 0);
|
|
const remaining = toNumber(imageQuota.remaining, 0);
|
|
const total = used + remaining;
|
|
quotas["Daily Images"] = {
|
|
used,
|
|
total,
|
|
remaining,
|
|
remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100),
|
|
resetAt: parseResetTime(imageQuota.resetAt),
|
|
unlimited: false,
|
|
};
|
|
}
|
|
|
|
if (Object.keys(quotas).length === 0) {
|
|
return { plan, message: "NanoGPT connected, but no active limits found." };
|
|
}
|
|
}
|
|
|
|
return { plan, quotas };
|
|
} catch (error) {
|
|
return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode the `sub` claim of a Cursor JWT (the WorkOS user id).
|
|
* Returns null if the token is not a parseable JWT.
|
|
*/
|
|
function decodeCursorJwtSub(token: string): string | null {
|
|
if (!token || typeof token !== "string") return null;
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return null;
|
|
try {
|
|
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
while (payload.length % 4 !== 0) payload += "=";
|
|
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
|
const sub = decoded?.sub;
|
|
return typeof sub === "string" && sub.length > 0 ? sub : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cursor Pro Plan Usage
|
|
* Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three
|
|
* windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API.
|
|
*/
|
|
async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) {
|
|
if (!accessToken) {
|
|
return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." };
|
|
}
|
|
|
|
const storedUserId = (() => {
|
|
const raw = toRecord(providerSpecificData).userId;
|
|
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
})();
|
|
const userId = storedUserId || decodeCursorJwtSub(accessToken);
|
|
|
|
if (!userId) {
|
|
return {
|
|
message: "Cursor token missing user id. Re-import the connection from Cursor IDE.",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
|
|
Origin: CURSOR_USAGE_CONFIG.origin,
|
|
Referer: CURSOR_USAGE_CONFIG.referer,
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
|
|
},
|
|
body: "{}",
|
|
});
|
|
|
|
// 3xx redirect to WorkOS authkit means the session cookie was rejected.
|
|
if (response.status >= 300 && response.status < 400) {
|
|
return {
|
|
plan: "Cursor",
|
|
message: "Cursor session expired. Re-import the token from Cursor IDE.",
|
|
};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorText = (await response.text()).slice(0, 200);
|
|
if (response.status === 401 || response.status === 403) {
|
|
return {
|
|
plan: "Cursor",
|
|
message: "Cursor session unauthorized. Re-import the token from Cursor IDE.",
|
|
};
|
|
}
|
|
return {
|
|
plan: "Cursor",
|
|
message: `Cursor usage endpoint error (${response.status}): ${errorText}`,
|
|
};
|
|
}
|
|
|
|
const data = toRecord(await response.json());
|
|
const planUsage = toRecord(data.planUsage);
|
|
|
|
if (Object.keys(planUsage).length === 0) {
|
|
return {
|
|
plan: "Cursor",
|
|
message: "Cursor connected. No active plan usage returned.",
|
|
};
|
|
}
|
|
|
|
const limitCents = Math.max(0, toNumber(planUsage.limit, 0));
|
|
const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0));
|
|
const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0));
|
|
const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0));
|
|
const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0));
|
|
|
|
// billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number.
|
|
const billingCycleEndMs = toNumber(data.billingCycleEnd, 0);
|
|
const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null;
|
|
|
|
// Convert cents → dollars rounded to 2 decimal places.
|
|
const toDollars = (cents: number) => Math.round(cents) / 100;
|
|
|
|
const limitDollars = toDollars(limitCents);
|
|
const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => {
|
|
const usedCents =
|
|
typeof usedCentsOverride === "number"
|
|
? usedCentsOverride
|
|
: Math.round((limitCents * percentUsed) / 100);
|
|
const used = toDollars(Math.min(usedCents, limitCents));
|
|
const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0));
|
|
return {
|
|
used,
|
|
total: limitDollars,
|
|
remaining,
|
|
remainingPercentage: clampPercentage(100 - percentUsed),
|
|
resetAt,
|
|
unlimited: false,
|
|
};
|
|
};
|
|
|
|
const quotas: Record<string, UsageQuota> = {
|
|
Total: buildWindow(totalPercentUsed, totalSpendCents),
|
|
"Auto + Composer": buildWindow(autoPercentUsed),
|
|
API: buildWindow(apiPercentUsed),
|
|
};
|
|
|
|
return {
|
|
plan: "Cursor Pro",
|
|
quotas,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
plan: "Cursor",
|
|
message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for which providers have a `getUsageForProvider`
|
|
* implementation. Consumers like `genericQuotaFetcher.ts` reference this so
|
|
* the registration list can't drift from the switch statement below.
|
|
*
|
|
* If you add a new provider to the switch, add it here too.
|
|
*/
|
|
export const USAGE_FETCHER_PROVIDERS = [
|
|
"github",
|
|
"gemini-cli",
|
|
"antigravity",
|
|
"agy",
|
|
"claude",
|
|
"codex",
|
|
"cursor",
|
|
"kiro",
|
|
"amazon-q",
|
|
"kimi-coding",
|
|
"kimi-coding-apikey",
|
|
"qwen",
|
|
"qoder",
|
|
"glm",
|
|
"glm-cn",
|
|
"zai",
|
|
"glmt",
|
|
"opencode-go",
|
|
"minimax",
|
|
"minimax-cn",
|
|
"crof",
|
|
"bailian-coding-plan",
|
|
"nanogpt",
|
|
"deepseek",
|
|
"opencode",
|
|
"opencode-zen",
|
|
"xiaomi-mimo",
|
|
"vertex",
|
|
"vertex-partner",
|
|
"codebuddy-cn",
|
|
] as const;
|
|
|
|
export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number];
|
|
|
|
/**
|
|
* Get usage data for a provider connection
|
|
* @param {Object} connection - Provider connection with accessToken
|
|
* @returns {Promise<unknown>} Usage data with quotas
|
|
*/
|
|
export async function getUsageForProvider(
|
|
connection: UsageProviderConnection,
|
|
options: { forceRefresh?: boolean } = {}
|
|
) {
|
|
const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection;
|
|
|
|
switch (provider) {
|
|
case "github":
|
|
return await getGitHubUsage(accessToken, providerSpecificData);
|
|
case "gemini-cli":
|
|
return await getGeminiUsage(accessToken, providerSpecificData, projectId);
|
|
case "antigravity":
|
|
case "agy":
|
|
return await getAntigravityUsage(
|
|
provider,
|
|
accessToken,
|
|
providerSpecificData,
|
|
projectId,
|
|
id,
|
|
options
|
|
);
|
|
case "claude":
|
|
return await getClaudeUsage(accessToken);
|
|
case "codex":
|
|
return await getCodexUsage(accessToken, providerSpecificData);
|
|
case "cursor":
|
|
return await getCursorUsage(accessToken || "", providerSpecificData);
|
|
case "kiro":
|
|
case "amazon-q":
|
|
return await getKiroUsage(accessToken, providerSpecificData);
|
|
case "vertex":
|
|
case "vertex-partner":
|
|
return await getVertexUsage(id || "", provider);
|
|
case "kimi-coding":
|
|
return await getKimiUsage(accessToken);
|
|
case "kimi-coding-apikey":
|
|
return await getKimiUsage(undefined, apiKey);
|
|
case "qwen":
|
|
return await getQwenUsage(accessToken, providerSpecificData);
|
|
case "qoder":
|
|
return await getQoderUsage(accessToken);
|
|
case "glm":
|
|
case "glm-cn":
|
|
case "zai":
|
|
case "glmt":
|
|
return await getGlmUsage(apiKey || "", {
|
|
...(providerSpecificData || {}),
|
|
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
|
});
|
|
case "opencode-go":
|
|
return await getOpenCodeGoUsage(apiKey || "", providerSpecificData);
|
|
case "ollama-cloud":
|
|
return await getOllamaCloudUsage(providerSpecificData);
|
|
case "minimax":
|
|
case "minimax-cn":
|
|
return await getMiniMaxUsage(apiKey || "", provider);
|
|
case "crof":
|
|
return await getCrofUsage(apiKey || "");
|
|
case "bailian-coding-plan":
|
|
return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData);
|
|
case "nanogpt":
|
|
return await getNanoGptUsage(apiKey || "");
|
|
case "deepseek":
|
|
return await getDeepseekUsage(id || "", apiKey || "");
|
|
case "opencode":
|
|
case "opencode-zen":
|
|
return await getOpencodeUsage(id || "", apiKey || "");
|
|
case "xiaomi-mimo":
|
|
return await getXiaomiMimoUsage(id || "");
|
|
case "codebuddy-cn":
|
|
return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData);
|
|
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.
|
|
*/
|
|
/**
|
|
* GitHub Copilot Usage
|
|
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
|
|
*/
|
|
async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
|
try {
|
|
if (!accessToken) {
|
|
throw new Error("No GitHub access token available. Please re-authorize the connection.");
|
|
}
|
|
|
|
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
|
|
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
|
headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
if (response.status === 401 || response.status === 403) {
|
|
return {
|
|
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
|
|
};
|
|
}
|
|
throw new Error(`GitHub API error: ${error}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const dataRecord = toRecord(data);
|
|
|
|
// Handle different response formats (paid vs free)
|
|
if (dataRecord.quota_snapshots) {
|
|
// Paid plan format
|
|
const snapshots = toRecord(dataRecord.quota_snapshots);
|
|
const resetAt = parseResetTime(
|
|
getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate")
|
|
);
|
|
const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt);
|
|
const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt);
|
|
const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt);
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
|
|
if (shouldDisplayGitHubQuota(premiumQuota)) {
|
|
quotas.premium_interactions = premiumQuota;
|
|
}
|
|
if (shouldDisplayGitHubQuota(chatQuota)) {
|
|
quotas.chat = chatQuota;
|
|
}
|
|
if (shouldDisplayGitHubQuota(completionsQuota)) {
|
|
quotas.completions = completionsQuota;
|
|
}
|
|
|
|
return {
|
|
plan: inferGitHubPlanName(dataRecord, premiumQuota),
|
|
resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"),
|
|
quotas,
|
|
};
|
|
} else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) {
|
|
// Free/limited plan format. NOTE (#2876): the upstream field
|
|
// `limited_user_quotas[name]` is the *remaining* count for the month
|
|
// (it counts down toward 0 and resets on `limited_user_reset_date`),
|
|
// NOT the used count. The pre-3.8.6 implementation inverted this and
|
|
// showed "0% when not used / 100% when fully used" on the dashboard.
|
|
// Confirmed against three independent upstream parsers:
|
|
// - robinebers/openusage docs/providers/copilot.md (Free Tier table)
|
|
// - raycast/extensions agent-usage/src/copilot/fetcher.ts (inline comment)
|
|
// - looplj/axonhub frontend/src/components/quota-badges.tsx
|
|
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
|
|
const remainingQuotas = toRecord(dataRecord.limited_user_quotas);
|
|
const resetDate = getFieldValue(
|
|
dataRecord,
|
|
"limited_user_reset_date",
|
|
"limitedUserResetDate"
|
|
);
|
|
const resetAt = parseResetTime(resetDate);
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
|
|
const addLimitedQuota = (name: string) => {
|
|
const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0);
|
|
if (total <= 0) return null;
|
|
const remainingRaw = Math.max(0, toNumber(getFieldValue(remainingQuotas, name, name), 0));
|
|
const remaining = Math.min(remainingRaw, total);
|
|
const used = Math.max(total - remaining, 0);
|
|
quotas[name] = {
|
|
used,
|
|
total,
|
|
remaining,
|
|
remainingPercentage: clampPercentage((remaining / total) * 100),
|
|
unlimited: false,
|
|
resetAt,
|
|
};
|
|
return quotas[name];
|
|
};
|
|
|
|
const premiumQuota = addLimitedQuota("premium_interactions");
|
|
addLimitedQuota("chat");
|
|
addLimitedQuota("completions");
|
|
|
|
return {
|
|
plan: inferGitHubPlanName(dataRecord, premiumQuota),
|
|
resetDate,
|
|
quotas,
|
|
};
|
|
}
|
|
|
|
return { message: "GitHub Copilot connected. Unable to parse quota data." };
|
|
} catch (error) {
|
|
throw new Error(`Failed to fetch GitHub usage: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function formatGitHubQuotaSnapshot(
|
|
quota: unknown,
|
|
resetAt: string | null = null
|
|
): UsageQuota | null {
|
|
const source = toRecord(quota);
|
|
if (Object.keys(source).length === 0) return null;
|
|
|
|
const unlimited = source.unlimited === true;
|
|
const entitlement = toNumber(source.entitlement, Number.NaN);
|
|
const totalValue = toNumber(source.total, Number.NaN);
|
|
const remainingValue = toNumber(source.remaining, Number.NaN);
|
|
const usedValue = toNumber(source.used, Number.NaN);
|
|
const percentRemainingValue = toNumber(
|
|
getFieldValue(source, "percent_remaining", "percentRemaining"),
|
|
Number.NaN
|
|
);
|
|
|
|
let total = Number.isFinite(totalValue)
|
|
? Math.max(0, totalValue)
|
|
: Number.isFinite(entitlement)
|
|
? Math.max(0, entitlement)
|
|
: 0;
|
|
let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined;
|
|
let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined;
|
|
let remainingPercentage = Number.isFinite(percentRemainingValue)
|
|
? clampPercentage(percentRemainingValue)
|
|
: undefined;
|
|
|
|
if (used === undefined && total > 0 && remaining !== undefined) {
|
|
used = Math.max(total - remaining, 0);
|
|
}
|
|
|
|
if (remaining === undefined && total > 0 && used !== undefined) {
|
|
remaining = Math.max(total - used, 0);
|
|
}
|
|
|
|
if (remainingPercentage === undefined && total > 0 && remaining !== undefined) {
|
|
remainingPercentage = clampPercentage((remaining / total) * 100);
|
|
}
|
|
|
|
if (total <= 0 && remainingPercentage !== undefined) {
|
|
total = 100;
|
|
used = 100 - remainingPercentage;
|
|
remaining = remainingPercentage;
|
|
}
|
|
|
|
return {
|
|
used: Math.max(0, used ?? 0),
|
|
total,
|
|
remaining,
|
|
remainingPercentage,
|
|
resetAt,
|
|
unlimited,
|
|
};
|
|
}
|
|
|
|
function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string {
|
|
const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan");
|
|
const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku");
|
|
const planText = typeof rawPlan === "string" ? rawPlan.trim() : "";
|
|
const skuText = typeof rawSku === "string" ? rawSku.trim() : "";
|
|
const combined = `${skuText} ${planText}`.trim().toUpperCase();
|
|
const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas"));
|
|
const premiumTotal =
|
|
premiumQuota?.total ||
|
|
toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0);
|
|
const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0);
|
|
|
|
if (combined.includes("PRO+") || combined.includes("PRO_PLUS") || combined.includes("PROPLUS")) {
|
|
return "Copilot Pro+";
|
|
}
|
|
if (combined.includes("ENTERPRISE")) return "Copilot Enterprise";
|
|
if (combined.includes("BUSINESS")) return "Copilot Business";
|
|
if (combined.includes("STUDENT")) return "Copilot Student";
|
|
if (combined.includes("FREE")) return "Copilot Free";
|
|
if (combined.includes("PRO")) return "Copilot Pro";
|
|
|
|
if (premiumTotal >= 1400) return "Copilot Pro+";
|
|
if (premiumTotal >= 900) return "Copilot Enterprise";
|
|
if (premiumTotal >= 250) {
|
|
if (combined.includes("INDIVIDUAL")) return "Copilot Pro";
|
|
return "Copilot Business";
|
|
}
|
|
if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free";
|
|
|
|
if (skuText) {
|
|
const label = toDisplayLabel(skuText);
|
|
return label ? `Copilot ${label}` : "GitHub Copilot";
|
|
}
|
|
if (planText) {
|
|
const label = toDisplayLabel(planText);
|
|
return label ? `Copilot ${label}` : "GitHub Copilot";
|
|
}
|
|
return "GitHub Copilot";
|
|
}
|
|
|
|
// ── Gemini CLI subscription info cache ──────────────────────────────────────
|
|
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
|
// Key: accessToken → { data, fetchedAt }
|
|
const _geminiCliSubCache = new Map<string, SubscriptionCacheEntry>();
|
|
const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
/**
|
|
* Normalize a Cloud Code project value into a trimmed string (or null).
|
|
* The upstream `loadCodeAssist` endpoint returns the project either as a bare
|
|
* string or as an object of the form `{ id: "..." }`, and stored connection
|
|
* project ids can carry stray whitespace. Centralized here so the Gemini CLI
|
|
* usage path matches the executor/oauth normalization already shipped in
|
|
* `open-sse/executors/gemini-cli.ts` and `src/lib/oauth/services/gemini.ts`.
|
|
*/
|
|
function normalizeCloudCodeProjectId(project: unknown): string | null {
|
|
if (typeof project === "string") return project.trim() || null;
|
|
if (project && typeof project === "object") {
|
|
const candidate = (project as { id?: unknown }).id;
|
|
if (typeof candidate === "string") return candidate.trim() || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Gemini CLI Usage — fetch per-model quota from Cloud Code Assist API.
|
|
* Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com),
|
|
* so this follows the same pattern as getAntigravityUsage().
|
|
*/
|
|
async function getGeminiUsage(
|
|
accessToken?: string,
|
|
providerSpecificData?: JsonRecord,
|
|
connectionProjectId?: string
|
|
) {
|
|
if (!accessToken) {
|
|
return { plan: "Free", message: "Gemini CLI access token not available." };
|
|
}
|
|
|
|
try {
|
|
// #1271: the OAuth save path stores `projectId` on the connection (not always in
|
|
// `providerSpecificData`), and `loadCodeAssist` may return the project either as a
|
|
// bare string or wrapped in `{ id: "..." }`. Normalize both so the quota lookup
|
|
// reuses the stored project id and skips a redundant `loadCodeAssist` round-trip
|
|
// when it is already known.
|
|
let projectId =
|
|
normalizeCloudCodeProjectId(connectionProjectId) ||
|
|
normalizeCloudCodeProjectId(providerSpecificData?.projectId);
|
|
let plan = "Free";
|
|
|
|
if (!projectId) {
|
|
const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken);
|
|
projectId = normalizeCloudCodeProjectId(toRecord(subscriptionInfo).cloudaicompanionProject);
|
|
plan = getGeminiCliPlanLabel(subscriptionInfo);
|
|
}
|
|
|
|
if (!projectId) {
|
|
return {
|
|
plan,
|
|
message:
|
|
"Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.",
|
|
};
|
|
}
|
|
|
|
// Use retrieveUserQuota (same endpoint as Gemini CLI /stats command).
|
|
// Returns per-model buckets with remainingFraction and resetTime.
|
|
const response = await fetch(
|
|
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ project: projectId }),
|
|
signal: AbortSignal.timeout(10000),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
return { plan, message: `Gemini CLI quota error (${response.status}).` };
|
|
}
|
|
|
|
const data = await response.json();
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
|
|
const dataRecord = toRecord(data);
|
|
if (Array.isArray(dataRecord.buckets)) {
|
|
for (const bucketValue of dataRecord.buckets) {
|
|
const bucket = toRecord(bucketValue);
|
|
if (!bucket.modelId || bucket.remainingFraction == null) continue;
|
|
|
|
const remainingFraction = toNumber(bucket.remainingFraction, 0);
|
|
const remainingPercentage = remainingFraction * 100;
|
|
const QUOTA_NORMALIZED_BASE = 1000;
|
|
const total = QUOTA_NORMALIZED_BASE;
|
|
const remaining = Math.round(total * remainingFraction);
|
|
const used = Math.max(0, total - remaining);
|
|
|
|
quotas[String(bucket.modelId)] = {
|
|
used,
|
|
total,
|
|
resetAt: parseResetTime(bucket.resetTime),
|
|
remainingPercentage,
|
|
unlimited: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { plan, quotas };
|
|
} catch (error) {
|
|
return { message: `Gemini CLI error: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Gemini CLI subscription info (cached, 5 min TTL)
|
|
*/
|
|
async function getGeminiCliSubscriptionInfoCached(accessToken: string): Promise<unknown> {
|
|
const cacheKey = accessToken;
|
|
const cached = _geminiCliSubCache.get(cacheKey);
|
|
|
|
if (cached && Date.now() - cached.fetchedAt < GEMINI_CLI_CACHE_TTL_MS) {
|
|
return cached.data;
|
|
}
|
|
|
|
const data = await getGeminiCliSubscriptionInfo(accessToken);
|
|
_geminiCliSubCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Get Gemini CLI subscription info using correct headers.
|
|
*/
|
|
async function getGeminiCliSubscriptionInfo(accessToken: string): Promise<unknown | null> {
|
|
try {
|
|
const response = await fetch(GEMINI_CLI_USAGE_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
metadata: {
|
|
ideType: "IDE_UNSPECIFIED",
|
|
platform: "PLATFORM_UNSPECIFIED",
|
|
pluginType: "GEMINI",
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) return null;
|
|
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map Gemini CLI subscription tier to display label (same tiers as Antigravity).
|
|
*/
|
|
function getGeminiCliPlanLabel(subscriptionInfo: unknown): string {
|
|
return mapCodeAssistSubscriptionToPlanLabel(subscriptionInfo);
|
|
}
|
|
|
|
// ── Antigravity subscription info cache ──────────────────────────────────────
|
|
// ── Proactive TTL purging for the Gemini CLI subscription cache ────────────
|
|
// Passive TTL evicts on read; this interval proactively purges stale entries so
|
|
// keys accessed once and never again don't leak memory. The Antigravity caches
|
|
// + their own purge timer were split out into ./usage/antigravity.ts (god-file
|
|
// decomposition), so each module now owns its caches and their cleanup.
|
|
const _geminiCacheCleanupTimer = setInterval(
|
|
() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of _geminiCliSubCache) {
|
|
if (now - entry.fetchedAt > GEMINI_CLI_CACHE_TTL_MS) _geminiCliSubCache.delete(key);
|
|
}
|
|
},
|
|
5 * 60 * 1000
|
|
); // every 5 minutes
|
|
_geminiCacheCleanupTimer.unref?.(); // Don't prevent process exit
|
|
|
|
/**
|
|
* 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);
|
|
// Skip OAuth usage call while this token is cooling down from a recent 429
|
|
// (chat with the same token still works — only the quota endpoint is throttled).
|
|
if (isClaudeOauthUsageCoolingDown(accessToken)) {
|
|
const legacy = await getClaudeUsageLegacy(accessToken);
|
|
return { ...legacy, bootstrap: await bootstrapPromise };
|
|
}
|
|
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,
|
|
};
|
|
}
|
|
|
|
// Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected).
|
|
if (oauthResponse.status === 429) {
|
|
markClaudeOauthUsage429(accessToken);
|
|
}
|
|
|
|
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
|
|
console.warn(
|
|
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`
|
|
);
|
|
const legacy = await getClaudeUsageLegacy(accessToken);
|
|
return { ...legacy, bootstrap: await bootstrapPromise };
|
|
} catch (error) {
|
|
return {
|
|
message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`,
|
|
bootstrap: await bootstrapPromise,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Legacy Claude usage fetcher for API key / org admin users.
|
|
* Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints.
|
|
*/
|
|
async function getClaudeUsageLegacy(accessToken?: string) {
|
|
try {
|
|
const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
|
},
|
|
});
|
|
|
|
if (settingsResponse.ok) {
|
|
const settings = toRecord(await settingsResponse.json());
|
|
|
|
const organizationId =
|
|
typeof settings.organization_id === "string" ? settings.organization_id : "";
|
|
if (organizationId) {
|
|
const usageResponse = await fetch(
|
|
CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId),
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (usageResponse.ok) {
|
|
const usage = await usageResponse.json();
|
|
return {
|
|
plan: settings.plan || "Unknown",
|
|
organization: settings.organization_name,
|
|
quotas: usage,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
plan: settings.plan || "Unknown",
|
|
organization: settings.organization_name,
|
|
message: "Claude connected. Usage details require admin access.",
|
|
};
|
|
}
|
|
|
|
return { message: "Claude connected. Usage API requires admin permissions." };
|
|
} catch (error) {
|
|
return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
|
|
* IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding.
|
|
* No fallback to other workspaces - strict binding to user's selected workspace.
|
|
*/
|
|
async function getCodexUsage(
|
|
accessToken?: string,
|
|
providerSpecificData: Record<string, unknown> = {}
|
|
) {
|
|
try {
|
|
// Use persisted workspace ID from OAuth - NO FALLBACK
|
|
const accountId =
|
|
typeof providerSpecificData.workspaceId === "string"
|
|
? providerSpecificData.workspaceId
|
|
: null;
|
|
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
};
|
|
if (accountId) {
|
|
headers["chatgpt-account-id"] = accountId;
|
|
}
|
|
|
|
const response = await fetch(CODEX_CONFIG.usageUrl, {
|
|
method: "GET",
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
return {
|
|
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
|
};
|
|
}
|
|
throw new Error(`Codex API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
const { rateLimit, quotas } = buildCodexUsageQuotas(data);
|
|
|
|
return {
|
|
plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"),
|
|
limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")),
|
|
quotas,
|
|
};
|
|
} catch (error) {
|
|
return { message: `Failed to fetch Codex usage: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the Kiro usage result from a GetUsageLimits response. When the account returns no
|
|
* usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via
|
|
* GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the
|
|
* dashboard renders a blank quota card with no explanation (#3506). Exported for testing.
|
|
*/
|
|
export function buildKiroUsageResult(
|
|
data: JsonRecord
|
|
): { plan: string; quotas: Record<string, UsageQuota> } | { message: string } {
|
|
const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : [];
|
|
const quotaInfo: Record<string, UsageQuota> = {};
|
|
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
|
|
const overageEnabled = isKiroOverageEnabled(data);
|
|
|
|
usageList.forEach((breakdownValue: unknown) => {
|
|
const breakdown = toRecord(breakdownValue);
|
|
const resourceType =
|
|
typeof breakdown.resourceType === "string" ? breakdown.resourceType.toLowerCase() : "unknown";
|
|
const used = toNumber(breakdown.currentUsageWithPrecision, 0);
|
|
const total = toNumber(breakdown.usageLimitWithPrecision, 0);
|
|
|
|
quotaInfo[resourceType] = buildKiroQuota(used, total, resetAt, overageEnabled);
|
|
|
|
const freeTrialInfo = toRecord(breakdown.freeTrialInfo);
|
|
if (Object.keys(freeTrialInfo).length > 0) {
|
|
const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0);
|
|
const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0);
|
|
quotaInfo[`${resourceType}_freetrial`] = buildKiroQuota(
|
|
freeUsed,
|
|
freeTotal,
|
|
resetAt,
|
|
overageEnabled
|
|
);
|
|
}
|
|
});
|
|
|
|
if (Object.keys(quotaInfo).length === 0) {
|
|
return {
|
|
message:
|
|
"Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro",
|
|
quotas: quotaInfo,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for
|
|
* AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the
|
|
* region-matched endpoint and prefers a profile whose ARN is in the same region. Returns
|
|
* undefined when no profile is available (e.g. the org/token has no Kiro entitlement).
|
|
* Exported for testing.
|
|
*/
|
|
export async function discoverKiroProfileArn(
|
|
accessToken: string,
|
|
usageBaseUrl: string,
|
|
region: string
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const response = await fetch(usageBaseUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/x-amz-json-1.0",
|
|
"x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({ maxResults: 10 }),
|
|
// Don't let a hung profile lookup block the usage/quota refresh indefinitely.
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!response.ok) return undefined;
|
|
|
|
const data = toRecord(await response.json());
|
|
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
|
|
const normalizedRegion = region.toLowerCase();
|
|
const matched =
|
|
profiles.find((profile: unknown) => {
|
|
const arn = toRecord(profile).arn;
|
|
return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`);
|
|
}) || profiles[0];
|
|
const arn = toRecord(matched).arn;
|
|
return typeof arn === "string" && arn.length > 0 ? arn : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kiro (AWS CodeWhisperer) Usage
|
|
*/
|
|
async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
|
try {
|
|
let profileArn =
|
|
typeof providerSpecificData?.profileArn === "string"
|
|
? providerSpecificData.profileArn
|
|
: undefined;
|
|
|
|
// Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and
|
|
// endpoint must all match the region. Derive the region from the stored region (preferred)
|
|
// or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the
|
|
// legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions).
|
|
const regionFromArn = profileArn
|
|
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
|
|
: undefined;
|
|
const region =
|
|
(typeof providerSpecificData?.region === "string" &&
|
|
providerSpecificData.region.trim().toLowerCase()) ||
|
|
regionFromArn ||
|
|
"us-east-1";
|
|
const usageBaseUrl =
|
|
region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`;
|
|
|
|
// IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which
|
|
// previously caused the quota card to show nothing ("0 used"). Discover it on demand from
|
|
// ListAvailableProfiles (region-matched) so usage still resolves for those accounts.
|
|
if (!profileArn && accessToken) {
|
|
profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region);
|
|
}
|
|
|
|
if (!profileArn) {
|
|
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
|
|
}
|
|
|
|
// Kiro uses AWS CodeWhisperer GetUsageLimits API
|
|
const payload = {
|
|
origin: "AI_EDITOR",
|
|
profileArn: profileArn,
|
|
resourceType: "AGENTIC_REQUEST",
|
|
};
|
|
|
|
const response = await fetch(usageBaseUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/x-amz-json-1.0",
|
|
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider
|
|
// Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits
|
|
// routinely rejects with 401/403, even when /messages still works. Surface a clear
|
|
// "auth expired, chat may still work" message instead of a generic upstream-error blob
|
|
// so the quota card matches what users with legacy social-auth accounts already see.
|
|
// Inspired by https://github.com/decolua/9router/pull/620.
|
|
if (
|
|
(response.status === 401 || response.status === 403) &&
|
|
isSocialAuthKiroAccount(providerSpecificData)
|
|
) {
|
|
return {
|
|
message: "Kiro quota API authentication expired. Chat may still work.",
|
|
quotas: {},
|
|
};
|
|
}
|
|
const errorText = await response.text();
|
|
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
|
|
}
|
|
|
|
const data = toRecord(await response.json());
|
|
return buildKiroUsageResult(data);
|
|
} catch (error) {
|
|
throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Was this Kiro connection added via the Google/GitHub social-auth device flow
|
|
* (POST /api/oauth/kiro/social-exchange)? That route persists
|
|
* `{ authMethod: "imported", provider: "Google" | "Github" }` on the connection.
|
|
* Builder-ID / IDC / kiro-cli imports use different markers and should keep the
|
|
* existing throw-on-failure behavior.
|
|
*/
|
|
function isSocialAuthKiroAccount(providerSpecificData?: JsonRecord): boolean {
|
|
if (!providerSpecificData || providerSpecificData.authMethod !== "imported") return false;
|
|
const provider =
|
|
typeof providerSpecificData.provider === "string"
|
|
? providerSpecificData.provider.toLowerCase()
|
|
: "";
|
|
return provider === "google" || provider === "github";
|
|
}
|
|
|
|
/**
|
|
* Vertex AI — SELF-TRACKED spend.
|
|
*
|
|
* Vertex AI exposes no usage/quota API for an API key or Service Account (billing/credit balance
|
|
* lives behind the Cloud Billing API, which the proxy credential can't reach). Instead we report
|
|
* the USD that OmniRoute has spent through this connection since the account was added — summed
|
|
* from `usage_history` and priced via the backend pricing table. Returns a `message` (with the $
|
|
* figure) plus a `spend` quota entry so the limits cache persists it (a message-only result is
|
|
* treated as a transient error and not cached).
|
|
*/
|
|
async function getVertexUsage(connectionId: string, provider: string) {
|
|
if (!connectionId) {
|
|
return { message: "Vertex connected. Connection id unavailable for usage tracking." };
|
|
}
|
|
try {
|
|
const { getConnectionSpendUsdSinceAdded } = await import("@/lib/usage/usageStats");
|
|
const { costUsd, requests } = await getConnectionSpendUsdSinceAdded(provider, connectionId);
|
|
|
|
const spend: JsonRecord = {
|
|
used: Number(costUsd.toFixed(6)),
|
|
displayName: "Spend (USD)",
|
|
quotaSource: "localUsageHistory",
|
|
resetAt: null,
|
|
unlimited: false,
|
|
};
|
|
|
|
if (requests === 0) {
|
|
return {
|
|
plan: "Vertex AI",
|
|
message: "Vertex connected. No usage recorded through OmniRoute yet for this account.",
|
|
quotas: { spend },
|
|
};
|
|
}
|
|
|
|
const costStr = costUsd >= 1 ? costUsd.toFixed(2) : costUsd.toFixed(4);
|
|
return {
|
|
plan: "Vertex AI",
|
|
message: `$${costStr} used since this account was added \u00b7 ${requests} request${
|
|
requests === 1 ? "" : "s"
|
|
}`,
|
|
quotas: { spend },
|
|
};
|
|
} catch (error) {
|
|
return { message: `Vertex usage tracking error: ${(error as Error).message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map Kimi membership level to display name
|
|
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
|
|
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
|
|
*/
|
|
function getKimiPlanName(level: unknown): string {
|
|
if (!level) return "";
|
|
const normalizedLevel = String(level);
|
|
|
|
const levelMap = {
|
|
LEVEL_BASIC: "Moderato",
|
|
LEVEL_INTERMEDIATE: "Allegretto",
|
|
LEVEL_ADVANCED: "Allegro",
|
|
LEVEL_STANDARD: "Vivace",
|
|
};
|
|
|
|
return (
|
|
levelMap[normalizedLevel as keyof typeof levelMap] ||
|
|
normalizedLevel.replace("LEVEL_", "").toLowerCase()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Kimi Coding Usage - Fetch quota from Kimi API
|
|
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
|
|
*/
|
|
async function getKimiUsage(accessToken?: string, apiKey?: string) {
|
|
// Generate device info for headers (same as OAuth flow)
|
|
const deviceId = "kimi-usage-" + Date.now();
|
|
const platform = "omniroute";
|
|
const version = "2.1.2";
|
|
const deviceModel =
|
|
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
|
|
|
|
// API key auth takes precedence — Kimi's /usages endpoint accepts the same
|
|
// API key used for /messages (verified live: responds with
|
|
// authentication.method = METHOD_API_KEY). OAuth flow falls through to the
|
|
// Bearer + device-headers shape used by Kimi Coding OAuth.
|
|
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
|
|
|
|
const authHeaders: Record<string, string> = useApiKey
|
|
? { "x-api-key": apiKey as string }
|
|
: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"X-Msh-Platform": platform,
|
|
"X-Msh-Version": version,
|
|
"X-Msh-Device-Model": deviceModel,
|
|
"X-Msh-Device-Id": deviceId,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(KIMI_CONFIG.usageUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
...authHeaders,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
plan: "Kimi Coding",
|
|
message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`,
|
|
};
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(responseText);
|
|
} catch {
|
|
return {
|
|
plan: "Kimi Coding",
|
|
message: "Kimi Coding connected. Invalid JSON response from API.",
|
|
};
|
|
}
|
|
|
|
const quotas: Record<string, UsageQuota> = {};
|
|
const dataObj = toRecord(data);
|
|
|
|
// Parse Kimi usage response format
|
|
// Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] }
|
|
const usageObj = toRecord(dataObj.usage);
|
|
|
|
// Check for Kimi's actual usage fields (strings, not numbers)
|
|
const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0);
|
|
const usageUsed = toNumber(usageObj.used || usageObj.Used, 0);
|
|
const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0);
|
|
const usageResetTime =
|
|
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
|
|
|
|
if (usageLimit > 0) {
|
|
const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0;
|
|
|
|
quotas["Weekly"] = {
|
|
used: usageUsed,
|
|
total: usageLimit,
|
|
remaining: usageRemaining,
|
|
remainingPercentage: percentRemaining,
|
|
resetAt: parseResetTime(usageResetTime),
|
|
unlimited: false,
|
|
};
|
|
}
|
|
|
|
// Also parse limits array for rate limits
|
|
const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : [];
|
|
for (let i = 0; i < limitsArray.length; i++) {
|
|
const limitItem = toRecord(limitsArray[i]);
|
|
const window = toRecord(limitItem.window);
|
|
const detail = toRecord(limitItem.detail);
|
|
|
|
const limit = toNumber(detail.limit || detail.Limit, 0);
|
|
const remaining = toNumber(detail.remaining || detail.Remaining, 0);
|
|
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
|
|
|
|
if (limit > 0) {
|
|
quotas["Ratelimit"] = {
|
|
used: limit - remaining,
|
|
total: limit,
|
|
remaining,
|
|
remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0,
|
|
resetAt: parseResetTime(resetTime),
|
|
unlimited: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check for quota windows (Claude-like format with utilization) as fallback
|
|
const hasUtilization = (window: JsonRecord) =>
|
|
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
|
|
|
const createQuotaObject = (window: JsonRecord) => {
|
|
const remaining = safePercentage(window.utilization) as number;
|
|
const used = 100 - remaining;
|
|
return {
|
|
used,
|
|
total: 100,
|
|
remaining,
|
|
resetAt: parseResetTime(window.resets_at),
|
|
remainingPercentage: remaining,
|
|
unlimited: false,
|
|
};
|
|
};
|
|
|
|
if (hasUtilization(toRecord(dataObj.five_hour))) {
|
|
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
|
}
|
|
|
|
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
|
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
|
|
}
|
|
|
|
// Check for model-specific quotas
|
|
for (const [key, value] of Object.entries(dataObj)) {
|
|
const valueRecord = toRecord(value);
|
|
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
|
const modelName = key.replace("seven_day_", "");
|
|
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(quotas).length > 0) {
|
|
const userRecord = toRecord(dataObj.user);
|
|
const membershipLevel = toRecord(userRecord.membership).level;
|
|
const planName = getKimiPlanName(membershipLevel);
|
|
return {
|
|
plan: planName || "Kimi Coding",
|
|
quotas,
|
|
};
|
|
}
|
|
|
|
// No quota data in response
|
|
const userRecord = toRecord(dataObj.user);
|
|
const membershipLevel = toRecord(userRecord.membership).level;
|
|
const planName = getKimiPlanName(membershipLevel);
|
|
return {
|
|
plan: planName || "Kimi Coding",
|
|
message: "Kimi Coding connected. Usage tracked per request.",
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Qwen Usage
|
|
*/
|
|
async function getQwenUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
|
void accessToken;
|
|
try {
|
|
const resourceUrl = providerSpecificData?.resourceUrl;
|
|
if (!resourceUrl) {
|
|
return { message: "Qwen connected. No resource URL available." };
|
|
}
|
|
|
|
// Qwen may have usage endpoint at resource URL
|
|
return { message: "Qwen connected. Usage tracked per request." };
|
|
} catch (error) {
|
|
return { message: "Unable to fetch Qwen usage." };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Qoder Usage
|
|
*/
|
|
async function getQoderUsage(accessToken?: string) {
|
|
void accessToken;
|
|
try {
|
|
// Qoder may have usage endpoint
|
|
return { message: "Qoder connected. Usage tracked per request." };
|
|
} catch (error) {
|
|
return { message: "Unable to fetch Qoder usage." };
|
|
}
|
|
}
|
|
|
|
export const __testing = {
|
|
parseResetTime,
|
|
formatGitHubQuotaSnapshot,
|
|
inferGitHubPlanName,
|
|
getGeminiCliPlanLabel,
|
|
getAntigravityPlanLabel,
|
|
extractCodeAssistSubscriptionTier,
|
|
extractCodeAssistOnboardTierId,
|
|
getMiniMaxPlanLabel,
|
|
inferMiniMaxPlanLabelFromTotals,
|
|
getOpencodeUsage,
|
|
getClaudePlanLabel,
|
|
createQuotaFromUsage,
|
|
getMiniMaxQuotaResetAt,
|
|
isMiniMaxTextQuotaModel,
|
|
getMiniMaxSessionTotal,
|
|
getMiniMaxWeeklyTotal,
|
|
createMiniMaxQuotaFromCount,
|
|
createMiniMaxQuotaFromPercent,
|
|
getMiniMaxRemainingPercent,
|
|
getMiniMaxUsage,
|
|
getXiaomiMimoUsage,
|
|
getVertexUsage,
|
|
getMiniMaxAuthErrorMessage,
|
|
getMiniMaxErrorSummary,
|
|
mapCodeAssistSubscriptionToPlanLabel,
|
|
mapCodeAssistTierIdToLabel,
|
|
mapSubscriptionTierStringToPlanLabel,
|
|
toDisplayLabel,
|
|
getKiroUsage,
|
|
};
|