diff --git a/changelog.d/maintenance/decomp-usage-service.md b/changelog.d/maintenance/decomp-usage-service.md new file mode 100644 index 0000000000..55806cc43b --- /dev/null +++ b/changelog.d/maintenance/decomp-usage-service.md @@ -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 diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 14d061e75c..a0fc34c4b5 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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/.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; 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 = {}; - - 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 -) { - 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 = {}; - - // 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 = {}; - - // $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 = {}; - - // 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 = {}; - - 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 = {}; - - 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; -} { - 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 = {}; - 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, diff --git a/open-sse/services/usage/bailian.ts b/open-sse/services/usage/bailian.ts new file mode 100644 index 0000000000..9830472234 --- /dev/null +++ b/open-sse/services/usage/bailian.ts @@ -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 +) { + 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}` }; + } +} diff --git a/open-sse/services/usage/crof.ts b/open-sse/services/usage/crof.ts new file mode 100644 index 0000000000..1ab2011dca --- /dev/null +++ b/open-sse/services/usage/crof.ts @@ -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; + +// 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 = {}; + + 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 }; +} diff --git a/open-sse/services/usage/deepseek.ts b/open-sse/services/usage/deepseek.ts new file mode 100644 index 0000000000..b558b37ca4 --- /dev/null +++ b/open-sse/services/usage/deepseek.ts @@ -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 = {}; + + // 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}` }; + } +} diff --git a/open-sse/services/usage/github.ts b/open-sse/services/usage/github.ts new file mode 100644 index 0000000000..628f6edb91 --- /dev/null +++ b/open-sse/services/usage/github.ts @@ -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; + +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 = {}; + + 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 = {}; + + 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"; +} diff --git a/open-sse/services/usage/nanogpt.ts b/open-sse/services/usage/nanogpt.ts new file mode 100644 index 0000000000..ae6361eb4e --- /dev/null +++ b/open-sse/services/usage/nanogpt.ts @@ -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 = {}; + + // 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}` }; + } +} diff --git a/open-sse/services/usage/opencode.ts b/open-sse/services/usage/opencode.ts new file mode 100644 index 0000000000..689a7c95ca --- /dev/null +++ b/open-sse/services/usage/opencode.ts @@ -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 = {}; + + // $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)}` }; + } +} diff --git a/open-sse/services/usage/qoder.ts b/open-sse/services/usage/qoder.ts new file mode 100644 index 0000000000..957ab69dde --- /dev/null +++ b/open-sse/services/usage/qoder.ts @@ -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; + +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; +} { + 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 = {}; + 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); +} diff --git a/open-sse/services/usage/vertex.ts b/open-sse/services/usage/vertex.ts new file mode 100644 index 0000000000..b9d5896d4e --- /dev/null +++ b/open-sse/services/usage/vertex.ts @@ -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; + +/** + * 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}` }; + } +} diff --git a/open-sse/services/usage/xai.ts b/open-sse/services/usage/xai.ts new file mode 100644 index 0000000000..05edfe7630 --- /dev/null +++ b/open-sse/services/usage/xai.ts @@ -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}` }; + } +} diff --git a/open-sse/services/usage/xiaomi-mimo.ts b/open-sse/services/usage/xiaomi-mimo.ts new file mode 100644 index 0000000000..879471a222 --- /dev/null +++ b/open-sse/services/usage/xiaomi-mimo.ts @@ -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}` }; + } +} diff --git a/tests/unit/usage-bailian-split.test.ts b/tests/unit/usage-bailian-split.test.ts new file mode 100644 index 0000000000..f61cc4c23d --- /dev/null +++ b/tests/unit/usage-bailian-split.test.ts @@ -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; + } +}); diff --git a/tests/unit/usage-crof-split.test.ts b/tests/unit/usage-crof-split.test.ts new file mode 100644 index 0000000000..ac56867b65 --- /dev/null +++ b/tests/unit/usage-crof-split.test.ts @@ -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 }; + 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; + } +}); diff --git a/tests/unit/usage-deepseek-split.test.ts b/tests/unit/usage-deepseek-split.test.ts new file mode 100644 index 0000000000..308c332f7f --- /dev/null +++ b/tests/unit/usage-deepseek-split.test.ts @@ -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; + }; + 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; + } +}); diff --git a/tests/unit/usage-github-split.test.ts b/tests/unit/usage-github-split.test.ts new file mode 100644 index 0000000000..4298dea28c --- /dev/null +++ b/tests/unit/usage-github-split.test.ts @@ -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)[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"); +}); diff --git a/tests/unit/usage-nanogpt-split.test.ts b/tests/unit/usage-nanogpt-split.test.ts new file mode 100644 index 0000000000..e98568db9b --- /dev/null +++ b/tests/unit/usage-nanogpt-split.test.ts @@ -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 }; + 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; + } +}); diff --git a/tests/unit/usage-opencode-split.test.ts b/tests/unit/usage-opencode-split.test.ts new file mode 100644 index 0000000000..912b2a8e8c --- /dev/null +++ b/tests/unit/usage-opencode-split.test.ts @@ -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/); +}); diff --git a/tests/unit/usage-qoder-split.test.ts b/tests/unit/usage-qoder-split.test.ts new file mode 100644 index 0000000000..d9487bff68 --- /dev/null +++ b/tests/unit/usage-qoder-split.test.ts @@ -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); +}); diff --git a/tests/unit/usage-vertex-split.test.ts b/tests/unit/usage-vertex-split.test.ts new file mode 100644 index 0000000000..63f6e665f7 --- /dev/null +++ b/tests/unit/usage-vertex-split.test.ts @@ -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; + }; + 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; + }; + assert.ok(r.message && /no usage/i.test(r.message)); + assert.equal(r.quotas?.spend.used, 0); + }); +}); diff --git a/tests/unit/usage-xai-split.test.ts b/tests/unit/usage-xai-split.test.ts new file mode 100644 index 0000000000..00bf99db03 --- /dev/null +++ b/tests/unit/usage-xai-split.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/usage-xiaomi-mimo-split.test.ts b/tests/unit/usage-xiaomi-mimo-split.test.ts new file mode 100644 index 0000000000..9a2f9fe91f --- /dev/null +++ b/tests/unit/usage-xiaomi-mimo-split.test.ts @@ -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"); + }); +});