fix: improve the UI for set the limit token for the user

This commit is contained in:
2026-07-18 16:03:34 +07:00
parent 8f6e57dfc6
commit 21532e0f00
10 changed files with 239 additions and 48 deletions
+2 -1
View File
@@ -27,4 +27,5 @@ export const USER_TOKEN_LIMIT_WINDOW_CONFIG = Object.freeze({
}), }),
}); });
export const USER_TOKEN_LIMIT_SESSION_MS = 5 * 60 * 60 * 1000; export const USER_TOKEN_LIMIT_SESSION_MS = 5 * 60 * 60 * 1000;
export const USER_TOKEN_LIMIT_WEEKLY_MS = 7 * 24 * 60 * 60 * 1000;
@@ -211,15 +211,17 @@ export function setQuotaCache(connectionId, quotaEntry) {
/** /**
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit) * Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
* @param {string|Date} date - ISO date string or Date object * @param {string|Date} date - ISO date string or Date object
* @param {string|Date|number} now - Current time, injectable for live countdowns
* @returns {string} Formatted countdown (e.g., "2d 5h 30m", "4h 40m", "15m") or "-" * @returns {string} Formatted countdown (e.g., "2d 5h 30m", "4h 40m", "15m") or "-"
*/ */
export function formatResetTime(date) { export function formatResetTime(date, now = new Date()) {
if (!date) return "-"; if (!date) return "-";
try { try {
const resetDate = typeof date === "string" ? new Date(date) : date; const resetDate = typeof date === "string" ? new Date(date) : date;
const now = new Date(); const currentTime = now instanceof Date ? now : new Date(now);
const diffMs = resetDate - now; if (!Number.isFinite(resetDate.getTime()) || !Number.isFinite(currentTime.getTime())) return "-";
const diffMs = resetDate - currentTime;
if (diffMs <= 0) return "-"; if (diffMs <= 0) return "-";
@@ -4,10 +4,10 @@ import { useCallback, useEffect, useState } from "react";
import { AI_PROVIDERS } from "@/shared/constants/providers"; import { AI_PROVIDERS } from "@/shared/constants/providers";
import { Button, Card, CardSkeleton } from "@/shared/components"; import { Button, Card, CardSkeleton } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon"; import ProviderIcon from "@/shared/components/ProviderIcon";
import { REFRESH_INTERVAL_MS } from "./ProviderLimits/utils"; import { formatResetTime, REFRESH_INTERVAL_MS } from "./ProviderLimits/utils";
import { formatVietnamTime } from "@/shared/utils/dateTime"; import { formatVietnamTime } from "@/shared/utils/dateTime";
import { formatTokenCount } from "@/shared/utils/tokenCount.js"; import { formatTokenCount } from "@/shared/utils/tokenCount.js";
import { USER_TOKEN_LIMIT_WINDOW_CONFIG } from "open-sse/config/userTokenLimits.js"; import { USER_TOKEN_LIMIT_WINDOWS } from "open-sse/config/userTokenLimits.js";
function getProviderInfo(providerId) { function getProviderInfo(providerId) {
return AI_PROVIDERS[providerId] || { return AI_PROVIDERS[providerId] || {
@@ -36,6 +36,29 @@ function getQuotaTone(percentage) {
return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" }; return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" };
} }
function TokenQuotaResetStatus({ quota }) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
if (!quota.resetAt) return undefined;
const intervalId = window.setInterval(() => setNow(new Date()), 60 * 1000);
return () => window.clearInterval(intervalId);
}, [quota.resetAt]);
const countdown = formatResetTime(quota.resetAt, now);
const isSession = quota.windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION;
const text = countdown === "-"
? (isSession ? "No tokens pending expiry" : "Reset time unavailable")
: (isSession ? `Next tokens restore in ${countdown}` : `Resets in ${countdown}`);
return (
<span className="mt-0.5 block text-xs text-text-muted" aria-live="polite">
{text}
</span>
);
}
function QuotaListRow({ quota }) { function QuotaListRow({ quota }) {
const tone = getQuotaTone(quota.remainingPercentage); const tone = getQuotaTone(quota.remainingPercentage);
@@ -68,17 +91,16 @@ function QuotaListRow({ quota }) {
function TokenQuotaListRow({ quota }) { function TokenQuotaListRow({ quota }) {
const isUnlimited = quota.isUnlimited === true; const isUnlimited = quota.isUnlimited === true;
const tone = isUnlimited ? null : getQuotaTone(quota.remainingPercentage); const tone = isUnlimited ? null : getQuotaTone(quota.remainingPercentage);
const windowConfig = USER_TOKEN_LIMIT_WINDOW_CONFIG[quota.windowType];
const description = windowConfig?.description || "Personal token budget";
return ( return (
<li className="py-3 first:pt-0 last:pb-0"> <li className="py-3 first:pt-0 last:pb-0">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<span className="block truncate text-sm font-medium text-text-main">{quota.name}</span> <span className="block truncate text-sm font-medium text-text-main">{quota.name}</span>
<span className="mt-0.5 block text-xs text-text-muted">{description}</span> <TokenQuotaResetStatus key={quota.resetAt || "no-reset"} quota={quota} />
</div> </div>
{isUnlimited ? ( <div className="text-right">
{isUnlimited ? (
<span className="shrink-0 rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] font-medium text-text-muted"> <span className="shrink-0 rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] font-medium text-text-muted">
Unlimited Unlimited
</span> </span>
@@ -88,10 +110,9 @@ function TokenQuotaListRow({ quota }) {
{quota.remainingPercentage}% {quota.remainingPercentage}%
</span> </span>
)} )}
<div className="mt-2 gap-3 text-xs tabular-nums text-text-muted">
</div> </div>
<div className="mt-2 flex items-center justify-between gap-3 text-xs tabular-nums text-text-muted"> </div>
<span>{formatTokenCount(quota.used)} used</span>
{isUnlimited ? null : <span>{formatTokenCount(quota.limit)} token limit</span>}
</div> </div>
{!isUnlimited && ( {!isUnlimited && (
<div <div
@@ -137,7 +158,6 @@ function ProviderQuotaListItem({ provider }) {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate font-semibold text-text-main">{providerName}</h2> <h2 className="truncate font-semibold text-text-main">{providerName}</h2>
{isPersonalTokenBudget ? <p className="text-sm text-text-muted">Personal token budget</p> : null}
</div> </div>
</div> </div>
</header> </header>
+104 -31
View File
@@ -9,7 +9,11 @@ import { formatVietnamDateTime } from "@/shared/utils/dateTime";
import { USER_TOKEN_LIMIT_WINDOWS } from "open-sse/config/userTokenLimits.js"; import { USER_TOKEN_LIMIT_WINDOWS } from "open-sse/config/userTokenLimits.js";
import QuotaCell from "./components/QuotaCell"; import QuotaCell from "./components/QuotaCell";
import TokenLimitsUsage from "./components/TokenLimitsUsage"; import TokenLimitsUsage from "./components/TokenLimitsUsage";
import { TOKEN_LIMIT_PROVIDER_OPTIONS } from "./components/tokenLimitDisplay.js"; import {
TOKEN_LIMIT_PROVIDER_OPTIONS,
TOKEN_LIMIT_WINDOW_OPTIONS,
formatTokenCount,
} from "./components/tokenLimitDisplay.js";
const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true }; const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true };
@@ -28,6 +32,81 @@ function formatDate(value) {
return formatVietnamDateTime(value, { dateStyle: "medium", timeStyle: "short" }) || "—"; return formatVietnamDateTime(value, { dateStyle: "medium", timeStyle: "short" }) || "—";
} }
function normalizeTokenLimitInput(value) {
return value.replace(/\D/g, "").replace(/^0+(?=\d)/, "");
}
function getTokenLimitNumber(value) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
function TokenLimitField({ provider, windowOption, value, onChange }) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(String(value ?? ""));
const limit = getTokenLimitNumber(value);
const isUnlimited = limit === 0;
const inputId = `${provider.id}-${windowOption.id}-token-limit`;
const descriptionId = `${inputId}-description`;
const handleChange = (event) => {
const nextValue = normalizeTokenLimitInput(event.target.value);
setDraft(nextValue);
onChange(nextValue);
};
const handleBlur = () => {
setIsEditing(false);
if (!draft) onChange(0);
};
return (
<section className="rounded-xl border border-border-subtle bg-surface p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand-500/10 text-brand-500">
<span className="material-symbols-outlined text-[19px]">{windowOption.id === USER_TOKEN_LIMIT_WINDOWS.SESSION ? "schedule" : "date_range"}</span>
</div>
<div>
<h3 className="text-base font-semibold text-text-main">{windowOption.name}</h3>
<p className="mt-0.5 text-xs text-text-muted">{windowOption.description}</p>
</div>
</div>
<span className={`shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold ${isUnlimited ? "bg-surface-2 text-text-muted" : "bg-brand-500/10 text-brand-500"}`}>
{isUnlimited ? "Unlimited" : "Limited"}
</span>
</div>
<div className="mt-7">
<div className="flex items-center justify-between gap-3">
<label htmlFor={inputId} className="text-sm font-medium text-text-main">Token budget</label>
<p className="text-xs text-text-muted" aria-live="polite">{isUnlimited ? "No usage cap" : `${formatTokenCount(limit)} tokens`}</p>
</div>
<div className="relative mt-1.5">
<Input
id={inputId}
type="text"
inputMode="numeric"
autoComplete="off"
value={isEditing ? draft : formatTokenCount(limit)}
onFocus={() => {
setDraft(String(value ?? ""));
setIsEditing(true);
}}
onBlur={handleBlur}
onChange={handleChange}
aria-describedby={descriptionId}
aria-label={`${provider.name} ${windowOption.name.toLowerCase()} token limit`}
inputClassName="h-12 pr-20 font-mono text-lg font-semibold tabular-nums"
/>
<span className="pointer-events-none absolute inset-y-0 right-4 flex items-center text-xs font-medium text-text-muted">tokens</span>
</div>
<p id={descriptionId} className="mt-2 text-xs leading-5 text-text-muted">Enter <span className="font-mono tabular-nums">0</span> to leave this window unlimited.</p>
</div>
</section>
);
}
export default function UsersPage() { export default function UsersPage() {
const router = useRouter(); const router = useRouter();
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
@@ -45,6 +124,9 @@ export default function UsersPage() {
const [limitsSaving, setLimitsSaving] = useState(false); const [limitsSaving, setLimitsSaving] = useState(false);
const [limitsError, setLimitsError] = useState(""); const [limitsError, setLimitsError] = useState("");
const [quotaRefreshKey, setQuotaRefreshKey] = useState(0); const [quotaRefreshKey, setQuotaRefreshKey] = useState(0);
const activeTokenLimitCount = TOKEN_LIMIT_PROVIDER_OPTIONS.reduce((count, provider) => (
count + TOKEN_LIMIT_WINDOW_OPTIONS.filter(({ id }) => getTokenLimitNumber(tokenLimits[provider.id]?.[id]) > 0).length
), 0);
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -267,7 +349,7 @@ export default function UsersPage() {
isOpen={!!limitEditor} isOpen={!!limitEditor}
onClose={() => !limitsSaving && setLimitEditor(null)} onClose={() => !limitsSaving && setLimitEditor(null)}
title={`Usage & limits · ${limitEditor?.username || "user"}`} title={`Usage & limits · ${limitEditor?.username || "user"}`}
size="xl" size="full"
footer={<><Button variant="ghost" onClick={() => setLimitEditor(null)} disabled={limitsSaving}>Cancel</Button><Button variant="primary" onClick={saveTokenLimits} loading={limitsSaving} disabled={limitsLoading}>Save limits</Button></>} footer={<><Button variant="ghost" onClick={() => setLimitEditor(null)} disabled={limitsSaving}>Cancel</Button><Button variant="primary" onClick={saveTokenLimits} loading={limitsSaving} disabled={limitsLoading}>Save limits</Button></>}
> >
<div className="space-y-6"> <div className="space-y-6">
@@ -297,13 +379,19 @@ export default function UsersPage() {
<div className="py-10 text-center text-sm text-text-muted">Loading token limits</div> <div className="py-10 text-center text-sm text-text-muted">Loading token limits</div>
) : ( ) : (
<section className="space-y-3 border-t border-border-subtle pt-6"> <section className="space-y-3 border-t border-border-subtle pt-6">
<div> <div className="flex flex-col gap-3 rounded-xl border border-border-subtle bg-surface-2/35 p-5 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-500">Budget settings</p> <div>
<p className="mt-1 text-sm text-text-muted">Set 0 to leave a provider window unlimited.</p> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-500">Budget settings</p>
<p className="mt-1 text-sm text-text-muted">Enter an exact token budget for each usage window. Set 0 for unlimited.</p>
</div>
<div className="shrink-0 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-right">
<p className="font-mono text-lg font-semibold leading-none text-text-main tabular-nums">{activeTokenLimitCount}<span className="text-sm text-text-muted"> / {TOKEN_LIMIT_PROVIDER_OPTIONS.length * TOKEN_LIMIT_WINDOW_OPTIONS.length}</span></p>
<p className="mt-1 text-[11px] font-medium uppercase tracking-wide text-text-muted">active budgets</p>
</div>
</div> </div>
{TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => ( {TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => (
<section key={provider.id} className="rounded-xl border border-border-subtle bg-surface-2/35 p-4"> <section key={provider.id} className="rounded-xl border border-border-subtle bg-surface-2/35 p-5">
<div className="mb-4 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg border border-border-subtle bg-surface text-brand-500 shadow-sm"> <div className="flex size-10 items-center justify-center rounded-lg border border-border-subtle bg-surface text-brand-500 shadow-sm">
<span className="material-symbols-outlined text-[21px]">{provider.icon}</span> <span className="material-symbols-outlined text-[21px]">{provider.icon}</span>
</div> </div>
@@ -312,31 +400,16 @@ export default function UsersPage() {
<p className="text-xs text-text-muted">{provider.description}</p> <p className="text-xs text-text-muted">{provider.description}</p>
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-1.5"> {TOKEN_LIMIT_WINDOW_OPTIONS.map((windowOption) => (
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">Session · 5 hours</label> <TokenLimitField
<Input key={windowOption.id}
type="number" provider={provider}
min="0" windowOption={windowOption}
step="1" value={tokenLimits[provider.id]?.[windowOption.id] ?? 0}
inputMode="numeric" onChange={(value) => updateTokenLimit(provider.id, windowOption.id, value)}
value={tokenLimits[provider.id]?.[USER_TOKEN_LIMIT_WINDOWS.SESSION] ?? 0}
onChange={(event) => updateTokenLimit(provider.id, USER_TOKEN_LIMIT_WINDOWS.SESSION, event.target.value)}
aria-label={`${provider.name} session token limit`}
/> />
</div> ))}
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">Weekly</label>
<Input
type="number"
min="0"
step="1"
inputMode="numeric"
value={tokenLimits[provider.id]?.[USER_TOKEN_LIMIT_WINDOWS.WEEKLY] ?? 0}
onChange={(event) => updateTokenLimit(provider.id, USER_TOKEN_LIMIT_WINDOWS.WEEKLY, event.target.value)}
aria-label={`${provider.name} weekly token limit`}
/>
</div>
</div> </div>
</section> </section>
))} ))}
+6 -1
View File
@@ -84,7 +84,11 @@ function sanitizeQuotaForUser(data, user) {
}) => ({ }) => ({
...provider, ...provider,
hasFailedQuotaChecks: failedAccountCount > 0, hasFailedQuotaChecks: failedAccountCount > 0,
quotas: provider.quotas.map(({ resetAt: _resetAt, recurring: _recurring, ...quota }) => quota), quotas: provider.quotas.map(({ resetAt, recurring: _recurring, ...quota }) => (
provider.quotaSource === "user-token-limit"
? { ...quota, resetAt }
: quota
)),
})), })),
}; };
} }
@@ -138,6 +142,7 @@ function buildUserTokenQuotaProviders(tokenQuota, activeConnectionCounts) {
remainingPercentage: quota?.remainingPercentage ?? null, remainingPercentage: quota?.remainingPercentage ?? null,
isUnlimited: quota?.isUnlimited === true, isUnlimited: quota?.isUnlimited === true,
windowStart: quota?.windowStart || null, windowStart: quota?.windowStart || null,
resetAt: quota?.resetAt || null,
}; };
}), }),
}]; }];
+1
View File
@@ -63,6 +63,7 @@ export {
export { export {
createEmptyUserTokenLimits, getUserTokenLimits, createEmptyUserTokenLimits, getUserTokenLimits,
replaceUserTokenLimits, getUserProviderTokenUsageSince, replaceUserTokenLimits, getUserProviderTokenUsageSince,
getUserProviderEarliestTokenUsageSince,
} from "./repos/userTokenLimitsRepo.js"; } from "./repos/userTokenLimitsRepo.js";
// Aliases (model + custom + mitm) // Aliases (model + custom + mitm)
+24
View File
@@ -112,4 +112,28 @@ export async function getUserProviderTokenUsageSince(userId, provider, since) {
[userId, provider, since.toISOString()], [userId, provider, since.toISOString()],
); );
return Math.max(0, Number(row?.totalTokens) || 0); return Math.max(0, Number(row?.totalTokens) || 0);
}
/**
* Return the earliest request with billable tokens inside a rolling window.
* Its expiry determines when the first part of a rolling quota becomes available again.
*/
export async function getUserProviderEarliestTokenUsageSince(userId, provider, since) {
if (!userId) return null;
assertProvider(provider);
if (!(since instanceof Date) || !Number.isFinite(since.getTime())) {
throw new Error("A valid usage window start is required");
}
const db = await getAdapter();
const row = db.get(
`SELECT MIN(timestamp) AS timestamp
FROM usageHistory
WHERE userId = ?
AND provider = ?
AND timestamp >= ?
AND (COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)) > 0`,
[userId, provider, since.toISOString()],
);
return row?.timestamp || null;
} }
+38
View File
@@ -1,4 +1,5 @@
import { import {
getUserProviderEarliestTokenUsageSince,
getUserProviderTokenUsageSince, getUserProviderTokenUsageSince,
getUserTokenLimits, getUserTokenLimits,
} from "@/lib/db/index.js"; } from "@/lib/db/index.js";
@@ -6,6 +7,9 @@ import { getUserTokenLimitWindowStart } from "@/lib/tokenLimitEnforcer.js";
import { import {
USER_TOKEN_LIMIT_PROVIDER_IDS, USER_TOKEN_LIMIT_PROVIDER_IDS,
USER_TOKEN_LIMIT_WINDOW_IDS, USER_TOKEN_LIMIT_WINDOW_IDS,
USER_TOKEN_LIMIT_SESSION_MS,
USER_TOKEN_LIMIT_WEEKLY_MS,
USER_TOKEN_LIMIT_WINDOWS,
} from "open-sse/config/userTokenLimits.js"; } from "open-sse/config/userTokenLimits.js";
function normalizeNonNegativeNumber(value) { function normalizeNonNegativeNumber(value) {
@@ -33,6 +37,19 @@ export function buildUserTokenQuotaWindow(limit, used, windowStart) {
}; };
} }
function getSessionNextTokenRestoreAt(earliestTokenUsageAt, now) {
if (!earliestTokenUsageAt) return null;
const expiryTime = new Date(earliestTokenUsageAt).getTime() + USER_TOKEN_LIMIT_SESSION_MS;
return Number.isFinite(expiryTime) && expiryTime > now.getTime()
? new Date(expiryTime).toISOString()
: null;
}
function getWeeklyResetAt(windowStart) {
return new Date(windowStart.getTime() + USER_TOKEN_LIMIT_WEEKLY_MS).toISOString();
}
/** /**
* Return the configured token-budget usage for a dashboard user. * Return the configured token-budget usage for a dashboard user.
* Limits of zero intentionally remain unlimited while still reporting use. * Limits of zero intentionally remain unlimited while still reporting use.
@@ -58,6 +75,24 @@ export async function getUserTokenQuota(userId, now = new Date()) {
)), )),
); );
const sessionTokenUsageEntries = await Promise.all(
USER_TOKEN_LIMIT_PROVIDER_IDS.map(async (provider) => [
provider,
await getUserProviderEarliestTokenUsageSince(
userId,
provider,
windows[USER_TOKEN_LIMIT_WINDOWS.SESSION],
),
]),
);
const sessionNextTokenRestoreAt = Object.fromEntries(
sessionTokenUsageEntries.map(([provider, timestamp]) => [
provider,
getSessionNextTokenRestoreAt(timestamp, now),
]),
);
const weeklyResetAt = getWeeklyResetAt(windows[USER_TOKEN_LIMIT_WINDOWS.WEEKLY]);
const providers = Object.fromEntries( const providers = Object.fromEntries(
USER_TOKEN_LIMIT_PROVIDER_IDS.map((provider) => [provider, {}]), USER_TOKEN_LIMIT_PROVIDER_IDS.map((provider) => [provider, {}]),
); );
@@ -67,6 +102,9 @@ export async function getUserTokenQuota(userId, now = new Date()) {
used, used,
windows[windowType], windows[windowType],
); );
providers[provider][windowType].resetAt = windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION
? sessionNextTokenRestoreAt[provider]
: weeklyResetAt;
} }
return providers; return providers;
+11 -2
View File
@@ -38,6 +38,9 @@ function quota(sessionLimit, sessionUsed, weeklyLimit, weeklyUsed) {
remainingPercentage: limit > 0 ? Math.round((Math.max(0, limit - used) / limit) * 100) : null, remainingPercentage: limit > 0 ? Math.round((Math.max(0, limit - used) / limit) * 100) : null,
isUnlimited: limit === 0, isUnlimited: limit === 0,
windowStart, windowStart,
resetAt: windowStart === "2026-07-17T05:00:00.000Z"
? "2026-07-17T10:00:00.000Z"
: "2026-07-20T17:00:00.000Z",
}); });
return { return {
@@ -92,8 +95,14 @@ describe("/api/usage/system-quota personal token quota overlay", () => {
expect(providerById(payload, "codex").accountCount).toBeUndefined(); expect(providerById(payload, "codex").accountCount).toBeUndefined();
expect(providerById(payload, "codex").quotaAccountCount).toBeUndefined(); expect(providerById(payload, "codex").quotaAccountCount).toBeUndefined();
expect(providerById(payload, "codex").quotas).toMatchObject([ expect(providerById(payload, "codex").quotas).toMatchObject([
{ name: "Session", tokenBudget: true, limit: 100, used: 25, remaining: 75, remainingPercentage: 75 }, {
{ name: "Weekly", tokenBudget: true, limit: 1000, used: 200, remaining: 800, remainingPercentage: 80 }, name: "Session", tokenBudget: true, limit: 100, used: 25,
remaining: 75, remainingPercentage: 75, resetAt: "2026-07-17T10:00:00.000Z",
},
{
name: "Weekly", tokenBudget: true, limit: 1000, used: 200,
remaining: 800, remainingPercentage: 80, resetAt: "2026-07-20T17:00:00.000Z",
},
]); ]);
expect(providerById(payload, "orbit-provider").quotas).toMatchObject([ expect(providerById(payload, "orbit-provider").quotas).toMatchObject([
{ name: "Session", tokenBudget: true, limit: 100, used: 25 }, { name: "Session", tokenBudget: true, limit: 100, used: 25 },
+18
View File
@@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const getUserTokenLimits = vi.fn(); const getUserTokenLimits = vi.fn();
const getUserProviderTokenUsageSince = vi.fn(); const getUserProviderTokenUsageSince = vi.fn();
const getUserProviderEarliestTokenUsageSince = vi.fn();
const getUserTokenLimitWindowStart = vi.fn(); const getUserTokenLimitWindowStart = vi.fn();
vi.mock("@/lib/db/index.js", () => ({ vi.mock("@/lib/db/index.js", () => ({
getUserTokenLimits, getUserTokenLimits,
getUserProviderTokenUsageSince, getUserProviderTokenUsageSince,
getUserProviderEarliestTokenUsageSince,
})); }));
vi.mock("@/lib/tokenLimitEnforcer.js", () => ({ getUserTokenLimitWindowStart })); vi.mock("@/lib/tokenLimitEnforcer.js", () => ({ getUserTokenLimitWindowStart }));
@@ -23,6 +25,7 @@ describe("user token quota snapshot", () => {
beforeEach(() => { beforeEach(() => {
getUserTokenLimits.mockReset(); getUserTokenLimits.mockReset();
getUserProviderTokenUsageSince.mockReset(); getUserProviderTokenUsageSince.mockReset();
getUserProviderEarliestTokenUsageSince.mockReset();
getUserTokenLimitWindowStart.mockReset(); getUserTokenLimitWindowStart.mockReset();
getUserTokenLimits.mockResolvedValue({ getUserTokenLimits.mockResolvedValue({
@@ -42,6 +45,7 @@ describe("user token quota snapshot", () => {
const windowType = since.getTime() === sessionStart.getTime() ? "session" : "weekly"; const windowType = since.getTime() === sessionStart.getTime() ? "session" : "weekly";
return usage.get(usageKey(provider, windowType)); return usage.get(usageKey(provider, windowType));
}); });
getUserProviderEarliestTokenUsageSince.mockResolvedValue(null);
}); });
it("calculates both provider windows and preserves zero as unlimited", async () => { it("calculates both provider windows and preserves zero as unlimited", async () => {
@@ -58,7 +62,21 @@ describe("user token quota snapshot", () => {
}, },
}); });
expect(quota.codex.session.windowStart).toBe(sessionStart.toISOString()); expect(quota.codex.session.windowStart).toBe(sessionStart.toISOString());
expect(quota.codex.session.resetAt).toBeNull();
expect(quota.codex.weekly.resetAt).toBe("2026-07-20T17:00:00.000Z");
expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(4); expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(4);
expect(getUserProviderEarliestTokenUsageSince).toHaveBeenCalledTimes(2);
});
it("reports when the next tokens leave a rolling session window", async () => {
getUserProviderEarliestTokenUsageSince.mockImplementation(async (_userId, provider) => (
provider === "orbit-provider" ? "2026-07-17T06:30:00.000Z" : null
));
const quota = await getUserTokenQuota("user-1", new Date("2026-07-17T10:00:00.000Z"));
expect(quota["orbit-provider"].session.resetAt).toBe("2026-07-17T11:30:00.000Z");
expect(quota.codex.session.resetAt).toBeNull();
}); });
it("requires a user id", async () => { it("requires a user id", async () => {