diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b3cbd2ee1344..dec18065ae30 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -56,6 +56,7 @@ import { toDisplayLabel, pickFirstNonEmptyString, } from "./usage/scalars.ts"; +import { type UsageQuota, parseResetTime, createQuotaFromUsage } from "./usage/quota.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/"; @@ -133,29 +134,6 @@ const MINIMAX_USAGE_CONFIG = { } as const; type JsonRecord = Record; -type UsageQuota = { - used: number; - total: number; - remaining?: number; - remainingPercentage?: number; - resetAt: string | null; - unlimited: boolean; - /** - * True when the upstream provider reported the remaining fraction. False - * means the API didn't include the field and the 0 value here is a sentinel, - * NOT a confirmed-exhausted state. Antigravity-specific. - */ - fractionReported?: boolean; - quotaSource?: "retrieveUserQuota" | "fetchAvailableModels" | "localUsageHistory"; - displayName?: string; - details?: Array<{ - name: string; - used: number; - }>; - currency?: string; - grantedBalance?: number; - toppedUpBalance?: number; -}; type UsageProviderConnection = JsonRecord & { id?: string; provider?: string; @@ -277,25 +255,6 @@ function getClaudePlanLabel(...candidates: Array): st return null; } -function createQuotaFromUsage( - usedValue: unknown, - totalValue: unknown, - resetValue: unknown -): UsageQuota { - const total = Math.max(0, toNumber(totalValue, 0)); - const used = total > 0 ? Math.min(Math.max(0, toNumber(usedValue, 0)), total) : 0; - const remaining = total > 0 ? Math.max(total - used, 0) : 0; - - return { - used, - total, - remaining, - remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0, - resetAt: parseResetTime(resetValue), - unlimited: false, - }; -} - function getMiniMaxQuotaResetAt( model: JsonRecord, capturedAtMs: number, @@ -1371,37 +1330,6 @@ export async function getUsageForProvider( * Parse reset date/time to ISO string * Handles multiple formats: Unix timestamp (ms), ISO date string, etc. */ -function parseResetTime(resetValue: unknown): string | null { - if (!resetValue) return null; - - try { - let date: Date; - if (resetValue instanceof Date) { - date = resetValue; - } else if (typeof resetValue === "number") { - date = new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue); - } else if (typeof resetValue === "string") { - // Numeric strings are Unix timestamps too (seconds or milliseconds). - // `new Date("1700000000")` otherwise returns Invalid Date. - if (/^\d+$/.test(resetValue)) { - const ts = Number(resetValue); - date = new Date(ts < 1e12 ? ts * 1000 : ts); - } else { - date = new Date(resetValue); - } - } else { - return null; - } - - // Epoch-zero (1970-01-01) means no scheduled reset — treat as null - if (date.getTime() <= 0) return null; - - return date.toISOString(); - } catch (error) { - return null; - } -} - /** * GitHub Copilot Usage * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API diff --git a/open-sse/services/usage/quota.ts b/open-sse/services/usage/quota.ts new file mode 100644 index 000000000000..ce9642c902e3 --- /dev/null +++ b/open-sse/services/usage/quota.ts @@ -0,0 +1,85 @@ +/** + * usage/quota.ts — shared usage-quota shape + builders for the usage fetchers. + * + * Extracted from services/usage.ts (god-file decomposition): the `UsageQuota` value + * type every per-provider fetcher returns, plus the two pure builders that normalize + * upstream reset timestamps and assemble a quota from used/total counts. Depends only on + * the sibling scalar leaf — no network, no DB, no module state — so usage.ts and the + * per-provider fetcher leaves (MiniMax, GLM, …) import it without a cycle. + */ + +import { toNumber, clampPercentage } from "./scalars.ts"; + +export type UsageQuota = { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + resetAt: string | null; + unlimited: boolean; + /** + * True when the upstream provider reported the remaining fraction. False + * means the API didn't include the field and the 0 value here is a sentinel, + * NOT a confirmed-exhausted state. Antigravity-specific. + */ + fractionReported?: boolean; + quotaSource?: "retrieveUserQuota" | "fetchAvailableModels" | "localUsageHistory"; + displayName?: string; + details?: Array<{ + name: string; + used: number; + }>; + currency?: string; + grantedBalance?: number; + toppedUpBalance?: number; +}; + +export function parseResetTime(resetValue: unknown): string | null { + if (!resetValue) return null; + + try { + let date: Date; + if (resetValue instanceof Date) { + date = resetValue; + } else if (typeof resetValue === "number") { + date = new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue); + } else if (typeof resetValue === "string") { + // Numeric strings are Unix timestamps too (seconds or milliseconds). + // `new Date("1700000000")` otherwise returns Invalid Date. + if (/^\d+$/.test(resetValue)) { + const ts = Number(resetValue); + date = new Date(ts < 1e12 ? ts * 1000 : ts); + } else { + date = new Date(resetValue); + } + } else { + return null; + } + + // Epoch-zero (1970-01-01) means no scheduled reset — treat as null + if (date.getTime() <= 0) return null; + + return date.toISOString(); + } catch { + return null; + } +} + +export function createQuotaFromUsage( + usedValue: unknown, + totalValue: unknown, + resetValue: unknown +): UsageQuota { + const total = Math.max(0, toNumber(totalValue, 0)); + const used = total > 0 ? Math.min(Math.max(0, toNumber(usedValue, 0)), total) : 0; + const remaining = total > 0 ? Math.max(total - used, 0) : 0; + + return { + used, + total, + remaining, + remainingPercentage: total > 0 ? clampPercentage((remaining / total) * 100) : 0, + resetAt: parseResetTime(resetValue), + unlimited: false, + }; +} diff --git a/tests/unit/usage-quota-core-split.test.ts b/tests/unit/usage-quota-core-split.test.ts new file mode 100644 index 000000000000..eb2197a5bfa4 --- /dev/null +++ b/tests/unit/usage-quota-core-split.test.ts @@ -0,0 +1,52 @@ +// Characterization of the services/usage.ts quota-core split (god-file decomposition): the shared +// UsageQuota shape + the two pure builders (parseResetTime, createQuotaFromUsage) moved into +// services/usage/quota.ts so the per-provider fetcher leaves can share them without a cycle. +// Behavior-preserving move — these locks pin the timestamp normalization (sec vs ms, epoch-zero → +// null, numeric strings) and the used/total → quota assembly (clamping, remaining %). +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const Q = await import("../../open-sse/services/usage/quota.ts"); + +test("parseResetTime normalizes seconds, millis, numeric strings; epoch-zero → null", () => { + // seconds (< 1e12) get *1000 + assert.equal(Q.parseResetTime(1_700_000_000), new Date(1_700_000_000_000).toISOString()); + // millis pass through + assert.equal(Q.parseResetTime(1_700_000_000_000), new Date(1_700_000_000_000).toISOString()); + // numeric string as seconds + assert.equal(Q.parseResetTime("1700000000"), new Date(1_700_000_000_000).toISOString()); + // ISO string passes through + assert.equal(Q.parseResetTime("2026-01-02T03:04:05.000Z"), "2026-01-02T03:04:05.000Z"); + // epoch-zero / falsy / junk → null + assert.equal(Q.parseResetTime(0), null); + assert.equal(Q.parseResetTime(null), null); + assert.equal(Q.parseResetTime("not a date"), null); +}); + +test("createQuotaFromUsage clamps used to total and computes remaining %", () => { + const q = Q.createQuotaFromUsage(30, 100, null); + assert.equal(q.total, 100); + assert.equal(q.used, 30); + assert.equal(q.remaining, 70); + assert.equal(q.remainingPercentage, 70); + assert.equal(q.unlimited, false); + assert.equal(q.resetAt, null); + + // used above total is clamped + const over = Q.createQuotaFromUsage(250, 100, null); + assert.equal(over.used, 100); + assert.equal(over.remaining, 0); + assert.equal(over.remainingPercentage, 0); + + // total 0 → everything zeroed + const zero = Q.createQuotaFromUsage(5, 0, null); + assert.equal(zero.total, 0); + assert.equal(zero.used, 0); + assert.equal(zero.remaining, 0); + assert.equal(zero.remainingPercentage, 0); +}); + +test("createQuotaFromUsage threads the reset timestamp through parseResetTime", () => { + const q = Q.createQuotaFromUsage(1, 10, 1_700_000_000); + assert.equal(q.resetAt, new Date(1_700_000_000_000).toISOString()); +});