mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: improve the UI for set the limit token for the user
This commit is contained in:
@@ -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)
|
||||
* @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 "-"
|
||||
*/
|
||||
export function formatResetTime(date) {
|
||||
export function formatResetTime(date, now = new Date()) {
|
||||
if (!date) return "-";
|
||||
|
||||
try {
|
||||
const resetDate = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = resetDate - now;
|
||||
const currentTime = now instanceof Date ? now : new Date(now);
|
||||
if (!Number.isFinite(resetDate.getTime()) || !Number.isFinite(currentTime.getTime())) return "-";
|
||||
const diffMs = resetDate - currentTime;
|
||||
|
||||
if (diffMs <= 0) return "-";
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { Button, Card, CardSkeleton } from "@/shared/components";
|
||||
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 { 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) {
|
||||
return AI_PROVIDERS[providerId] || {
|
||||
@@ -36,6 +36,29 @@ function getQuotaTone(percentage) {
|
||||
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 }) {
|
||||
const tone = getQuotaTone(quota.remainingPercentage);
|
||||
|
||||
@@ -68,17 +91,16 @@ function QuotaListRow({ quota }) {
|
||||
function TokenQuotaListRow({ quota }) {
|
||||
const isUnlimited = quota.isUnlimited === true;
|
||||
const tone = isUnlimited ? null : getQuotaTone(quota.remainingPercentage);
|
||||
const windowConfig = USER_TOKEN_LIMIT_WINDOW_CONFIG[quota.windowType];
|
||||
const description = windowConfig?.description || "Personal token budget";
|
||||
|
||||
return (
|
||||
<li className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
{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">
|
||||
Unlimited
|
||||
</span>
|
||||
@@ -88,10 +110,9 @@ function TokenQuotaListRow({ quota }) {
|
||||
{quota.remainingPercentage}%
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-2 gap-3 text-xs tabular-nums text-text-muted">
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3 text-xs tabular-nums text-text-muted">
|
||||
<span>{formatTokenCount(quota.used)} used</span>
|
||||
{isUnlimited ? null : <span>{formatTokenCount(quota.limit)} token limit</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div
|
||||
@@ -137,7 +158,6 @@ function ProviderQuotaListItem({ provider }) {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -9,7 +9,11 @@ import { formatVietnamDateTime } from "@/shared/utils/dateTime";
|
||||
import { USER_TOKEN_LIMIT_WINDOWS } from "open-sse/config/userTokenLimits.js";
|
||||
import QuotaCell from "./components/QuotaCell";
|
||||
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 };
|
||||
|
||||
@@ -28,6 +32,81 @@ function formatDate(value) {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
@@ -45,6 +124,9 @@ export default function UsersPage() {
|
||||
const [limitsSaving, setLimitsSaving] = useState(false);
|
||||
const [limitsError, setLimitsError] = useState("");
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -267,7 +349,7 @@ export default function UsersPage() {
|
||||
isOpen={!!limitEditor}
|
||||
onClose={() => !limitsSaving && setLimitEditor(null)}
|
||||
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></>}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<section className="space-y-3 border-t border-border-subtle pt-6">
|
||||
<div>
|
||||
<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">Set 0 to leave a provider window unlimited.</p>
|
||||
<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">
|
||||
<div>
|
||||
<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>
|
||||
{TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => (
|
||||
<section key={provider.id} className="rounded-xl border border-border-subtle bg-surface-2/35 p-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<section key={provider.id} className="rounded-xl border border-border-subtle bg-surface-2/35 p-5">
|
||||
<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">
|
||||
<span className="material-symbols-outlined text-[21px]">{provider.icon}</span>
|
||||
</div>
|
||||
@@ -312,31 +400,16 @@ export default function UsersPage() {
|
||||
<p className="text-xs text-text-muted">{provider.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">Session · 5 hours</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="numeric"
|
||||
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 className="grid gap-4 lg:grid-cols-2">
|
||||
{TOKEN_LIMIT_WINDOW_OPTIONS.map((windowOption) => (
|
||||
<TokenLimitField
|
||||
key={windowOption.id}
|
||||
provider={provider}
|
||||
windowOption={windowOption}
|
||||
value={tokenLimits[provider.id]?.[windowOption.id] ?? 0}
|
||||
onChange={(value) => updateTokenLimit(provider.id, windowOption.id, value)}
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -84,7 +84,11 @@ function sanitizeQuotaForUser(data, user) {
|
||||
}) => ({
|
||||
...provider,
|
||||
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,
|
||||
isUnlimited: quota?.isUnlimited === true,
|
||||
windowStart: quota?.windowStart || null,
|
||||
resetAt: quota?.resetAt || null,
|
||||
};
|
||||
}),
|
||||
}];
|
||||
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
export {
|
||||
createEmptyUserTokenLimits, getUserTokenLimits,
|
||||
replaceUserTokenLimits, getUserProviderTokenUsageSince,
|
||||
getUserProviderEarliestTokenUsageSince,
|
||||
} from "./repos/userTokenLimitsRepo.js";
|
||||
|
||||
// Aliases (model + custom + mitm)
|
||||
|
||||
@@ -112,4 +112,28 @@ export async function getUserProviderTokenUsageSince(userId, provider, since) {
|
||||
[userId, provider, since.toISOString()],
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
getUserProviderEarliestTokenUsageSince,
|
||||
getUserProviderTokenUsageSince,
|
||||
getUserTokenLimits,
|
||||
} from "@/lib/db/index.js";
|
||||
@@ -6,6 +7,9 @@ import { getUserTokenLimitWindowStart } from "@/lib/tokenLimitEnforcer.js";
|
||||
import {
|
||||
USER_TOKEN_LIMIT_PROVIDER_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";
|
||||
|
||||
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.
|
||||
* 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(
|
||||
USER_TOKEN_LIMIT_PROVIDER_IDS.map((provider) => [provider, {}]),
|
||||
);
|
||||
@@ -67,6 +102,9 @@ export async function getUserTokenQuota(userId, now = new Date()) {
|
||||
used,
|
||||
windows[windowType],
|
||||
);
|
||||
providers[provider][windowType].resetAt = windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION
|
||||
? sessionNextTokenRestoreAt[provider]
|
||||
: weeklyResetAt;
|
||||
}
|
||||
|
||||
return providers;
|
||||
|
||||
@@ -38,6 +38,9 @@ function quota(sessionLimit, sessionUsed, weeklyLimit, weeklyUsed) {
|
||||
remainingPercentage: limit > 0 ? Math.round((Math.max(0, limit - used) / limit) * 100) : null,
|
||||
isUnlimited: limit === 0,
|
||||
windowStart,
|
||||
resetAt: windowStart === "2026-07-17T05:00:00.000Z"
|
||||
? "2026-07-17T10:00:00.000Z"
|
||||
: "2026-07-20T17:00:00.000Z",
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -92,8 +95,14 @@ describe("/api/usage/system-quota personal token quota overlay", () => {
|
||||
expect(providerById(payload, "codex").accountCount).toBeUndefined();
|
||||
expect(providerById(payload, "codex").quotaAccountCount).toBeUndefined();
|
||||
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([
|
||||
{ name: "Session", tokenBudget: true, limit: 100, used: 25 },
|
||||
|
||||
@@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getUserTokenLimits = vi.fn();
|
||||
const getUserProviderTokenUsageSince = vi.fn();
|
||||
const getUserProviderEarliestTokenUsageSince = vi.fn();
|
||||
const getUserTokenLimitWindowStart = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db/index.js", () => ({
|
||||
getUserTokenLimits,
|
||||
getUserProviderTokenUsageSince,
|
||||
getUserProviderEarliestTokenUsageSince,
|
||||
}));
|
||||
vi.mock("@/lib/tokenLimitEnforcer.js", () => ({ getUserTokenLimitWindowStart }));
|
||||
|
||||
@@ -23,6 +25,7 @@ describe("user token quota snapshot", () => {
|
||||
beforeEach(() => {
|
||||
getUserTokenLimits.mockReset();
|
||||
getUserProviderTokenUsageSince.mockReset();
|
||||
getUserProviderEarliestTokenUsageSince.mockReset();
|
||||
getUserTokenLimitWindowStart.mockReset();
|
||||
|
||||
getUserTokenLimits.mockResolvedValue({
|
||||
@@ -42,6 +45,7 @@ describe("user token quota snapshot", () => {
|
||||
const windowType = since.getTime() === sessionStart.getTime() ? "session" : "weekly";
|
||||
return usage.get(usageKey(provider, windowType));
|
||||
});
|
||||
getUserProviderEarliestTokenUsageSince.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
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.resetAt).toBeNull();
|
||||
expect(quota.codex.weekly.resetAt).toBe("2026-07-20T17:00:00.000Z");
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user