Repository navigation
refactor(sse): extrai quota-core (UsageQuota + builders) de services/usage.ts #4950
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
resetValueis an invalid date string (e.g.,'not a date'),new Date(resetValue)produces anInvalid Dateobject whosegetTime()returnsNaN. SinceNaN <= 0evaluates tofalse, the code proceeds to calldate.toISOString(), which throws aRangeError: Invalid time value.While the
try...catchblock handles this and returnsnull, relying on exceptions for expected control flow is inefficient and can disrupt debugging (e.g., when 'Pause on caught exceptions' is enabled). Explicitly checking forNaNavoids throwing the exception.