Files
OmniRoute/open-sse/services/usage.ts
Abhishek Sharma 95b2e53727 feat(usage): generic billing/quota for openai-compatible connections (#13673)
* feat(usage): generic billing/quota for openai-compatible connections (#13616)

Every other fetcher in services/usage hard-codes one upstream's URL, auth
and response shape, which works because those providers are known. An
openai-compatible connection can point at anything, and its id is minted
per connection -- so it can never be a member of USAGE_SUPPORTED_PROVIDERS
or a case in the dispatcher switch.

So the shape comes from the connection instead. `providerSpecificData.
quotaEndpoint` declares the url, auth mode, optional headers, and a mapping
of dot-paths onto UsageQuota:

    { "url": "...", "auth": "bearer",
      "quotas": { "credits": { "used": "$.data.used_usd",
                               "total": "$.data.limit_usd",
                               "currency": "USD" } } }

Dot/bracket paths (`$.a.b[0].c`) rather than full JSONPath, so the mapping
stays dependency-free and legible in a config field.

Three decisions worth stating:

- **An unresolvable mapping reports nothing, never 0/0.** A quota reading
  0 of 0 renders as fully exhausted, and an operator would act on that. A
  typo'd path must produce no card, not a fake outage.
- **The transport error is not echoed.** The url is operator-supplied and
  can carry a query-string secret; the message says "unreachable" and
  nothing more.
- **The capability is read off the connection, not the id.**
  `supportsProviderQuota` already takes the connection and already has a
  connection-shaped check (moonshot), so the gate goes there. A declared
  url with no `quotas` mapping does NOT count as supported: it can be
  fetched but can never yield a quota, and would leave a permanently empty
  card in Provider Limits.

Verified: 7 new tests; mutations each killed by the right one --
  let an unresolved mapping fall through to 0/0 -> that test alone fails
  echo the transport error                       -> that test alone fails
121 tests pass across this file, usage-families-split, provider-plugin-
manifest, provider-limits* and the quota-visibility suites (the drift
guard from #13134 included). eslint clean on all four files; the three
no-unused-vars errors in usage.ts are byte-identical on the base branch.

* fix(usage): bound the openai-compatible quota fetch with a timeout

An operator-configured quotaEndpoint that never responds would hang
getOpenAiCompatibleUsage()'s fetch() indefinitely, stalling that
connection's Provider Limits sync. Same 15s bound as the other
fetchers in this directory (grokResetCredits.ts's FETCH_TIMEOUT_MS).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(usage): pin the openai-compatible quota fetch timeout

A quota endpoint that accepts the connection and never answers must be
aborted by the fetch signal instead of hanging the Provider Limits sync.
Fails without the AbortSignal.timeout() bound, passes with it.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: abhisheksharma2411 <abhisheksharma2411@users.noreply.github.com>
2026-09-18 13:14:06 -03:00

278 lines
11 KiB
TypeScript

/**
* Usage Fetcher - Get usage data from provider APIs
*
* This module is the dispatcher (orchestration) layer: it maps a provider name
* to the per-provider usage fetcher leaf under `./usage/<provider>.ts` and
* shapes the connection into the args each leaf expects. The provider-specific
* fetcher/parser logic itself lives in those leaves so this file stays flat.
* External consumers import `getUsageForProvider` / `USAGE_FETCHER_PROVIDERS`
* (and the re-exported helpers) from here — the leaf split is an internal
* implementation detail.
*/
import {
extractCodeAssistOnboardTierId,
extractCodeAssistSubscriptionTier,
} from "./codeAssistSubscription.ts";
import { toDisplayLabel } from "./usage/scalars.ts";
import { 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";
import { getCursorUsage } from "./usage/cursor.ts";
import { getKimiUsage } from "./usage/kimi.ts";
import { getCodexUsage } from "./usage/codex.ts";
import { getClaudeUsage, getClaudePlanLabel } from "./usage/claude.ts";
import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts";
// Re-exported para os testes kiro-* (importam de services/usage).
export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts";
import { getAdobeFireflyUsage } from "./usage/adobeFirefly.ts";
import { getOpenrouterUsage } from "./usage/openrouter.ts";
import { getOpenAiCompatibleUsage } from "./usage/openaiCompatible.ts";
import { getLlmgatewayUsage } from "./usage/llmgateway.ts";
import { getOllamaCloudUsage } from "./opencodeOllamaUsage.ts";
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts";
import { getPromptQlUsage } from "./usage/promptql.ts";
import { getHyperAgentUsage } from "./usage/hyperagent.ts";
import { getGitHubUsage, formatGitHubQuotaSnapshot, inferGitHubPlanName } from "./usage/github.ts";
import { getCrofUsage } from "./usage/crof.ts";
import { getNanoGptUsage } from "./usage/nanogpt.ts";
import { getQoderUsage, parseQoderUserStatusUsage } from "./usage/qoder.ts";
// Re-exported para o teste qoder-usage-quota (importa parseQoderUserStatusUsage de services/usage).
export { parseQoderUserStatusUsage } from "./usage/qoder.ts";
import { getOpencodeUsage } from "./usage/opencode.ts";
import { getDeepseekUsage } from "./usage/deepseek.ts";
import { getMoonshotOpenPlatformUsage } from "./moonshotQuotaFetcher.ts";
import { isMoonshotOpenPlatformConnection } from "./usage/moonshotOpenPlatform.ts";
import { getDevinCliUsage } from "./usage/devinCli.ts";
import { getBailianCodingPlanUsage } from "./usage/bailian.ts";
import { getVertexUsage } from "./usage/vertex.ts";
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts";
import { getXaiUsage } from "./usage/xai.ts";
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
import { getGrokCliUsage } from "./usage/grokCli.ts";
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
import { getVolcenginePlanUsage } from "./usage/volcenginePlan.ts";
import { getCommandCodeUsage } from "./usage/command-code.ts";
import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts";
import { getConolUsage } from "./conolUsage.ts";
import { getAgentrouterUsage } from "./usage/agentrouter.ts";
import { getKilocodeUsage } from "./usage/kilocode.ts";
type JsonRecord = Record<string, unknown>;
type UsageProviderConnection = JsonRecord & {
id?: string;
provider?: string;
accessToken?: string;
apiKey?: string;
providerSpecificData?: JsonRecord;
projectId?: string;
email?: string;
};
/**
* Single source of truth for which providers have a `getUsageForProvider`
* implementation — consumers like `genericQuotaFetcher.ts` reference it so the
* registration list can't drift from the switch statement below.
*
* If you add a new provider to the switch, add it to the list too. The list now lives in
* `./usage/fetcherProviders.ts` (a zero-dependency leaf) so that consumers which only need
* to know *whether* a fetcher exists — the provider-plugin manifest — can read it without
* importing this dispatcher. Re-exported here so this stays the public import path.
*/
export { USAGE_FETCHER_PROVIDERS } from "./usage/fetcherProviders.ts";
export type { UsageFetcherProvider } from "./usage/fetcherProviders.ts";
/**
* 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;
if (isMoonshotOpenPlatformConnection(connection)) {
return await getMoonshotOpenPlatformUsage(connection);
}
// openai-compatible-* ids are generated per connection, so they can never
// appear in the switch below or in USAGE_FETCHER_PROVIDERS. The connection
// itself declares where its quota lives (#13616); without that declaration
// this returns a message and the sync treats it as "nothing to show", exactly
// as it did before.
if (typeof provider === "string" && provider.startsWith("openai-compatible-")) {
return await getOpenAiCompatibleUsage(apiKey, providerSpecificData);
}
switch (provider) {
case "github":
return await getGitHubUsage(accessToken, providerSpecificData);
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":
case "kimi-coding-apikey":
return await getKimiUsage(accessToken, apiKey, providerSpecificData);
case "qoder":
// Qoder PATs live in `apiKey` (decrypted) or `providerSpecificData.qoderPat`,
// never in `accessToken`.
return await getQoderUsage(apiKey, providerSpecificData);
case "glm":
case "glm-cn":
case "zai":
case "glmt":
return await getGlmUsage(apiKey || "", {
...(providerSpecificData || {}),
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
});
case "opencode-go":
return await getOpencodeUsage(id || "", apiKey || "");
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 "qwen-cloud-token-plan":
return await getQwenTokenPlanUsage(id || "", apiKey || "", providerSpecificData);
case "nanogpt":
return await getNanoGptUsage(apiKey || "");
case "deepseek":
return await getDeepseekUsage(id || "", apiKey || "");
case "moonshot":
case "kimi":
return await getMoonshotOpenPlatformUsage(connection);
case "openrouter":
return await getOpenrouterUsage(id || "", apiKey || "", providerSpecificData);
case "llmgateway":
return await getLlmgatewayUsage(id || "", apiKey || "");
case "opencode":
case "opencode-zen":
return await getOpencodeUsage(id || "", apiKey || "");
case "xiaomi-mimo":
return await getXiaomiMimoUsage(id || "");
case "xai":
return await getXaiUsage(id || "");
case "xai-oauth":
case "xao":
return await getXaiOauthUsage(id || "", accessToken, connection);
case "grok-cli":
return await getGrokCliUsage(accessToken);
case "codebuddy-cn":
return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData);
case "promptql":
case "pql":
// DDN lux JWTs carry projectId only in JWT aud; connection.projectId may be set by sync.
return await getPromptQlUsage(apiKey || accessToken, providerSpecificData, projectId);
case "adobe-firefly":
case "firefly":
// Cookie or IMS JWT in apiKey/accessToken → GET firefly.adobe.io/v1/credits/balance
return await getAdobeFireflyUsage(apiKey, accessToken, providerSpecificData);
case "hyperagent":
case "ha":
return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData);
case "firecrawl":
return await getFirecrawlUsage(id || "", apiKey, connection);
case "volcengine-agent-plan":
case "volcengine-coding-plan":
return await getVolcenginePlanUsage(apiKey || "", provider, providerSpecificData);
case "command-code":
return await getCommandCodeUsage(apiKey || accessToken || "");
case "conol-web":
case "cnl":
return await getConolUsage(apiKey || accessToken, providerSpecificData);
case "agentrouter":
return await getAgentrouterUsage(id, connection);
case "kilocode":
return await getKilocodeUsage(id, connection);
case "devin-cli":
// Devin CLI tokens live in `accessToken` (oauth import) or `apiKey`.
return await getDevinCliUsage(apiKey || accessToken);
default:
return { message: `Usage API not implemented for ${provider}` };
}
}
export const __testing = {
parseResetTime,
parseQoderUserStatusUsage,
formatGitHubQuotaSnapshot,
inferGitHubPlanName,
getAntigravityPlanLabel,
extractCodeAssistSubscriptionTier,
extractCodeAssistOnboardTierId,
getMiniMaxPlanLabel,
inferMiniMaxPlanLabelFromTotals,
getOpencodeUsage,
getClaudePlanLabel,
createQuotaFromUsage,
getMiniMaxQuotaResetAt,
isMiniMaxTextQuotaModel,
getMiniMaxSessionTotal,
getMiniMaxWeeklyTotal,
createMiniMaxQuotaFromCount,
createMiniMaxQuotaFromPercent,
getMiniMaxRemainingPercent,
getMiniMaxUsage,
getXiaomiMimoUsage,
getXaiUsage,
getXaiOauthUsage,
getFirecrawlUsage,
getCommandCodeUsage,
getVertexUsage,
getMiniMaxAuthErrorMessage,
getMiniMaxErrorSummary,
mapCodeAssistSubscriptionToPlanLabel,
mapCodeAssistTierIdToLabel,
mapSubscriptionTierStringToPlanLabel,
toDisplayLabel,
getKiroUsage,
getKilocodeUsage,
};