mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
chore(usage): decompose services/usage.ts into per-provider usage/* leaves (999 → 253) (#8545)
* chore(usage): extract crof, nanogpt, qoder, opencode, deepseek, bailian, vertex, xiaomi-mimo, xai, github usage fetchers into usage/* leaves Decompose services/usage.ts (god-file phase 1): move the remaining per-provider usage fetcher/parser logic into co-located leaves under open-sse/services/usage/ so usage.ts becomes a thin dispatcher (imports + USAGE_FETCHER_PROVIDERS + getUsageForProvider switch + __testing re-exports). New leaves (each a pure data transform or independent fetcher, no orchestration): - usage/github.ts getGitHubUsage, formatGitHubQuotaSnapshot, inferGitHubPlanName, shouldDisplayGitHubQuota - usage/crof.ts getCrofUsage - usage/nanogpt.ts getNanoGptUsage - usage/qoder.ts getQoderUsage, parseQoderUserStatusUsage - usage/opencode.ts getOpencodeUsage - usage/deepseek.ts getDeepseekUsage - usage/bailian.ts getBailianCodingPlanUsage - usage/vertex.ts getVertexUsage - usage/xiaomi-mimo.ts getXiaomiMimoUsage - usage/xai.ts getXaiUsage usage.ts re-exports parseQoderUserStatusUsage (named) and threads every helper the existing __testing contract exposes (usage-utils / usage-service-hardening / qoder-usage-quota / xiaomi-mimo-selftrack / xai-usage / vertex-spend suites read them from services/usage). External importers unchanged. open-sse/services/usage.ts: 1065 -> 256 lines (cap 800). npm run check:file-size: OK. typecheck:core: OK. eslint: clean. check:cycles: OK. Added characterization tests (tests/unit/usage-<provider>-split.test.ts) that import each leaf directly and pin its export surface + key edges, mirroring the existing usage-quota-core-split / usage-scalars-split pattern. * docs(changelog): add fragment for this PR
This commit is contained in:
1
changelog.d/maintenance/decomp-usage-service.md
Normal file
1
changelog.d/maintenance/decomp-usage-service.md
Normal file
@@ -0,0 +1 @@
|
||||
- chore(usage): decompose `open-sse/services/usage.ts` (999 → 253 lines) by extracting the crof, nanogpt, qoder, opencode, deepseek, bailian, vertex, xiaomi-mimo, xai and github usage fetchers into `usage/*` leaves — behavior-preserving move, the file is now a thin provider→fetcher dispatcher and the public import surface is unchanged
|
||||
@@ -1,35 +1,21 @@
|
||||
/**
|
||||
* 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 { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts";
|
||||
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 { getOpenrouterUsage } from "./usage/openrouter.ts";
|
||||
import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts";
|
||||
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts";
|
||||
import { getPromptQlUsage } from "./usage/promptql.ts";
|
||||
import { getHyperAgentUsage } from "./usage/hyperagent.ts";
|
||||
import {
|
||||
extractCodeAssistOnboardTierId,
|
||||
extractCodeAssistSubscriptionTier,
|
||||
} from "./codeAssistSubscription.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { resolveQoderJobToken } from "./qoderCli.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 { toDisplayLabel } from "./usage/scalars.ts";
|
||||
import { parseResetTime, createQuotaFromUsage } from "./usage/quota.ts";
|
||||
import {
|
||||
getMiniMaxUsage,
|
||||
getMiniMaxPlanLabel,
|
||||
@@ -62,13 +48,23 @@ import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./us
|
||||
// Re-exported para os testes kiro-* (importam de services/usage).
|
||||
export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts";
|
||||
import { getAdobeFireflyUsage } from "./usage/adobeFirefly.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 NANOGPT_CONFIG = {
|
||||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||||
};
|
||||
import { getOpenrouterUsage } from "./usage/openrouter.ts";
|
||||
import { getOllamaCloudUsage, getOpenCodeGoUsage } 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 { getBailianCodingPlanUsage } from "./usage/bailian.ts";
|
||||
import { getVertexUsage } from "./usage/vertex.ts";
|
||||
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts";
|
||||
import { getXaiUsage } from "./usage/xai.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type UsageProviderConnection = JsonRecord & {
|
||||
@@ -81,429 +77,6 @@ type UsageProviderConnection = JsonRecord & {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 Token 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: "Alibaba Token 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 Token Plan",
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt: bailianQuota.resetAt,
|
||||
unlimited: false,
|
||||
displayName: "Alibaba Token Plan",
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Alibaba Token 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}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI (Grok) — SELF-TRACKED cumulative usage.
|
||||
*
|
||||
* xAI has no public per-account quota API (the billing console at console.x.ai
|
||||
* requires a session cookie, not an API key), so — exactly like the Xiaomi
|
||||
* MiMo self-track pattern above — OmniRoute sums the tokens it itself routed
|
||||
* to this connection (from `usage_history`) instead of calling an upstream
|
||||
* endpoint. Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the
|
||||
* aggregate is reported as `unlimited: true` with `remaining: 100` — this
|
||||
* renders the dashboard's green "100%" badge instead of a meaningless
|
||||
* progress bar against a `total: 0`.
|
||||
*/
|
||||
async function getXaiUsage(connectionId: string) {
|
||||
if (!connectionId) {
|
||||
return { message: "xAI: connection id unavailable for self-tracked usage." };
|
||||
}
|
||||
try {
|
||||
const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats");
|
||||
const used = getMonthlyProviderTokensForConnection("xai", connectionId);
|
||||
return {
|
||||
plan: "xAI / Grok (OmniRoute-tracked)",
|
||||
quotas: {
|
||||
monthly: {
|
||||
used,
|
||||
total: 0,
|
||||
remaining: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: null,
|
||||
unlimited: true,
|
||||
} as UsageQuota,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `xAI 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}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for which providers have a `getUsageForProvider`
|
||||
* implementation. Consumers like `genericQuotaFetcher.ts` reference this so
|
||||
@@ -633,11 +206,7 @@ export async function getUsageForProvider(
|
||||
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
|
||||
);
|
||||
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
|
||||
@@ -650,387 +219,6 @@ export async function getUsageForProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Qoder Usage
|
||||
*
|
||||
* Qoder exposes account plan + quota at `openapi.qoder.sh/api/v3/user/status`,
|
||||
* the same endpoint the official qodercli reads for its usage badge. The status
|
||||
* call needs a short-lived `jt-*` job token, so we exchange the PAT the same way
|
||||
* the chat/validation paths do (see qoderCli.ts::resolveQoderJobToken).
|
||||
*/
|
||||
const QODER_USER_STATUS_URL = "https://openapi.qoder.sh/api/v3/user/status";
|
||||
|
||||
/** Human-readable plan label from Qoder's `PLAN_TIER_*` enum / `userTag`. */
|
||||
function prettifyQoderPlan(planRaw: string, userTag: string): string {
|
||||
const tag = String(userTag || "").trim();
|
||||
if (tag) return tag;
|
||||
const stripped = String(planRaw || "")
|
||||
.trim()
|
||||
.replace(/^PLAN_TIER_/i, "");
|
||||
return stripped ? toTitleCase(stripped) : "Qoder";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Qoder `/user/status` payload into the shared `{ plan, quotas }` shape.
|
||||
* Pure (no I/O) so it can be unit-tested against captured payloads.
|
||||
*/
|
||||
export function parseQoderUserStatusUsage(status: JsonRecord): {
|
||||
plan: string;
|
||||
quotas: Record<string, UsageQuota>;
|
||||
} {
|
||||
const userType = String(status.userType || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const planLabel = prettifyQoderPlan(String(status.plan || ""), String(status.userTag || ""));
|
||||
const isExceeded = status.isQuotaExceeded === true;
|
||||
const quotaNum = toNumber(status.quota, 0);
|
||||
const resetAt = parseResetTime(status.nextResetAt);
|
||||
// Team/enterprise seats draw from a pooled org quota rather than a per-user
|
||||
// counter, so `quota: 0` there means "pooled", not "exhausted".
|
||||
const isPooled = userType === "teams" || userType === "enterprise";
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
if (isExceeded) {
|
||||
// Genuinely out of quota — remainingPercentage 0 lets routing skip it until reset.
|
||||
quotas["Quota"] = {
|
||||
used: quotaNum,
|
||||
total: quotaNum,
|
||||
remaining: 0,
|
||||
remainingPercentage: 0,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: "Quota exceeded",
|
||||
};
|
||||
} else if (isPooled || quotaNum <= 0) {
|
||||
// Pooled/unlimited seat — MUST report 100% remaining. The quota→routing
|
||||
// conversion (src/domain/quotaCache.ts) ignores `unlimited` and would treat a
|
||||
// `total: 0` window as 0% (i.e. exhausted), wrongly 429-ing every request.
|
||||
quotas["Plan"] = {
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining: 0,
|
||||
remainingPercentage: 100,
|
||||
resetAt,
|
||||
unlimited: true,
|
||||
displayName: `${planLabel} plan · pooled quota`,
|
||||
};
|
||||
} else {
|
||||
quotas["Requests"] = {
|
||||
used: 0,
|
||||
total: quotaNum,
|
||||
remaining: quotaNum,
|
||||
remainingPercentage: 100,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: `${quotaNum} requests left`,
|
||||
};
|
||||
}
|
||||
|
||||
return { plan: planLabel, quotas };
|
||||
}
|
||||
|
||||
async function getQoderUsage(apiKey?: string, providerSpecificData?: JsonRecord) {
|
||||
const token = (apiKey || "").trim() || String(providerSpecificData?.qoderPat || "").trim();
|
||||
if (!token) {
|
||||
return { message: "Qoder connected. Add a Personal Access Token to view quota." };
|
||||
}
|
||||
|
||||
let jobToken: string;
|
||||
try {
|
||||
jobToken = await resolveQoderJobToken(token);
|
||||
} catch {
|
||||
return { message: "Qoder connected. Unable to resolve a usage token." };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(QODER_USER_STATUS_URL, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${jobToken}`, Accept: "application/json" },
|
||||
// @ts-ignore — AbortSignal.timeout is available on the Node runtime
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Qoder connected. Unable to fetch usage: ${sanitizeErrorMessage((error as Error).message)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: "Qoder connected. The token was rejected by the usage API — re-test the connection.",
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { message: `Qoder connected. Usage API returned HTTP ${response.status}.` };
|
||||
}
|
||||
|
||||
let status: JsonRecord;
|
||||
try {
|
||||
status = toRecord(await response.json());
|
||||
} catch {
|
||||
return { message: "Qoder connected. Unable to parse the usage response." };
|
||||
}
|
||||
|
||||
return parseQoderUserStatusUsage(status);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
parseResetTime,
|
||||
parseQoderUserStatusUsage,
|
||||
|
||||
50
open-sse/services/usage/bailian.ts
Normal file
50
open-sse/services/usage/bailian.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* usage/bailian.ts — Bailian (Alibaba Token Plan) usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Bailian family —
|
||||
* delegates to the dedicated bailianQuotaFetcher and shapes the triple-window
|
||||
* (5h, weekly, monthly) worst-case quota into the standard usage response.
|
||||
* Depends only on the sibling scalar/quota leaves + fetchBailianQuota — no host
|
||||
* coupling — so it lives as a co-located provider leaf. usage.ts imports
|
||||
* getBailianCodingPlanUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts";
|
||||
|
||||
/**
|
||||
* Bailian (Alibaba Token Plan) Usage
|
||||
* Fetches triple-window quota (5h, weekly, monthly) and returns worst-case.
|
||||
*/
|
||||
export 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: "Alibaba Token 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 Token Plan",
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt: bailianQuota.resetAt,
|
||||
unlimited: false,
|
||||
displayName: "Alibaba Token Plan",
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Alibaba Token Plan error: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
123
open-sse/services/usage/crof.ts
Normal file
123
open-sse/services/usage/crof.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* usage/crof.ts — CrofAI usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the CrofAI family —
|
||||
* the /usage_api/ endpoint config and the getCrofUsage fetcher that surfaces the
|
||||
* daily `usable_requests` subscription bucket plus the USD `credits` balance as
|
||||
* separate quotas. Depends only on the sibling scalar/quota leaves — no host
|
||||
* coupling — so it lives as a co-located provider leaf. usage.ts imports
|
||||
* getCrofUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { toRecord, toNumber } from "./scalars.ts";
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
// Quota / usage upstream URLs (overridable for testing or relays).
|
||||
const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/";
|
||||
|
||||
// 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.
|
||||
export 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 };
|
||||
}
|
||||
62
open-sse/services/usage/deepseek.ts
Normal file
62
open-sse/services/usage/deepseek.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* usage/deepseek.ts — DeepSeek usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the DeepSeek family —
|
||||
* delegates to the dedicated deepseekQuotaFetcher and shapes the balance result
|
||||
* into the standard `{ plan, quotas }` usage response (all balances surfaced as
|
||||
* credits-style entries). Depends only on the sibling scalar/quota leaves +
|
||||
* fetchDeepseekQuota — no host coupling — so it lives as a co-located provider
|
||||
* leaf. usage.ts imports getDeepseekUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { fetchDeepseekQuota, type DeepseekQuota } from "../deepseekQuotaFetcher.ts";
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
|
||||
/**
|
||||
* DeepSeek Usage
|
||||
* Fetches balance from the DeepSeek balance API.
|
||||
* Returns all balances (USD and CNY) as "credits" for credits-style UI display.
|
||||
*/
|
||||
export 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}` };
|
||||
}
|
||||
}
|
||||
230
open-sse/services/usage/github.ts
Normal file
230
open-sse/services/usage/github.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* usage/github.ts — GitHub Copilot usage fetcher + quota/plan helpers.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the GitHub family —
|
||||
* the copilot_internal/user fetcher (getGitHubUsage), the paid/limited quota
|
||||
* snapshot formatter (formatGitHubQuotaSnapshot), the plan-name inference
|
||||
* (inferGitHubPlanName), and the display gate (shouldDisplayGitHubQuota).
|
||||
* Depends only on the sibling scalar/quota leaves + the GitHub internal-user
|
||||
* header profile — no host coupling — so it lives as a co-located provider leaf.
|
||||
* usage.ts imports getGitHubUsage (dispatcher) + re-exports the helpers via
|
||||
* __testing (existing usage-utils / usage-service-hardening suites read them
|
||||
* from there). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { getGitHubCopilotInternalUserHeaders } from "../../config/providerHeaderProfiles.ts";
|
||||
import { toRecord, toNumber, getFieldValue, clampPercentage, toDisplayLabel } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot Usage
|
||||
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
|
||||
*/
|
||||
export 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
96
open-sse/services/usage/nanogpt.ts
Normal file
96
open-sse/services/usage/nanogpt.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* usage/nanogpt.ts — NanoGPT usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the NanoGPT family —
|
||||
* the subscription usage-API config and the getNanoGptUsage fetcher that reads
|
||||
* daily/weekly token + daily image limits for PRO accounts. Depends only on the
|
||||
* sibling scalar/quota leaves — no host coupling — so it lives as a co-located
|
||||
* provider leaf. usage.ts imports getNanoGptUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { toRecord, toNumber, clampPercentage } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
const NANOGPT_CONFIG = {
|
||||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||||
};
|
||||
|
||||
/**
|
||||
* NanoGPT Usage
|
||||
* Fetches subscription-level quota from the NanoGPT API.
|
||||
* Returns daily/weekly token limits and daily image limits for PRO accounts.
|
||||
*/
|
||||
export 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}` };
|
||||
}
|
||||
}
|
||||
86
open-sse/services/usage/opencode.ts
Normal file
86
open-sse/services/usage/opencode.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* usage/opencode.ts — OpenCode / OpenCode Zen usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the OpenCode family —
|
||||
* delegates to the dedicated opencodeQuotaFetcher and shapes the triple-window
|
||||
* ($12/5h, $30/wk, $60/mo) result into the standard `{ plan, quotas }` usage
|
||||
* response expected by the limits page. Depends only on the sibling scalar/quota
|
||||
* leaves + fetchOpencodeQuota + sanitizeErrorMessage — no host coupling — so it
|
||||
* lives as a co-located provider leaf. usage.ts imports getOpencodeUsage
|
||||
* (dispatcher + __testing). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "../opencodeQuotaFetcher.ts";
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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)}` };
|
||||
}
|
||||
}
|
||||
145
open-sse/services/usage/qoder.ts
Normal file
145
open-sse/services/usage/qoder.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* usage/qoder.ts — Qoder usage fetcher + status parser.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Qoder family —
|
||||
* the /api/v3/user/status endpoint config, the plan-label prettifier, the pure
|
||||
* status→quotas mapper (parseQoderUserStatusUsage), and the getQoderUsage
|
||||
* fetcher that exchanges the PAT for a short-lived job token then reads the
|
||||
* status endpoint. Depends only on the sibling scalar/quota leaves +
|
||||
* resolveQoderJobToken + sanitizeErrorMessage — no host coupling — so it lives
|
||||
* as a co-located provider leaf. usage.ts imports getQoderUsage (dispatcher) +
|
||||
* re-exports parseQoderUserStatusUsage (named export + __testing, used by the
|
||||
* qoder-usage-quota suite). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import { resolveQoderJobToken } from "../qoderCli.ts";
|
||||
import { toRecord, toNumber, toTitleCase } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const QODER_USER_STATUS_URL = "https://openapi.qoder.sh/api/v3/user/status";
|
||||
|
||||
/** Human-readable plan label from Qoder's `PLAN_TIER_*` enum / `userTag`. */
|
||||
function prettifyQoderPlan(planRaw: string, userTag: string): string {
|
||||
const tag = String(userTag || "").trim();
|
||||
if (tag) return tag;
|
||||
const stripped = String(planRaw || "")
|
||||
.trim()
|
||||
.replace(/^PLAN_TIER_/i, "");
|
||||
return stripped ? toTitleCase(stripped) : "Qoder";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Qoder `/user/status` payload into the shared `{ plan, quotas }` shape.
|
||||
* Pure (no I/O) so it can be unit-tested against captured payloads.
|
||||
*/
|
||||
export function parseQoderUserStatusUsage(status: JsonRecord): {
|
||||
plan: string;
|
||||
quotas: Record<string, UsageQuota>;
|
||||
} {
|
||||
const userType = String(status.userType || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const planLabel = prettifyQoderPlan(String(status.plan || ""), String(status.userTag || ""));
|
||||
const isExceeded = status.isQuotaExceeded === true;
|
||||
const quotaNum = toNumber(status.quota, 0);
|
||||
const resetAt = parseResetTime(status.nextResetAt);
|
||||
// Team/enterprise seats draw from a pooled org quota rather than a per-user
|
||||
// counter, so `quota: 0` there means "pooled", not "exhausted".
|
||||
const isPooled = userType === "teams" || userType === "enterprise";
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
if (isExceeded) {
|
||||
// Genuinely out of quota — remainingPercentage 0 lets routing skip it until reset.
|
||||
quotas["Quota"] = {
|
||||
used: quotaNum,
|
||||
total: quotaNum,
|
||||
remaining: 0,
|
||||
remainingPercentage: 0,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: "Quota exceeded",
|
||||
};
|
||||
} else if (isPooled || quotaNum <= 0) {
|
||||
// Pooled/unlimited seat — MUST report 100% remaining. The quota→routing
|
||||
// conversion (src/domain/quotaCache.ts) ignores `unlimited` and would treat a
|
||||
// `total: 0` window as 0% (i.e. exhausted), wrongly 429-ing every request.
|
||||
quotas["Plan"] = {
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining: 0,
|
||||
remainingPercentage: 100,
|
||||
resetAt,
|
||||
unlimited: true,
|
||||
displayName: `${planLabel} plan · pooled quota`,
|
||||
};
|
||||
} else {
|
||||
quotas["Requests"] = {
|
||||
used: 0,
|
||||
total: quotaNum,
|
||||
remaining: quotaNum,
|
||||
remainingPercentage: 100,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: `${quotaNum} requests left`,
|
||||
};
|
||||
}
|
||||
|
||||
return { plan: planLabel, quotas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Qoder Usage
|
||||
*
|
||||
* Qoder exposes account plan + quota at `openapi.qoder.sh/api/v3/user/status`,
|
||||
* the same endpoint the official qodercli reads for its usage badge. The status
|
||||
* call needs a short-lived `jt-*` job token, so we exchange the PAT the same way
|
||||
* the chat/validation paths do (see qoderCli.ts::resolveQoderJobToken).
|
||||
*/
|
||||
export async function getQoderUsage(apiKey?: string, providerSpecificData?: JsonRecord) {
|
||||
const token = (apiKey || "").trim() || String(providerSpecificData?.qoderPat || "").trim();
|
||||
if (!token) {
|
||||
return { message: "Qoder connected. Add a Personal Access Token to view quota." };
|
||||
}
|
||||
|
||||
let jobToken: string;
|
||||
try {
|
||||
jobToken = await resolveQoderJobToken(token);
|
||||
} catch {
|
||||
return { message: "Qoder connected. Unable to resolve a usage token." };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(QODER_USER_STATUS_URL, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${jobToken}`, Accept: "application/json" },
|
||||
// @ts-ignore — AbortSignal.timeout is available on the Node runtime
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Qoder connected. Unable to fetch usage: ${sanitizeErrorMessage((error as Error).message)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: "Qoder connected. The token was rejected by the usage API — re-test the connection.",
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { message: `Qoder connected. Usage API returned HTTP ${response.status}.` };
|
||||
}
|
||||
|
||||
let status: JsonRecord;
|
||||
try {
|
||||
status = toRecord(await response.json());
|
||||
} catch {
|
||||
return { message: "Qoder connected. Unable to parse the usage response." };
|
||||
}
|
||||
|
||||
return parseQoderUserStatusUsage(status);
|
||||
}
|
||||
61
open-sse/services/usage/vertex.ts
Normal file
61
open-sse/services/usage/vertex.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* usage/vertex.ts — Vertex AI self-tracked spend usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Vertex family —
|
||||
* Vertex AI exposes no usage/quota API for an API key or Service Account, so
|
||||
* OmniRoute self-tracks the USD it spent through the connection (summed from
|
||||
* usage_history via getConnectionSpendUsdSinceAdded) and surfaces a `spend`
|
||||
* quota entry plus a `$X used · N requests` message. Depends only on the
|
||||
* sibling scalar/quota leaves + the usageStats dynamic import — no host
|
||||
* coupling — so it lives as a co-located provider leaf. usage.ts imports
|
||||
* getVertexUsage (dispatcher + __testing). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export 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}` };
|
||||
}
|
||||
}
|
||||
53
open-sse/services/usage/xai.ts
Normal file
53
open-sse/services/usage/xai.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* usage/xai.ts — xAI (Grok) self-tracked cumulative usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the xAI family —
|
||||
* xAI has no public per-account quota API (the billing console at console.x.ai
|
||||
* requires a session cookie, not an API key), so — exactly like the Xiaomi MiMo
|
||||
* self-track pattern — OmniRoute sums the tokens it itself routed to this
|
||||
* connection (from `usage_history`) instead of calling an upstream endpoint.
|
||||
* Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the aggregate is reported
|
||||
* as `unlimited: true` with `remaining: 100`. Depends only on the sibling
|
||||
* scalar/quota leaves + the usageStats dynamic import — no host coupling — so it
|
||||
* lives as a co-located provider leaf. usage.ts imports getXaiUsage (dispatcher
|
||||
* + __testing). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
|
||||
/**
|
||||
* xAI (Grok) — SELF-TRACKED cumulative usage.
|
||||
*
|
||||
* xAI has no public per-account quota API (the billing console at console.x.ai
|
||||
* requires a session cookie, not an API key), so — exactly like the Xiaomi
|
||||
* MiMo self-track pattern above — OmniRoute sums the tokens it itself routed
|
||||
* to this connection (from `usage_history`) instead of calling an upstream
|
||||
* endpoint. Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the
|
||||
* aggregate is reported as `unlimited: true` with `remaining: 100` — this
|
||||
* renders the dashboard's green "100%" badge instead of a meaningless
|
||||
* progress bar against a `total: 0`.
|
||||
*/
|
||||
export async function getXaiUsage(connectionId: string) {
|
||||
if (!connectionId) {
|
||||
return { message: "xAI: connection id unavailable for self-tracked usage." };
|
||||
}
|
||||
try {
|
||||
const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats");
|
||||
const used = getMonthlyProviderTokensForConnection("xai", connectionId);
|
||||
return {
|
||||
plan: "xAI / Grok (OmniRoute-tracked)",
|
||||
quotas: {
|
||||
monthly: {
|
||||
used,
|
||||
total: 0,
|
||||
remaining: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: null,
|
||||
unlimited: true,
|
||||
} as UsageQuota,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `xAI self-tracked usage error: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
51
open-sse/services/usage/xiaomi-mimo.ts
Normal file
51
open-sse/services/usage/xiaomi-mimo.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* usage/xiaomi-mimo.ts — Xiaomi MiMo self-tracked monthly quota fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Xiaomi MiMo family —
|
||||
* Xiaomi exposes plan usage only behind the console session cookie (the API key
|
||||
* cannot reach the `tokenPlan/usage` endpoint), so OmniRoute self-tracks the
|
||||
* tokens it routed to the connection in the current UTC month (from usage_history)
|
||||
* and compares them to the known Token Plan monthly limit. Depends only on the
|
||||
* sibling scalar/quota leaves + the usageStats dynamic import — no host coupling
|
||||
* — so it lives as a co-located provider leaf. usage.ts imports getXiaomiMimoUsage
|
||||
* (dispatcher + __testing). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { createQuotaFromUsage } from "./quota.ts";
|
||||
|
||||
// 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.
|
||||
*/
|
||||
export 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}` };
|
||||
}
|
||||
}
|
||||
59
tests/unit/usage-bailian-split.test.ts
Normal file
59
tests/unit/usage-bailian-split.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Characterization of the services/usage.ts bailian split (god-file decomposition): the Bailian
|
||||
// (Alibaba Token Plan) triple-window fetcher (getBailianCodingPlanUsage) moved into
|
||||
// services/usage/bailian.ts so usage.ts stays a thin dispatcher. Behavior-preserving move — this
|
||||
// locks the export surface and the worst-case window selection; the full triple-window matrix
|
||||
// is covered via getUsageForProvider in bailian-usage.test.ts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const B = await import("../../open-sse/services/usage/bailian.ts");
|
||||
|
||||
test("module exposes getBailianCodingPlanUsage", () => {
|
||||
assert.equal(typeof B.getBailianCodingPlanUsage, "function");
|
||||
});
|
||||
|
||||
test("getBailianCodingPlanUsage picks the most restrictive window as the surfaced quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: "Success",
|
||||
data: {
|
||||
codingPlanInstanceInfos: [
|
||||
{
|
||||
planName: "Qwen3 Coder Next",
|
||||
codingPlanQuotaInfo: {
|
||||
per5HourUsedQuota: 60,
|
||||
per5HourTotalQuota: 100,
|
||||
per5HourQuotaNextRefreshTime: 1718304000,
|
||||
perWeekUsedQuota: 80,
|
||||
perWeekTotalQuota: 100,
|
||||
perWeekQuotaNextRefreshTime: 1718563200,
|
||||
perBillMonthUsedQuota: 40,
|
||||
perBillMonthTotalQuota: 100,
|
||||
perBillMonthQuotaNextRefreshTime: 1719772800,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
try {
|
||||
const r = (await B.getBailianCodingPlanUsage("conn", "sk-test", { consoleApiKey: "ck" })) as {
|
||||
plan?: string;
|
||||
used?: number;
|
||||
total?: number;
|
||||
remaining?: number;
|
||||
remainingPercentage?: number;
|
||||
unlimited?: boolean;
|
||||
};
|
||||
assert.equal(r.plan, "Alibaba Token Plan");
|
||||
assert.equal(r.unlimited, false);
|
||||
// weekly 80% is the most restrictive → used/total = 0.8
|
||||
assert.equal(r.used! / r.total!, 0.8);
|
||||
assert.equal(r.remainingPercentage, 20);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
69
tests/unit/usage-crof-split.test.ts
Normal file
69
tests/unit/usage-crof-split.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Characterization of the services/usage.ts crof split (god-file decomposition): the CrofAI
|
||||
// /usage_api/ fetcher (getCrofUsage) + its overridable URL moved into services/usage/crof.ts so
|
||||
// usage.ts stays a thin dispatcher. Behavior-preserving move — these locks pin the export surface
|
||||
// and the no-key fail-open message already covered via getUsageForProvider in crof-usage.test.ts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const C = await import("../../open-sse/services/usage/crof.ts");
|
||||
|
||||
test("module exposes getCrofUsage", () => {
|
||||
assert.equal(typeof C.getCrofUsage, "function");
|
||||
});
|
||||
|
||||
test("getCrofUsage returns a friendly message when the api key is missing", async () => {
|
||||
const r = (await C.getCrofUsage("")) as { message?: string };
|
||||
assert.match(r.message ?? "", /CrofAI API key not available/);
|
||||
});
|
||||
|
||||
test("getCrofUsage surfaces Requests Today + Credits for a subscription account", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ usable_requests: 450, credits: 12.3456 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
try {
|
||||
const r = (await C.getCrofUsage("test-key")) as {
|
||||
quotas?: Record<
|
||||
string,
|
||||
{ remaining: number; total: number; used: number; displayName?: string; unlimited: boolean }
|
||||
>;
|
||||
};
|
||||
assert.ok(r.quotas?.["Requests Today"]);
|
||||
assert.equal(r.quotas!["Requests Today"].remaining, 450);
|
||||
assert.equal(r.quotas!["Requests Today"].total, 1000);
|
||||
assert.equal(r.quotas!["Requests Today"].used, 550);
|
||||
assert.ok(r.quotas?.["Credits"]?.unlimited);
|
||||
assert.match(r.quotas!["Credits"].displayName!, /\$12\.3456/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getCrofUsage omits Requests Today for pay-as-you-go (usable_requests=null)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ usable_requests: null, credits: 5.5 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
try {
|
||||
const r = (await C.getCrofUsage("test-key")) as { quotas?: Record<string, unknown> };
|
||||
assert.equal(r.quotas?.["Requests Today"], undefined);
|
||||
assert.ok(r.quotas?.["Credits"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getCrofUsage returns a rejected-key message on 401/403", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("nope", { status: 401 });
|
||||
try {
|
||||
const r = (await C.getCrofUsage("bad-key")) as { message?: string };
|
||||
assert.match(r.message ?? "", /rejected/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
82
tests/unit/usage-deepseek-split.test.ts
Normal file
82
tests/unit/usage-deepseek-split.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Characterization of the services/usage.ts deepseek split (god-file decomposition): the DeepSeek
|
||||
// balance fetcher (getDeepseekUsage) moved into services/usage/deepseek.ts so usage.ts stays a
|
||||
// thin dispatcher. Behavior-preserving move — this locks the export surface and the
|
||||
// credits-style shaping; the full balance matrix is covered via getUsageForProvider in
|
||||
// usage-service-deepseek.test.ts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const D = await import("../../open-sse/services/usage/deepseek.ts");
|
||||
|
||||
test("module exposes getDeepseekUsage", () => {
|
||||
assert.equal(typeof D.getDeepseekUsage, "function");
|
||||
});
|
||||
|
||||
test("getDeepseekUsage surfaces USD + CNY balances as unlimited credits entries", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "1000.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "1000.00",
|
||||
},
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "50.00",
|
||||
granted_balance: "5.00",
|
||||
topped_up_balance: "45.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
try {
|
||||
const r = (await D.getDeepseekUsage("conn-multi", "k")) as {
|
||||
plan?: string;
|
||||
quotas?: Record<string, { remaining: number; currency: string; unlimited: boolean }>;
|
||||
};
|
||||
assert.equal(r.plan, "DeepSeek");
|
||||
assert.equal(r.quotas?.credits_usd.remaining, 50);
|
||||
assert.equal(r.quotas?.credits_usd.currency, "USD");
|
||||
assert.equal(r.quotas?.credits_cny.remaining, 1000);
|
||||
assert.equal(r.quotas?.credits_usd.unlimited, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getDeepseekUsage labels the plan '(Insufficient Balance)' when is_available is false", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: false,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
try {
|
||||
const r = (await D.getDeepseekUsage("conn-empty", "k")) as {
|
||||
plan?: string;
|
||||
isAvailable?: boolean;
|
||||
limitReached?: boolean;
|
||||
};
|
||||
assert.equal(r.plan, "DeepSeek (Insufficient Balance)");
|
||||
assert.equal(r.isAvailable, false);
|
||||
assert.equal(r.limitReached, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
71
tests/unit/usage-github-split.test.ts
Normal file
71
tests/unit/usage-github-split.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Characterization of the services/usage.ts github split (god-file decomposition): the GitHub
|
||||
// Copilot fetcher (getGitHubUsage) + the paid/limited quota snapshot formatter
|
||||
// (formatGitHubQuotaSnapshot) + the plan-name inference (inferGitHubPlanName) + the display gate
|
||||
// (shouldDisplayGitHubQuota) moved into services/usage/github.ts so usage.ts stays a thin
|
||||
// dispatcher. Behavior-preserving move — these locks pin the export surface and the pure-helper
|
||||
// edges already covered via __testing in usage-utils / usage-service-hardening.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const G = await import("../../open-sse/services/usage/github.ts");
|
||||
|
||||
test("module exposes the four github helpers", () => {
|
||||
for (const name of [
|
||||
"getGitHubUsage",
|
||||
"formatGitHubQuotaSnapshot",
|
||||
"inferGitHubPlanName",
|
||||
"shouldDisplayGitHubQuota",
|
||||
]) {
|
||||
assert.equal(typeof (G as Record<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("shouldDisplayGitHubQuota hides unlimited-with-zero-total and empty quotas", () => {
|
||||
assert.equal(G.shouldDisplayGitHubQuota(null), false);
|
||||
assert.equal(
|
||||
G.shouldDisplayGitHubQuota({ used: 0, total: 0, resetAt: null, unlimited: true }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
G.shouldDisplayGitHubQuota({ used: 0, total: 0, resetAt: null, unlimited: false }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
G.shouldDisplayGitHubQuota({ used: 0, total: 100, resetAt: null, unlimited: false }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
G.shouldDisplayGitHubQuota({
|
||||
used: 0,
|
||||
total: 0,
|
||||
remainingPercentage: 50,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("formatGitHubQuotaSnapshot returns null for empty objects and synthesizes total from percent", () => {
|
||||
assert.equal(G.formatGitHubQuotaSnapshot({}), null);
|
||||
const fromPercent = G.formatGitHubQuotaSnapshot({ percent_remaining: 30 })!;
|
||||
assert.equal(fromPercent.total, 100);
|
||||
assert.equal(fromPercent.used, 70);
|
||||
assert.equal(fromPercent.remaining, 30);
|
||||
assert.equal(fromPercent.remainingPercentage, 30);
|
||||
});
|
||||
|
||||
test("inferGitHubPlanName maps sku/plan strings and entitlement tiers", () => {
|
||||
assert.equal(
|
||||
G.inferGitHubPlanName({ access_type_sku: "copilot_business_seat" }, null),
|
||||
"Copilot Business"
|
||||
);
|
||||
assert.equal(
|
||||
G.inferGitHubPlanName(
|
||||
{ copilot_plan: "individual" },
|
||||
{ used: 0, total: 300, resetAt: null, unlimited: false }
|
||||
),
|
||||
"Copilot Pro"
|
||||
);
|
||||
assert.equal(G.inferGitHubPlanName({}, null), "GitHub Copilot");
|
||||
});
|
||||
73
tests/unit/usage-nanogpt-split.test.ts
Normal file
73
tests/unit/usage-nanogpt-split.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Characterization of the services/usage.ts nanogpt split (god-file decomposition): the NanoGPT
|
||||
// subscription usage fetcher (getNanoGptUsage) + its API config moved into
|
||||
// services/usage/nanogpt.ts so usage.ts stays a thin dispatcher. Behavior-preserving move —
|
||||
// these locks pin the export surface and the no-key / 401 fail-open messages.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const N = await import("../../open-sse/services/usage/nanogpt.ts");
|
||||
|
||||
test("module exposes getNanoGptUsage", () => {
|
||||
assert.equal(typeof N.getNanoGptUsage, "function");
|
||||
});
|
||||
|
||||
test("getNanoGptUsage returns a friendly message when the api key is missing", async () => {
|
||||
const r = (await N.getNanoGptUsage("")) as { message?: string };
|
||||
assert.match(r.message ?? "", /NanoGPT API key not available/);
|
||||
});
|
||||
|
||||
test("getNanoGptUsage returns FREE plan with no quotas for inactive subscriptions", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ active: false }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
try {
|
||||
const r = (await N.getNanoGptUsage("k")) as { plan?: string; quotas?: Record<string, unknown> };
|
||||
assert.equal(r.plan, "FREE");
|
||||
assert.deepEqual(r.quotas ?? {}, {});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getNanoGptUsage surfaces Daily Tokens + Daily Images for PRO accounts", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
active: true,
|
||||
dailyInputTokens: { used: 10, remaining: 90, percentUsed: 0.1, resetAt: 1_700_000_000 },
|
||||
dailyImages: { used: 2, remaining: 8, percentUsed: 0.2, resetAt: 1_700_000_000 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
try {
|
||||
const r = (await N.getNanoGptUsage("k")) as {
|
||||
plan?: string;
|
||||
quotas?: Record<
|
||||
string,
|
||||
{ total: number; used: number; remaining: number; remainingPercentage: number }
|
||||
>;
|
||||
};
|
||||
assert.equal(r.plan, "PRO");
|
||||
assert.ok(r.quotas?.["Daily Tokens"]);
|
||||
assert.equal(r.quotas!["Daily Tokens"].total, 100);
|
||||
assert.equal(r.quotas!["Daily Tokens"].remaining, 90);
|
||||
assert.equal(r.quotas!["Daily Images"].total, 10);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getNanoGptUsage returns Invalid API key message on 401", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("nope", { status: 401 });
|
||||
try {
|
||||
const r = (await N.getNanoGptUsage("bad")) as { message?: string };
|
||||
assert.match(r.message ?? "", /Invalid NanoGPT API key/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
18
tests/unit/usage-opencode-split.test.ts
Normal file
18
tests/unit/usage-opencode-split.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Characterization of the services/usage.ts opencode split (god-file decomposition): the
|
||||
// OpenCode / OpenCode Zen triple-window fetcher (getOpencodeUsage) moved into
|
||||
// services/usage/opencode.ts so usage.ts stays a thin dispatcher. Behavior-preserving move —
|
||||
// this locks the export surface and the no-key fail-open message; the window-shaping edges are
|
||||
// exercised via fetchOpencodeQuota stubs in opencode-quota-fetcher tests.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const O = await import("../../open-sse/services/usage/opencode.ts");
|
||||
|
||||
test("module exposes getOpencodeUsage", () => {
|
||||
assert.equal(typeof O.getOpencodeUsage, "function");
|
||||
});
|
||||
|
||||
test("getOpencodeUsage returns a no-api-key message when the key is missing", async () => {
|
||||
const r = (await O.getOpencodeUsage("conn", "")) as { message?: string };
|
||||
assert.match(r.message ?? "", /OpenCode API key not available/);
|
||||
});
|
||||
61
tests/unit/usage-qoder-split.test.ts
Normal file
61
tests/unit/usage-qoder-split.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Characterization of the services/usage.ts qoder split (god-file decomposition): the Qoder
|
||||
// /api/v3/user/status fetcher (getQoderUsage) + the pure status→quotas mapper
|
||||
// (parseQoderUserStatusUsage) + the plan-label prettifier moved into services/usage/qoder.ts so
|
||||
// usage.ts stays a thin dispatcher. Behavior-preserving move — these locks pin the export surface
|
||||
// and the pure parser edges already covered via __testing in qoder-usage-quota.test.ts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const Q = await import("../../open-sse/services/usage/qoder.ts");
|
||||
|
||||
test("module exposes getQoderUsage + parseQoderUserStatusUsage", () => {
|
||||
assert.equal(typeof Q.getQoderUsage, "function");
|
||||
assert.equal(typeof Q.parseQoderUserStatusUsage, "function");
|
||||
});
|
||||
|
||||
test("getQoderUsage returns a Personal Access Token prompt when no token is present", async () => {
|
||||
const r = (await Q.getQoderUsage(undefined, {})) as { message?: string };
|
||||
assert.match(r.message ?? "", /Personal Access Token/i);
|
||||
});
|
||||
|
||||
test("parseQoderUserStatusUsage maps a Teams/pooled seat to an unlimited plan entry", () => {
|
||||
const { plan, quotas } = Q.parseQoderUserStatusUsage({
|
||||
userType: "teams",
|
||||
userTag: "Teams",
|
||||
plan: "PLAN_TIER_TEAM",
|
||||
quota: 0,
|
||||
isQuotaExceeded: false,
|
||||
nextResetAt: 1784736000000,
|
||||
});
|
||||
assert.equal(plan, "Teams");
|
||||
assert.deepEqual(Object.keys(quotas), ["Plan"]);
|
||||
assert.equal(quotas.Plan.unlimited, true);
|
||||
assert.equal(quotas.Plan.remainingPercentage, 100);
|
||||
});
|
||||
|
||||
test("parseQoderUserStatusUsage maps an individual plan with remaining quota to a Requests entry", () => {
|
||||
const { plan, quotas } = Q.parseQoderUserStatusUsage({
|
||||
userType: "individual",
|
||||
plan: "PLAN_TIER_PRO",
|
||||
quota: 42,
|
||||
isQuotaExceeded: false,
|
||||
nextResetAt: 1784736000000,
|
||||
});
|
||||
assert.equal(plan, "Pro");
|
||||
assert.deepEqual(Object.keys(quotas), ["Requests"]);
|
||||
assert.equal(quotas.Requests.remaining, 42);
|
||||
assert.equal(quotas.Requests.unlimited, false);
|
||||
});
|
||||
|
||||
test("parseQoderUserStatusUsage flags an exceeded quota", () => {
|
||||
const { quotas } = Q.parseQoderUserStatusUsage({
|
||||
userType: "individual",
|
||||
plan: "PLAN_TIER_FREE",
|
||||
quota: 100,
|
||||
isQuotaExceeded: true,
|
||||
nextResetAt: 1784736000000,
|
||||
});
|
||||
assert.deepEqual(Object.keys(quotas), ["Quota"]);
|
||||
assert.equal(quotas.Quota.remaining, 0);
|
||||
assert.match(quotas.Quota.displayName!, /exceeded/i);
|
||||
});
|
||||
78
tests/unit/usage-vertex-split.test.ts
Normal file
78
tests/unit/usage-vertex-split.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Characterization of the services/usage.ts vertex split (god-file decomposition): the Vertex AI
|
||||
// self-tracked spend fetcher (getVertexUsage) moved into services/usage/vertex.ts so usage.ts
|
||||
// stays a thin dispatcher. Behavior-preserving move — this locks the export surface and the
|
||||
// missing-connection-id fail-open; the spend aggregation + message shape is covered via
|
||||
// __testing.getVertexUsage in vertex-spend-usage.test.ts.
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// DATA_DIR must be set before any module that opens the DB is imported.
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vertex-split-"));
|
||||
process.env.DATA_DIR = TMP;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const V = await import("../../open-sse/services/usage/vertex.ts");
|
||||
|
||||
function insertUsage(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
model: string,
|
||||
tokensIn: number,
|
||||
tokensOut: number,
|
||||
success = 1
|
||||
) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(provider, model, connectionId, tokensIn, tokensOut, success, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe("vertex leaf self-tracked spend", () => {
|
||||
before(() => {
|
||||
core.getDbInstance(); // trigger migrations
|
||||
insertUsage("conn-leaf", "vertex", "gemini-2.5-flash", 1_000_000, 500_000, 1);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort temp cleanup
|
||||
}
|
||||
});
|
||||
|
||||
it("module exposes getVertexUsage", () => {
|
||||
assert.equal(typeof V.getVertexUsage, "function");
|
||||
});
|
||||
|
||||
it("returns a message when connection id is missing", async () => {
|
||||
const r = (await V.getVertexUsage("", "vertex")) as { message?: string; quotas?: unknown };
|
||||
assert.ok(r.message && !r.quotas, "no spend quota without a connection id");
|
||||
});
|
||||
|
||||
it("returns a spend quota + $ message for a used connection", async () => {
|
||||
const r = (await V.getVertexUsage("conn-leaf", "vertex")) as {
|
||||
plan?: string;
|
||||
message?: string;
|
||||
quotas?: Record<string, { used: number; quotaSource?: string; displayName?: string }>;
|
||||
};
|
||||
assert.ok(r.quotas?.spend, "spend quota present");
|
||||
assert.equal(r.quotas!.spend.quotaSource, "localUsageHistory");
|
||||
assert.ok(r.message && r.message.includes("$"));
|
||||
assert.ok(r.message!.includes("1 request"));
|
||||
});
|
||||
|
||||
it("reports no-usage cleanly when nothing was routed", async () => {
|
||||
const r = (await V.getVertexUsage("conn-empty", "vertex")) as {
|
||||
message?: string;
|
||||
quotas?: Record<string, { used: number }>;
|
||||
};
|
||||
assert.ok(r.message && /no usage/i.test(r.message));
|
||||
assert.equal(r.quotas?.spend.used, 0);
|
||||
});
|
||||
});
|
||||
72
tests/unit/usage-xai-split.test.ts
Normal file
72
tests/unit/usage-xai-split.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Characterization of the services/usage.ts xai split (god-file decomposition): the xAI (Grok)
|
||||
// self-tracked cumulative usage fetcher (getXaiUsage) moved into services/usage/xai.ts so
|
||||
// usage.ts stays a thin dispatcher. Behavior-preserving move — this locks the export surface and
|
||||
// the missing-connection-id fail-open; the cumulative unlimited shaping is covered via
|
||||
// __testing.getXaiUsage in xai-usage.test.ts.
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// DATA_DIR must be set before any module that opens the DB is imported.
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-xai-split-"));
|
||||
process.env.DATA_DIR = TMP;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const X = await import("../../open-sse/services/usage/xai.ts");
|
||||
|
||||
function insertUsage(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
tokensIn: number,
|
||||
tokensOut: number,
|
||||
timestamp: string
|
||||
) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, connection_id, tokens_input, tokens_output, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(provider, connectionId, tokensIn, tokensOut, timestamp);
|
||||
}
|
||||
|
||||
describe("xai leaf self-tracked usage", () => {
|
||||
before(() => {
|
||||
core.getDbInstance(); // trigger migrations
|
||||
insertUsage("conn-leaf", "xai", 2_000_000, 300_000, new Date().toISOString());
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort temp cleanup
|
||||
}
|
||||
});
|
||||
|
||||
it("module exposes getXaiUsage", () => {
|
||||
assert.equal(typeof X.getXaiUsage, "function");
|
||||
});
|
||||
|
||||
it("returns a message when connection id is missing", async () => {
|
||||
const r = (await X.getXaiUsage("")) as { message?: string; quotas?: unknown };
|
||||
assert.ok(r.message && !r.quotas, "no quota without a connection id");
|
||||
});
|
||||
|
||||
it("returns a cumulative unlimited quota scoped to the connection", async () => {
|
||||
const r = (await X.getXaiUsage("conn-leaf")) as {
|
||||
plan?: string;
|
||||
quotas?: Record<
|
||||
string,
|
||||
{ used: number; total: number; remaining: number; unlimited: boolean }
|
||||
>;
|
||||
message?: string;
|
||||
};
|
||||
assert.ok(r.quotas, `expected quotas, got message: ${r.message}`);
|
||||
const m = r.quotas!.monthly;
|
||||
assert.equal(m.used, 2_300_000);
|
||||
assert.equal(m.unlimited, true, "xAI has no fixed monthly cap");
|
||||
assert.equal(m.remaining, 100, "unlimited rows report remaining: 100");
|
||||
});
|
||||
});
|
||||
79
tests/unit/usage-xiaomi-mimo-split.test.ts
Normal file
79
tests/unit/usage-xiaomi-mimo-split.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Characterization of the services/usage.ts xiaomi-mimo split (god-file decomposition): the
|
||||
// Xiaomi MiMo self-tracked monthly quota fetcher (getXiaomiMimoUsage) moved into
|
||||
// services/usage/xiaomi-mimo.ts so usage.ts stays a thin dispatcher. Behavior-preserving move —
|
||||
// this locks the export surface and the missing-connection-id fail-open; the monthly aggregation
|
||||
// + 4.1B limit comparison is covered via __testing.getXiaomiMimoUsage in
|
||||
// xiaomi-mimo-selftrack-usage.test.ts.
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// DATA_DIR must be set before any module that opens the DB is imported.
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-xiaomi-split-"));
|
||||
process.env.DATA_DIR = TMP;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const X = await import("../../open-sse/services/usage/xiaomi-mimo.ts");
|
||||
|
||||
function insertUsage(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
tokensIn: number,
|
||||
tokensOut: number,
|
||||
timestamp: string
|
||||
) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, connection_id, tokens_input, tokens_output, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(provider, connectionId, tokensIn, tokensOut, timestamp);
|
||||
}
|
||||
|
||||
describe("xiaomi-mimo leaf self-tracked quota", () => {
|
||||
before(() => {
|
||||
core.getDbInstance(); // trigger migrations
|
||||
insertUsage("conn-leaf", "xiaomi-mimo", 1_000_000, 500_000, new Date().toISOString());
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort temp cleanup
|
||||
}
|
||||
});
|
||||
|
||||
it("module exposes getXiaomiMimoUsage", () => {
|
||||
assert.equal(typeof X.getXiaomiMimoUsage, "function");
|
||||
});
|
||||
|
||||
it("returns a message when connection id is missing", async () => {
|
||||
const r = (await X.getXiaomiMimoUsage("")) as { message?: string; quotas?: unknown };
|
||||
assert.ok(r.message && !r.quotas, "no quota without a connection id");
|
||||
});
|
||||
|
||||
it("returns a monthly quota against the 4.1B limit", async () => {
|
||||
const r = (await X.getXiaomiMimoUsage("conn-leaf")) as {
|
||||
plan?: string;
|
||||
quotas?: Record<
|
||||
string,
|
||||
{
|
||||
used: number;
|
||||
total: number;
|
||||
remaining?: number;
|
||||
remainingPercentage?: number;
|
||||
resetAt: string | null;
|
||||
}
|
||||
>;
|
||||
message?: string;
|
||||
};
|
||||
assert.ok(r.quotas, `expected quotas, got message: ${r.message}`);
|
||||
const m = r.quotas!.monthly;
|
||||
assert.equal(m.total, 4_100_000_000);
|
||||
assert.equal(m.used, 1_500_000);
|
||||
assert.ok(m.resetAt && m.resetAt.endsWith("T00:00:00.000Z"), "reset = first of next month UTC");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user