diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9f4d08..59b506d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features - **User limits**: add per-user total-token budgets for Orbit Provider and Codex with rolling 5-hour and weekly windows +- **User quota**: show remaining Orbit and Codex headroom in the users table with session and weekly usage details - **Orbit Provider**: add Anthropic-compatible API-key routing for Claude Opus 4.6–4.8 models - **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI - **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml` diff --git a/src/app/(dashboard)/dashboard/users/components/QuotaCell.js b/src/app/(dashboard)/dashboard/users/components/QuotaCell.js new file mode 100644 index 00000000..ac16cec9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/users/components/QuotaCell.js @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { + TOKEN_LIMIT_PROVIDER_OPTIONS, + TOKEN_LIMIT_WINDOW_OPTIONS, + formatTokenCount, + getProviderRemainingPercentage, + getQuotaTone, +} from "./tokenLimitDisplay.js"; + +function buildQuotaSummary(providerUsage) { + return TOKEN_LIMIT_WINDOW_OPTIONS + .map(({ id, name }) => { + const usage = providerUsage?.[id]; + if (!usage?.limit) return null; + return `${name}: ${formatTokenCount(usage.remaining)} of ${formatTokenCount(usage.limit)} tokens left`; + }) + .filter(Boolean) + .join(" · "); +} + +export default function QuotaCell({ userId, refreshKey = 0 }) { + const [state, setState] = useState({ status: "loading", data: null }); + + useEffect(() => { + const controller = new AbortController(); + + async function loadUsage() { + setState((current) => ({ ...current, status: "loading" })); + try { + const response = await fetch(`/api/users/${userId}/token-usage`, { + cache: "no-store", + signal: controller.signal, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "Failed to load token usage"); + setState({ status: "ready", data: payload }); + } catch (error) { + if (error?.name !== "AbortError") setState({ status: "error", data: null }); + } + } + + void loadUsage(); + return () => controller.abort(); + }, [refreshKey, userId]); + + if (state.status === "loading") { + return ( +
+ + +
+ ); + } + + if (state.status === "error") { + return ( + + + ); + } + + const configuredProviders = TOKEN_LIMIT_PROVIDER_OPTIONS.flatMap((provider) => { + const usage = state.data?.providers?.[provider.id]; + const remainingPercentage = getProviderRemainingPercentage(usage); + return remainingPercentage === null ? [] : [{ ...provider, usage, remainingPercentage }]; + }); + + if (configuredProviders.length === 0) { + return Unlimited; + } + + return ( +
+ {configuredProviders.map((provider) => { + const tone = getQuotaTone(provider.remainingPercentage); + const title = `${provider.name} · ${buildQuotaSummary(provider.usage)}`; + return ( + + + ); + })} +
+ ); +} + +QuotaCell.propTypes = { + userId: PropTypes.string.isRequired, + refreshKey: PropTypes.number, +}; diff --git a/src/app/(dashboard)/dashboard/users/components/TokenLimitsUsage.js b/src/app/(dashboard)/dashboard/users/components/TokenLimitsUsage.js new file mode 100644 index 00000000..cbd0a187 --- /dev/null +++ b/src/app/(dashboard)/dashboard/users/components/TokenLimitsUsage.js @@ -0,0 +1,178 @@ +"use client"; + +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { + TOKEN_LIMIT_PROVIDER_OPTIONS, + TOKEN_LIMIT_WINDOW_OPTIONS, + formatTokenCount, + getProviderRemainingPercentage, + getQuotaTone, +} from "./tokenLimitDisplay.js"; + +function UsageWindow({ option, usage }) { + const hasLimit = usage?.limit > 0; + + if (!hasLimit) { + return ( +
+
+

{option.name}

+

{option.description}

+
+
+ + {formatTokenCount(usage?.used)} used + + + Unlimited + +
+
+ ); + } + + const tone = getQuotaTone(usage.remainingPercentage); + + return ( +
+
+

{option.name}

+

{option.description}

+
+
+
+ + {usage.remainingPercentage}% remaining + + + {formatTokenCount(usage.used)} / {formatTokenCount(usage.limit)} used + +
+
+
+
+
+
+ ); +} + +function UsageSkeleton() { + return ( +
+ {[1, 2].map((key) => ( +
+
+
+
+
+ ))} +
+ ); +} + +export default function TokenLimitsUsage({ userId, refreshKey = 0 }) { + const [state, setState] = useState({ status: "loading", data: null, error: "" }); + + useEffect(() => { + const controller = new AbortController(); + + async function loadUsage() { + setState((current) => ({ ...current, status: "loading", error: "" })); + try { + const response = await fetch(`/api/users/${userId}/token-usage`, { + cache: "no-store", + signal: controller.signal, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "Failed to load token usage"); + setState({ status: "ready", data: payload, error: "" }); + } catch (error) { + if (error?.name !== "AbortError") { + setState({ status: "error", data: null, error: error?.message || "Failed to load token usage" }); + } + } + } + + void loadUsage(); + return () => controller.abort(); + }, [refreshKey, userId]); + + if (state.status === "loading") return ; + + if (state.status === "error") { + return ( +
+ error +
+

Usage data is unavailable

+

{state.error}

+
+
+ ); + } + + return ( +
+ {TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => { + const providerUsage = state.data?.providers?.[provider.id]; + const remainingPercentage = getProviderRemainingPercentage(providerUsage); + const tone = remainingPercentage === null ? null : getQuotaTone(remainingPercentage); + + return ( +
+
+
+ {provider.icon} +
+

{provider.name}

+

{provider.description}

+
+
+ {remainingPercentage === null ? ( + No limits + ) : ( + + + )} +
+
+ {TOKEN_LIMIT_WINDOW_OPTIONS.map((option) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +UsageWindow.propTypes = { + option: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + usage: PropTypes.shape({ + limit: PropTypes.number, + used: PropTypes.number, + remainingPercentage: PropTypes.number, + }), +}; + +TokenLimitsUsage.propTypes = { + userId: PropTypes.string.isRequired, + refreshKey: PropTypes.number, +}; diff --git a/src/app/(dashboard)/dashboard/users/components/tokenLimitDisplay.js b/src/app/(dashboard)/dashboard/users/components/tokenLimitDisplay.js new file mode 100644 index 00000000..8f2d1ec8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/users/components/tokenLimitDisplay.js @@ -0,0 +1,76 @@ +import { + USER_TOKEN_LIMIT_PROVIDERS, + USER_TOKEN_LIMIT_WINDOWS, +} from "open-sse/config/userTokenLimits.js"; + +const TOKEN_FORMATTER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); +const COMPACT_TOKEN_FORMATTER = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +export const TOKEN_LIMIT_PROVIDER_OPTIONS = Object.freeze([ + { + id: USER_TOKEN_LIMIT_PROVIDERS.ORBIT, + name: "Orbit Provider", + shortName: "Orbit", + description: "Anthropic-compatible traffic routed through Orbit.", + icon: "orbit", + }, + { + id: USER_TOKEN_LIMIT_PROVIDERS.CODEX, + name: "Codex", + shortName: "Codex", + description: "OpenAI Codex responses and coding sessions.", + icon: "terminal", + }, +]); + +export const TOKEN_LIMIT_WINDOW_OPTIONS = Object.freeze([ + { + id: USER_TOKEN_LIMIT_WINDOWS.SESSION, + name: "Session", + description: "Rolling 5 hours", + }, + { + id: USER_TOKEN_LIMIT_WINDOWS.WEEKLY, + name: "Weekly", + description: "Resets Monday, Vietnam time", + }, +]); + +export function getQuotaTone(remainingPercentage) { + if (remainingPercentage > 70) { + return { + bar: "bg-emerald-500", + dot: "bg-emerald-500", + text: "text-emerald-600 dark:text-emerald-400", + }; + } + if (remainingPercentage >= 30) { + return { + bar: "bg-amber-500", + dot: "bg-amber-500", + text: "text-amber-600 dark:text-amber-400", + }; + } + return { + bar: "bg-red-500", + dot: "bg-red-500", + text: "text-red-600 dark:text-red-400", + }; +} + +export function getProviderRemainingPercentage(providerUsage) { + const activeWindows = TOKEN_LIMIT_WINDOW_OPTIONS + .map(({ id }) => providerUsage?.[id]) + .filter((windowUsage) => windowUsage?.limit > 0); + + if (activeWindows.length === 0) return null; + return Math.min(...activeWindows.map((windowUsage) => windowUsage.remainingPercentage)); +} + +export function formatTokenCount(value, compact = false) { + const amount = Math.max(0, Number(value) || 0); + return (compact ? COMPACT_TOKEN_FORMATTER : TOKEN_FORMATTER).format(amount); +} diff --git a/src/app/(dashboard)/dashboard/users/page.js b/src/app/(dashboard)/dashboard/users/page.js index 82b1cd33..a334a94f 100644 --- a/src/app/(dashboard)/dashboard/users/page.js +++ b/src/app/(dashboard)/dashboard/users/page.js @@ -6,26 +6,12 @@ import { Button, Card, Input } from "@/shared/components"; import Modal, { ConfirmModal } from "@/shared/components/Modal"; import useUserStore from "@/store/userStore"; import { formatVietnamDateTime } from "@/shared/utils/dateTime"; -import { - USER_TOKEN_LIMIT_PROVIDERS, - 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 TokenLimitsUsage from "./components/TokenLimitsUsage"; +import { TOKEN_LIMIT_PROVIDER_OPTIONS } from "./components/tokenLimitDisplay.js"; const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true }; -const TOKEN_LIMIT_PROVIDER_OPTIONS = [ - { - id: USER_TOKEN_LIMIT_PROVIDERS.ORBIT, - name: "Orbit Provider", - description: "Anthropic-compatible traffic routed through Orbit.", - icon: "orbit", - }, - { - id: USER_TOKEN_LIMIT_PROVIDERS.CODEX, - name: "Codex", - description: "OpenAI Codex responses and coding sessions.", - icon: "terminal", - }, -]; function createEmptyTokenLimits() { return Object.fromEntries(TOKEN_LIMIT_PROVIDER_OPTIONS.map(({ id }) => [ @@ -58,6 +44,7 @@ export default function UsersPage() { const [limitsLoading, setLimitsLoading] = useState(false); const [limitsSaving, setLimitsSaving] = useState(false); const [limitsError, setLimitsError] = useState(""); + const [quotaRefreshKey, setQuotaRefreshKey] = useState(0); const loadUsers = useCallback(async () => { setLoading(true); @@ -192,6 +179,7 @@ export default function UsersPage() { }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to save token limits"); + setQuotaRefreshKey((current) => current + 1); setLimitEditor(null); } catch (requestError) { setLimitsError(requestError.message || "Failed to save token limits"); @@ -228,20 +216,24 @@ export default function UsersPage() { Username Role Status + Quota remaining Created Actions {loading ? ( - Loading users… + Loading users… ) : users.length === 0 ? ( - No users found. + No users found. ) : users.map((entry) => ( {entry.username}{entry.id === user.id ? (you) : null} {entry.role} {entry.isActive ? "Active" : "Disabled"} + + {entry.role === "user" ? : } + {formatDate(entry.createdAt)}
@@ -274,10 +266,11 @@ export default function UsersPage() { !limitsSaving && setLimitEditor(null)} - title={`Token limits · ${limitEditor?.username || "user"}`} + title={`Usage & limits · ${limitEditor?.username || "user"}`} + size="xl" footer={<>} > -
+
hourglass_top @@ -290,10 +283,24 @@ export default function UsersPage() { {limitsError ?

{limitsError}

: null} + {limitEditor ? ( +
+
+

Current headroom

+

The lowest active window determines the quota shown in the users table.

+
+ +
+ ) : null} + {limitsLoading ? (
Loading token limits…
) : ( -
+
+
+

Budget settings

+

Set 0 to leave a provider window unlimited.

+
{TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => (
@@ -333,7 +340,7 @@ export default function UsersPage() {
))} -
+ )}
diff --git a/src/app/api/users/[userId]/token-usage/route.js b/src/app/api/users/[userId]/token-usage/route.js new file mode 100644 index 00000000..213772b0 --- /dev/null +++ b/src/app/api/users/[userId]/token-usage/route.js @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { + getUserById, + getUserProviderTokenUsageSince, + getUserTokenLimits, +} from "@/lib/db/index.js"; +import { requireAdminUser } from "@/lib/auth/currentUser.js"; +import { getUserTokenLimitWindowStart } from "@/lib/tokenLimitEnforcer.js"; +import { + USER_TOKEN_LIMIT_PROVIDER_IDS, + USER_TOKEN_LIMIT_WINDOW_IDS, +} from "open-sse/config/userTokenLimits.js"; + +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; + +function errorResponse(error) { + const message = error?.message || "Request failed"; + const status = message === "Unauthorized" + ? 401 + : message === "Forbidden" + ? 403 + : message === "User not found" + ? 404 + : 400; + return NextResponse.json({ error: message }, { status, headers: NO_STORE_HEADERS }); +} + +function buildWindowUsage(limit, used, windowStart) { + const normalizedLimit = Math.max(0, Number(limit) || 0); + const normalizedUsed = Math.max(0, Number(used) || 0); + const remaining = normalizedLimit > 0 + ? Math.max(0, normalizedLimit - normalizedUsed) + : null; + const remainingPercentage = normalizedLimit > 0 + ? Math.round((remaining / normalizedLimit) * 100) + : null; + + return { + limit: normalizedLimit, + used: normalizedUsed, + remaining, + remainingPercentage, + windowStart: windowStart.toISOString(), + }; +} + +export async function GET(_request, { params }) { + try { + await requireAdminUser(); + + const { userId } = await params; + const user = await getUserById(userId); + if (!user) throw new Error("User not found"); + if (user.role !== "user") throw new Error("Token usage only applies to user accounts"); + + const now = new Date(); + const limits = await getUserTokenLimits(user.id); + const windows = Object.fromEntries(USER_TOKEN_LIMIT_WINDOW_IDS.map((windowType) => [ + windowType, + getUserTokenLimitWindowStart(windowType, now), + ])); + + const usageEntries = await Promise.all( + USER_TOKEN_LIMIT_PROVIDER_IDS.flatMap((provider) => ( + USER_TOKEN_LIMIT_WINDOW_IDS.map(async (windowType) => { + const used = await getUserProviderTokenUsageSince( + user.id, + provider, + windows[windowType], + ); + return [provider, windowType, used]; + }) + )), + ); + + const usageByProvider = Object.fromEntries( + USER_TOKEN_LIMIT_PROVIDER_IDS.map((provider) => [provider, {}]), + ); + for (const [provider, windowType, used] of usageEntries) { + usageByProvider[provider][windowType] = buildWindowUsage( + limits[provider]?.[windowType], + used, + windows[windowType], + ); + } + + return NextResponse.json({ + userId: user.id, + providers: usageByProvider, + updatedAt: now.toISOString(), + }, { headers: NO_STORE_HEADERS }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/tests/unit/user-token-usage-route.test.js b/tests/unit/user-token-usage-route.test.js new file mode 100644 index 00000000..b65f2f64 --- /dev/null +++ b/tests/unit/user-token-usage-route.test.js @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const requireAdminUser = vi.fn(); +const getUserById = vi.fn(); +const getUserTokenLimits = vi.fn(); +const getUserProviderTokenUsageSince = vi.fn(); +const getUserTokenLimitWindowStart = vi.fn(); + +vi.mock("next/server", () => ({ + NextResponse: { + json(body, init = {}) { + return new Response(JSON.stringify(body), { + status: init.status || 200, + headers: { "Content-Type": "application/json", ...(init.headers || {}) }, + }); + }, + }, +})); +vi.mock("@/lib/auth/currentUser.js", () => ({ requireAdminUser })); +vi.mock("@/lib/db/index.js", () => ({ + getUserById, + getUserTokenLimits, + getUserProviderTokenUsageSince, +})); +vi.mock("@/lib/tokenLimitEnforcer.js", () => ({ getUserTokenLimitWindowStart })); + +const { GET } = await import("@/app/api/users/[userId]/token-usage/route.js"); +const context = (userId = "user-1") => ({ params: Promise.resolve({ userId }) }); +const request = new Request("https://9router.local/api/users/user-1/token-usage"); + +const sessionStart = new Date("2026-07-17T05:00:00.000Z"); +const weeklyStart = new Date("2026-07-13T17:00:00.000Z"); +const limits = { + "orbit-provider": { session: 100, weekly: 1000 }, + codex: { session: 0, weekly: 500 }, +}; + +function usageKey(provider, since) { + return `${provider}|${since.toISOString()}`; +} + +describe("/api/users/[userId]/token-usage", () => { + beforeEach(() => { + requireAdminUser.mockReset(); + getUserById.mockReset(); + getUserTokenLimits.mockReset(); + getUserProviderTokenUsageSince.mockReset(); + getUserTokenLimitWindowStart.mockReset(); + + requireAdminUser.mockResolvedValue({ id: "admin-1", role: "admin" }); + getUserById.mockResolvedValue({ id: "user-1", role: "user", isActive: true }); + getUserTokenLimits.mockResolvedValue(limits); + getUserTokenLimitWindowStart.mockImplementation((windowType) => ( + windowType === "session" ? sessionStart : weeklyStart + )); + + const usage = new Map([ + [usageKey("orbit-provider", sessionStart), 25], + [usageKey("orbit-provider", weeklyStart), 1200], + [usageKey("codex", sessionStart), 12], + [usageKey("codex", weeklyStart), 400], + ]); + getUserProviderTokenUsageSince.mockImplementation(async (_userId, provider, since) => ( + usage.get(usageKey(provider, since)) || 0 + )); + }); + + it("returns usage and remaining headroom for every provider window", async () => { + const response = await GET(request, context()); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(payload).toMatchObject({ + userId: "user-1", + providers: { + "orbit-provider": { + session: { + limit: 100, + used: 25, + remaining: 75, + remainingPercentage: 75, + windowStart: sessionStart.toISOString(), + }, + weekly: { + limit: 1000, + used: 1200, + remaining: 0, + remainingPercentage: 0, + windowStart: weeklyStart.toISOString(), + }, + }, + codex: { + session: { + limit: 0, + used: 12, + remaining: null, + remainingPercentage: null, + windowStart: sessionStart.toISOString(), + }, + weekly: { + limit: 500, + used: 400, + remaining: 100, + remainingPercentage: 20, + windowStart: weeklyStart.toISOString(), + }, + }, + }, + }); + expect(payload.updatedAt).toEqual(expect.any(String)); + expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(4); + }); + + it("requires an administrator and an existing regular user", async () => { + requireAdminUser.mockRejectedValueOnce(new Error("Forbidden")); + expect((await GET(request, context())).status).toBe(403); + + getUserById.mockResolvedValueOnce(null); + expect((await GET(request, context("missing-user"))).status).toBe(404); + + getUserById.mockResolvedValueOnce({ id: "admin-2", role: "admin", isActive: true }); + expect((await GET(request, context("admin-2"))).status).toBe(400); + }); +});