Files
OmniRoute/open-sse/services/usage/openrouter.ts
Mr White 5a0a131bc7 feat(usage): devin-cli agentic quota + openrouter credits in Provider Limits (#12256)
* feat(usage): devin-cli agentic quota + openrouter credits in Provider Limits

Two provider families with live quota APIs were missing from the Provider
Limits dashboard because their list entries were absent:

- devin-cli: new usage leaf querying the Codeium seat-management Connect API
  (exa.seat_management_pb.SeatManagementService/GetUserStatus, protobuf over
  POST with the raw `Basic <token>-<token>` auth header the CLI itself uses).
  Surfaces the plan name plus daily/weekly agentic quota percentages with
  reset timestamps from the GetUserStatus plan_status payload, via a minimal
  hand-rolled protobuf encoder/reader (no proto dependency warranted for two
  fixed messages).
- openrouter: the /key + /credits quota fetcher (#6842) was already wired
  into the dispatcher but gated out of the bulk sync — add it to
  USAGE_SUPPORTED_PROVIDERS and PROVIDER_LIMITS_APIKEY_PROVIDERS so key
  limits and account credits actually surface.

* fix(build): externalize tiktoken so tiktoken_bg.wasm resolves at runtime

The vendored ChatGPT Web connector v4.0.7 (#12181) imports tiktoken
(get_encoding) at module level. tiktoken's node build reads
tiktoken_bg.wasm via a __dirname-relative fs.readFileSync during import;
when Next bundles the package the wasm asset is not traced into the server
chunk, and page-data collection for every route reaching the tokenizer
(e.g. /api/providers/[id]/chatgpt-web-codex-doctor) aborts with
"Missing tiktoken_bg.wasm" — breaking the whole standalone build.

Externalize it like the other runtime-resolved native/wasm packages
(sql.js, sqlite-vec, better-sqlite3): the require stays at runtime, where
node_modules/tiktoken/tiktoken_bg.wasm resolves normally.

* fix(openrouter): /credits balance survives a /key failure

OpenRouter is credit-based, not subscription-based: the authoritative
remaining-credits signal is GET /api/v1/credits (total_credits -
total_usage, the documented "get remaining credits" endpoint), while the
/key limit fields are optional per-key caps that most accounts never set.

fetchOpenrouterQuota previously treated /key as mandatory — any /key
failure (429 rate limit, transient error, unexpected shape) discarded the
whole payload and the Usage dashboard showed "OpenRouter (usage endpoint
unreachable)" even though /credits was reachable. Now:

- /key unavailable + /credits OK → credits-only quota (creditBalance =
  total_credits - total_usage) instead of null
- /key 401/403 alone no longer means an invalid token; only a double
  auth-rejection (both endpoints) does
- null is returned only when both endpoints fail, and the dashboard label
  reflects that ("credits endpoint unreachable")

* fix(openrouter): render AI Credits as a USD credit count in Provider Limits

The Provider Limits card's dollar renderer only activates on
isCredits/creditCount rows (QuotaCardExpanded), but openrouter went through
parseGeneric — which drops `currency` and never sets those flags — so the
credits balance rendered as a meaningless "100% left" (the unlimited-credits
row is always 100%) instead of the actual credit count.

Route openrouter's `credits` quota through buildCreditsQuota() like the
DeepSeek/AgentRouter credits rows: label "AI Credits", dollar-formatted
balance. Free-tier request windows keep the generic percentage treatment.

* fix(usage): document DEVIN_SEAT_API_URL and split quota parsers

Keep fetchOpenrouterQuota and decodeProtoFields under the complexity
ratchets, and add the seat-management URL to the env/docs contract.

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

* test(usage): drop duplicated GLM quota-ordering test in provider-limits-ui

* test(usage): drop stale openrouter ACCEPTED_DIVERGENCE

OpenRouter is now in both USAGE_FETCHER_PROVIDERS and
USAGE_SUPPORTED_PROVIDERS, so the recorded aggregator divergence
is no longer real. Add the changelog fragment.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-02 00:02:15 -03:00

91 lines
3.2 KiB
TypeScript

/**
* usage/openrouter.ts — OpenRouter usage-dashboard builder (#6842)
*
* Extracted as a leaf module (not inlined in usage.ts) so the god-file stays
* flat: this owns turning an OpenrouterQuota into the `UsageQuota` shape the
* Dashboard → Usage page renders, mirroring getDeepseekUsage's pattern.
*/
import { fetchOpenrouterQuota, type OpenrouterQuota } from "../openrouterQuotaFetcher.ts";
import { getFreeWindowStatus, resolveAccountKey } from "../openrouterFreeWindow.ts";
import { type UsageQuota } from "./quota.ts";
function buildCreditsQuota(quota: OpenrouterQuota): UsageQuota | null {
if (quota.limit === null && quota.creditBalance === null) return null;
return {
used: quota.limit !== null ? quota.limit - (quota.limitRemaining ?? quota.limit) : 0,
total: quota.limit ?? 0,
remaining: quota.creditBalance ?? undefined,
remainingPercentage: quota.limit !== null ? Math.round((1 - quota.percentUsed) * 100) : 100,
resetAt: quota.resetAt ?? null,
unlimited: quota.limit === null,
currency: "USD",
};
}
function buildFreeWindowQuota(connectionId: string, connection?: Record<string, unknown>) {
const accountKey = resolveAccountKey(connectionId, connection);
const status = getFreeWindowStatus(accountKey);
const dailyQuota: UsageQuota = {
used: status.dailyUsed,
total: status.dailyLimit,
remaining: status.dailyRemaining,
remainingPercentage: Math.round((status.dailyRemaining / status.dailyLimit) * 100),
resetAt: status.dailyResetAt,
unlimited: false,
displayName: "Free-tier requests (daily)",
};
const rpmQuota: UsageQuota = {
used: status.rpmUsed,
total: status.rpmLimit,
remaining: status.rpmRemaining,
remainingPercentage: Math.round((status.rpmRemaining / status.rpmLimit) * 100),
resetAt: null,
unlimited: false,
displayName: "Free-tier requests (per minute)",
};
return { dailyQuota, rpmQuota };
}
/**
* OpenRouter Usage — merges the /key + /credits polling fetcher with the
* locally-tracked `:free`-variant request window into one usage payload.
*/
export async function getOpenrouterUsage(
connectionId: string,
apiKey: string,
providerSpecificData?: Record<string, unknown> | null
) {
if (!apiKey) {
return { message: "OpenRouter API key not available. Add a key to view usage." };
}
const connection = { apiKey, providerSpecificData: providerSpecificData ?? {} };
const quota = (await fetchOpenrouterQuota(connectionId, connection)) as OpenrouterQuota | null;
const quotas: Record<string, UsageQuota> = {};
const { dailyQuota, rpmQuota } = buildFreeWindowQuota(connectionId, connection);
quotas.free_daily = dailyQuota;
quotas.free_rpm = rpmQuota;
if (!quota) {
return {
plan: "OpenRouter (credits endpoint unreachable)",
quotas,
message: "OpenRouter connected. /key and /credits both unreachable — no balance data.",
};
}
const creditsQuota = buildCreditsQuota(quota);
if (creditsQuota) quotas.credits = creditsQuota;
return {
plan: quota.isFreeTier ? "OpenRouter (Free Tier)" : "OpenRouter",
quotas,
isFreeTier: quota.isFreeTier,
usageDaily: quota.usageDaily,
usageWeekly: quota.usageWeekly,
usageMonthly: quota.usageMonthly,
};
}