diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17a06f85..2f9f4d08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
# v0.5.35 (2026-07-16)
## Features
+- **User limits**: add per-user total-token budgets for Orbit Provider and Codex with rolling 5-hour and weekly windows
- **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/open-sse/config/userTokenLimits.js b/open-sse/config/userTokenLimits.js
new file mode 100644
index 00000000..14cd31d3
--- /dev/null
+++ b/open-sse/config/userTokenLimits.js
@@ -0,0 +1,19 @@
+export const USER_TOKEN_LIMIT_PROVIDERS = Object.freeze({
+ ORBIT: "orbit-provider",
+ CODEX: "codex",
+});
+
+export const USER_TOKEN_LIMIT_WINDOWS = Object.freeze({
+ SESSION: "session",
+ WEEKLY: "weekly",
+});
+
+export const USER_TOKEN_LIMIT_PROVIDER_IDS = Object.freeze(
+ Object.values(USER_TOKEN_LIMIT_PROVIDERS),
+);
+
+export const USER_TOKEN_LIMIT_WINDOW_IDS = Object.freeze(
+ Object.values(USER_TOKEN_LIMIT_WINDOWS),
+);
+
+export const USER_TOKEN_LIMIT_SESSION_MS = 5 * 60 * 60 * 1000;
\ No newline at end of file
diff --git a/src/app/(dashboard)/dashboard/users/page.js b/src/app/(dashboard)/dashboard/users/page.js
index da038cdc..82b1cd33 100644
--- a/src/app/(dashboard)/dashboard/users/page.js
+++ b/src/app/(dashboard)/dashboard/users/page.js
@@ -6,8 +6,36 @@ 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";
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 }) => [
+ id,
+ {
+ [USER_TOKEN_LIMIT_WINDOWS.SESSION]: 0,
+ [USER_TOKEN_LIMIT_WINDOWS.WEEKLY]: 0,
+ },
+ ]));
+}
function formatDate(value) {
if (!value) return "—";
@@ -25,6 +53,11 @@ export default function UsersPage() {
const [editor, setEditor] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState(null);
+ const [limitEditor, setLimitEditor] = useState(null);
+ const [tokenLimits, setTokenLimits] = useState(createEmptyTokenLimits);
+ const [limitsLoading, setLimitsLoading] = useState(false);
+ const [limitsSaving, setLimitsSaving] = useState(false);
+ const [limitsError, setLimitsError] = useState("");
const loadUsers = useCallback(async () => {
setLoading(true);
@@ -113,6 +146,60 @@ export default function UsersPage() {
}
};
+ const openTokenLimits = async (target) => {
+ setLimitEditor(target);
+ setTokenLimits(createEmptyTokenLimits());
+ setLimitsError("");
+ setLimitsLoading(true);
+ try {
+ const response = await fetch(`/api/users/${target.id}/token-limits`, { cache: "no-store" });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "Failed to load token limits");
+ setTokenLimits(data.limits || createEmptyTokenLimits());
+ } catch (requestError) {
+ setLimitsError(requestError.message || "Failed to load token limits");
+ } finally {
+ setLimitsLoading(false);
+ }
+ };
+
+ const updateTokenLimit = (provider, windowType, value) => {
+ setTokenLimits((current) => ({
+ ...current,
+ [provider]: {
+ ...current[provider],
+ [windowType]: value,
+ },
+ }));
+ };
+
+ const saveTokenLimits = async () => {
+ if (!limitEditor) return;
+ setLimitsSaving(true);
+ setLimitsError("");
+ try {
+ const normalizedLimits = Object.fromEntries(TOKEN_LIMIT_PROVIDER_OPTIONS.map(({ id }) => [
+ id,
+ {
+ [USER_TOKEN_LIMIT_WINDOWS.SESSION]: Number(tokenLimits[id]?.session || 0),
+ [USER_TOKEN_LIMIT_WINDOWS.WEEKLY]: Number(tokenLimits[id]?.weekly || 0),
+ },
+ ]));
+ const response = await fetch(`/api/users/${limitEditor.id}/token-limits`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ limits: normalizedLimits }),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "Failed to save token limits");
+ setLimitEditor(null);
+ } catch (requestError) {
+ setLimitsError(requestError.message || "Failed to save token limits");
+ } finally {
+ setLimitsSaving(false);
+ }
+ };
+
if (!user || user.role !== "admin") {
return
Loading user management…
;
}
@@ -158,6 +245,7 @@ export default function UsersPage() {
{formatDate(entry.createdAt)}
+ {entry.role === "user" ? openTokenLimits(entry)}>Token limits : null}
openEdit(entry)}>Edit
setDeleteTarget(entry)} disabled={entry.id === user.id}>Delete
@@ -183,6 +271,73 @@ export default function UsersPage() {
+ !limitsSaving && setLimitEditor(null)}
+ title={`Token limits · ${limitEditor?.username || "user"}`}
+ footer={<> setLimitEditor(null)} disabled={limitsSaving}>Cancel Save limits >}
+ >
+
+
+
+
hourglass_top
+
+
Total token budgets
+
Usage includes input and output tokens. Session usage is measured over the previous 5 hours; weekly usage resets Monday at 00:00 Vietnam time. Enter 0 for unlimited.
+
+
+
+
+ {limitsError ?
{limitsError}
: null}
+
+ {limitsLoading ? (
+
Loading token limits…
+ ) : (
+
+ {TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => (
+
+
+
+ {provider.icon}
+
+
+
{provider.name}
+
{provider.description}
+
+
+
+
+ ))}
+
+ )}
+
+
+
!saving && setDeleteTarget(null)}
diff --git a/src/app/api/users/[userId]/token-limits/route.js b/src/app/api/users/[userId]/token-limits/route.js
new file mode 100644
index 00000000..a3e62fea
--- /dev/null
+++ b/src/app/api/users/[userId]/token-limits/route.js
@@ -0,0 +1,56 @@
+import { NextResponse } from "next/server";
+import {
+ getUserById,
+ getUserTokenLimits,
+ replaceUserTokenLimits,
+} from "@/lib/db/index.js";
+import { requireAdminUser } from "@/lib/auth/currentUser.js";
+
+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 });
+}
+
+async function getTargetUser(params) {
+ const { userId } = await params;
+ const user = await getUserById(userId);
+ if (!user) throw new Error("User not found");
+ return user;
+}
+
+export async function GET(_request, { params }) {
+ try {
+ await requireAdminUser();
+ const user = await getTargetUser(params);
+ const limits = await getUserTokenLimits(user.id);
+ return NextResponse.json({ limits }, { headers: NO_STORE_HEADERS });
+ } catch (error) {
+ return errorResponse(error);
+ }
+}
+
+export async function PUT(request, { params }) {
+ try {
+ await requireAdminUser();
+ const user = await getTargetUser(params);
+ if (user.role !== "user") throw new Error("Token limits only apply to user accounts");
+
+ const body = await request.json();
+ if (!body?.limits || typeof body.limits !== "object" || Array.isArray(body.limits)) {
+ throw new Error("Token limits are required");
+ }
+ const limits = await replaceUserTokenLimits(user.id, body?.limits);
+ return NextResponse.json({ limits }, { headers: NO_STORE_HEADERS });
+ } catch (error) {
+ return errorResponse(error);
+ }
+}
diff --git a/src/lib/db/index.js b/src/lib/db/index.js
index 602d635c..ff478c18 100644
--- a/src/lib/db/index.js
+++ b/src/lib/db/index.js
@@ -2,6 +2,13 @@
import { getAdapter } from "./driver.js";
import { stringifyJson, parseJson } from "./helpers/jsonCol.js";
import { normalizeCliToolConfig, isPersistableCliTool } from "@/shared/constants/cliToolConfig.js";
+import {
+ USER_TOKEN_LIMIT_PROVIDER_IDS,
+ USER_TOKEN_LIMIT_WINDOW_IDS,
+} from "open-sse/config/userTokenLimits.js";
+
+const userTokenLimitProviderSet = new Set(USER_TOKEN_LIMIT_PROVIDER_IDS);
+const userTokenLimitWindowSet = new Set(USER_TOKEN_LIMIT_WINDOW_IDS);
// Settings
export {
@@ -52,6 +59,12 @@ export {
upsertCliToolConfig, deleteCliToolConfigsByOwnerId,
} from "./repos/cliToolConfigsRepo.js";
+// Per-user provider token limits
+export {
+ createEmptyUserTokenLimits, getUserTokenLimits,
+ replaceUserTokenLimits, getUserProviderTokenUsageSince,
+} from "./repos/userTokenLimitsRepo.js";
+
// Aliases (model + custom + mitm)
export {
getModelAliases, setModelAlias, deleteModelAlias,
@@ -95,6 +108,7 @@ export async function exportDb() {
apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, ownerId: r.ownerId, isActive: r.isActive === 1, createdAt: r.createdAt })),
combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, ownerId: r.ownerId, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })),
cliToolConfigs: db.all(`SELECT * FROM cliToolConfigs`).map((r) => ({ ownerId: r.ownerId, toolId: r.toolId, config: parseJson(r.data, {}), createdAt: r.createdAt, updatedAt: r.updatedAt })),
+ userTokenLimits: db.all(`SELECT userId, provider, windowType, tokenLimit, createdAt, updatedAt FROM userTokenLimits`),
modelAliases: {},
customModels: [],
mitmAlias: {},
@@ -130,6 +144,7 @@ export async function importDb(payload) {
// Wipe all tables (keep _meta)
db.run(`DELETE FROM settings`);
db.run(`DELETE FROM cliToolConfigs`);
+ db.run(`DELETE FROM userTokenLimits`);
// Old backups predate multi-user authentication. Preserve the local
// administrator unless the payload explicitly carries a users array.
if (Array.isArray(payload.users)) db.run(`DELETE FROM users`);
@@ -155,6 +170,19 @@ export async function importDb(payload) {
}
}
+ const importedUserIds = new Set(db.all(`SELECT id FROM users`).map((user) => user.id));
+ for (const limit of payload.userTokenLimits || []) {
+ if (!importedUserIds.has(limit?.userId)) continue;
+ if (!userTokenLimitProviderSet.has(limit.provider) || !userTokenLimitWindowSet.has(limit.windowType)) continue;
+ const tokenLimit = Number(limit.tokenLimit);
+ if (!Number.isSafeInteger(tokenLimit) || tokenLimit <= 0) continue;
+ db.run(
+ `INSERT OR REPLACE INTO userTokenLimits(userId, provider, windowType, tokenLimit, createdAt, updatedAt)
+ VALUES(?, ?, ?, ?, ?, ?)`,
+ [limit.userId, limit.provider, limit.windowType, tokenLimit, limit.createdAt || new Date().toISOString(), limit.updatedAt || new Date().toISOString()],
+ );
+ }
+
const adminOwnerIds = new Set(
db.all(`SELECT id FROM users WHERE role = 'admin'`).map((user) => user.id),
);
diff --git a/src/lib/db/migrations/008-user-token-limits.js b/src/lib/db/migrations/008-user-token-limits.js
new file mode 100644
index 00000000..27cb7e2f
--- /dev/null
+++ b/src/lib/db/migrations/008-user-token-limits.js
@@ -0,0 +1,16 @@
+import { TABLES, buildCreateTableSql } from "../schema.js";
+
+const userTokenLimitsMigration = {
+ version: 8,
+ name: "user-token-limits",
+ up(db) {
+ const definition = TABLES.userTokenLimits;
+ db.exec(buildCreateTableSql("userTokenLimits", definition));
+ for (const index of definition.indexes || []) db.exec(index);
+ db.exec(
+ "CREATE INDEX IF NOT EXISTS idx_uh_user_provider_ts ON usageHistory(userId, provider, timestamp)",
+ );
+ },
+};
+
+export default userTokenLimitsMigration;
\ No newline at end of file
diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js
index a43c71a1..716038f5 100644
--- a/src/lib/db/migrations/index.js
+++ b/src/lib/db/migrations/index.js
@@ -8,8 +8,9 @@ import m004 from "./004-provider-connection-owners.js";
import m005 from "./005-usage-user-attribution.js";
import m006 from "./006-combo-owners.js";
import m007 from "./007-admin-provider-connections.js";
+import m008 from "./008-user-token-limits.js";
-export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007].sort((a, b) => a.version - b.version);
+export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008].sort((a, b) => a.version - b.version);
export function latestVersion() {
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
diff --git a/src/lib/db/repos/userTokenLimitsRepo.js b/src/lib/db/repos/userTokenLimitsRepo.js
new file mode 100644
index 00000000..e4a8c0c6
--- /dev/null
+++ b/src/lib/db/repos/userTokenLimitsRepo.js
@@ -0,0 +1,115 @@
+import { getAdapter } from "../driver.js";
+import {
+ USER_TOKEN_LIMIT_PROVIDER_IDS,
+ USER_TOKEN_LIMIT_WINDOW_IDS,
+} from "open-sse/config/userTokenLimits.js";
+
+const providerSet = new Set(USER_TOKEN_LIMIT_PROVIDER_IDS);
+const windowSet = new Set(USER_TOKEN_LIMIT_WINDOW_IDS);
+
+export function createEmptyUserTokenLimits() {
+ return Object.fromEntries(
+ USER_TOKEN_LIMIT_PROVIDER_IDS.map((provider) => [
+ provider,
+ Object.fromEntries(USER_TOKEN_LIMIT_WINDOW_IDS.map((windowType) => [windowType, 0])),
+ ]),
+ );
+}
+
+function assertProvider(provider) {
+ if (!providerSet.has(provider)) throw new Error("Unsupported token limit provider");
+}
+
+function assertWindowType(windowType) {
+ if (!windowSet.has(windowType)) throw new Error("Unsupported token limit window");
+}
+
+function normalizeTokenLimit(value) {
+ const tokenLimit = Number(value);
+ if (!Number.isSafeInteger(tokenLimit) || tokenLimit < 0) {
+ throw new Error("Token limit must be a non-negative integer");
+ }
+ return tokenLimit;
+}
+
+function normalizeLimits(limits) {
+ if (!limits || typeof limits !== "object" || Array.isArray(limits)) {
+ throw new Error("Token limits are required");
+ }
+ for (const provider of Object.keys(limits)) assertProvider(provider);
+ for (const providerLimits of Object.values(limits)) {
+ if (!providerLimits || typeof providerLimits !== "object" || Array.isArray(providerLimits)) {
+ throw new Error("Provider token limits must be an object");
+ }
+ for (const windowType of Object.keys(providerLimits)) assertWindowType(windowType);
+ }
+
+ const normalized = createEmptyUserTokenLimits();
+ for (const provider of USER_TOKEN_LIMIT_PROVIDER_IDS) {
+ for (const windowType of USER_TOKEN_LIMIT_WINDOW_IDS) {
+ normalized[provider][windowType] = normalizeTokenLimit(
+ limits?.[provider]?.[windowType] ?? 0,
+ );
+ }
+ }
+ return normalized;
+}
+
+export async function getUserTokenLimits(userId) {
+ if (!userId) return createEmptyUserTokenLimits();
+ const db = await getAdapter();
+ const limits = createEmptyUserTokenLimits();
+ const rows = db.all(
+ `SELECT provider, windowType, tokenLimit
+ FROM userTokenLimits
+ WHERE userId = ?`,
+ [userId],
+ );
+
+ for (const row of rows) {
+ if (!providerSet.has(row.provider) || !windowSet.has(row.windowType)) continue;
+ limits[row.provider][row.windowType] = Math.max(0, Number(row.tokenLimit) || 0);
+ }
+ return limits;
+}
+
+export async function replaceUserTokenLimits(userId, limits) {
+ if (!userId) throw new Error("User id is required");
+ const normalized = normalizeLimits(limits);
+ const db = await getAdapter();
+ const now = new Date().toISOString();
+
+ db.transaction(() => {
+ db.run(`DELETE FROM userTokenLimits WHERE userId = ?`, [userId]);
+ for (const provider of USER_TOKEN_LIMIT_PROVIDER_IDS) {
+ for (const windowType of USER_TOKEN_LIMIT_WINDOW_IDS) {
+ const tokenLimit = normalized[provider][windowType];
+ if (tokenLimit === 0) continue;
+ db.run(
+ `INSERT INTO userTokenLimits(userId, provider, windowType, tokenLimit, createdAt, updatedAt)
+ VALUES(?, ?, ?, ?, ?, ?)`,
+ [userId, provider, windowType, tokenLimit, now, now],
+ );
+ }
+ }
+ });
+
+ return normalized;
+}
+
+export async function getUserProviderTokenUsageSince(userId, provider, since) {
+ if (!userId) return 0;
+ 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 COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS totalTokens
+ FROM usageHistory
+ WHERE userId = ? AND provider = ? AND timestamp >= ?`,
+ [userId, provider, since.toISOString()],
+ );
+ return Math.max(0, Number(row?.totalTokens) || 0);
+}
\ No newline at end of file
diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js
index c2b5b0f9..ac841f31 100644
--- a/src/lib/db/schema.js
+++ b/src/lib/db/schema.js
@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting
// to bump only skips that backup — it does NOT break the additive auto-sync.
-export const SCHEMA_VERSION = 8;
+export const SCHEMA_VERSION = 9;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -134,6 +134,20 @@ export const TABLES = {
primaryKey: "PRIMARY KEY (ownerId, toolId)",
indexes: ["CREATE INDEX IF NOT EXISTS idx_cli_tool_configs_owner ON cliToolConfigs(ownerId)"],
},
+ userTokenLimits: {
+ columns: {
+ userId: "TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE",
+ provider: "TEXT NOT NULL",
+ windowType: "TEXT NOT NULL",
+ tokenLimit: "INTEGER NOT NULL DEFAULT 0",
+ createdAt: "TEXT NOT NULL",
+ updatedAt: "TEXT NOT NULL",
+ },
+ primaryKey: "PRIMARY KEY (userId, provider, windowType)",
+ indexes: [
+ "CREATE INDEX IF NOT EXISTS idx_user_token_limits_user ON userTokenLimits(userId)",
+ ],
+ },
kv: {
columns: {
scope: "TEXT NOT NULL",
@@ -166,6 +180,7 @@ export const TABLES = {
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
"CREATE INDEX IF NOT EXISTS idx_uh_user ON usageHistory(userId)",
+ "CREATE INDEX IF NOT EXISTS idx_uh_user_provider_ts ON usageHistory(userId, provider, timestamp)",
],
},
usageDaily: {
diff --git a/src/lib/tokenLimitEnforcer.js b/src/lib/tokenLimitEnforcer.js
new file mode 100644
index 00000000..04fe6795
--- /dev/null
+++ b/src/lib/tokenLimitEnforcer.js
@@ -0,0 +1,74 @@
+import {
+ USER_TOKEN_LIMIT_PROVIDER_IDS,
+ USER_TOKEN_LIMIT_SESSION_MS,
+ USER_TOKEN_LIMIT_WINDOWS,
+} from "open-sse/config/userTokenLimits.js";
+import {
+ getUserById,
+ getUserProviderTokenUsageSince,
+ getUserTokenLimits,
+} from "@/lib/db/index.js";
+import {
+ getVietnamDateKey,
+ shiftVietnamDateKey,
+} from "@/shared/utils/dateTime.js";
+
+const limitedProviderSet = new Set(USER_TOKEN_LIMIT_PROVIDER_IDS);
+
+export function getUserTokenLimitWindowStart(windowType, now = new Date()) {
+ const current = now instanceof Date ? now : new Date(now);
+ if (!Number.isFinite(current.getTime())) throw new Error("A valid current time is required");
+
+ if (windowType === USER_TOKEN_LIMIT_WINDOWS.SESSION) {
+ return new Date(current.getTime() - USER_TOKEN_LIMIT_SESSION_MS);
+ }
+
+ if (windowType === USER_TOKEN_LIMIT_WINDOWS.WEEKLY) {
+ const dateKey = getVietnamDateKey(current);
+ const vietnamNoon = new Date(`${dateKey}T12:00:00+07:00`);
+ const daysSinceMonday = (vietnamNoon.getUTCDay() + 6) % 7;
+ const mondayKey = shiftVietnamDateKey(dateKey, -daysSinceMonday);
+ return new Date(`${mondayKey}T00:00:00+07:00`);
+ }
+
+ throw new Error("Unsupported token limit window");
+}
+
+/**
+ * Check whether a dashboard user has exhausted a provider token budget.
+ * Returns null when the provider/user is exempt or all configured limits have headroom.
+ */
+export async function checkUserTokenLimit(userId, provider, now = new Date()) {
+ if (!userId || !limitedProviderSet.has(provider)) return null;
+
+ const user = await getUserById(userId);
+ if (!user || !user.isActive || user.role !== "user") return null;
+
+ const limits = await getUserTokenLimits(user.id);
+ const providerLimits = limits[provider];
+ if (!providerLimits) return null;
+
+ for (const windowType of [
+ USER_TOKEN_LIMIT_WINDOWS.SESSION,
+ USER_TOKEN_LIMIT_WINDOWS.WEEKLY,
+ ]) {
+ const limit = providerLimits[windowType];
+ if (!Number.isSafeInteger(limit) || limit <= 0) continue;
+
+ const windowStart = getUserTokenLimitWindowStart(windowType, now);
+ const used = await getUserProviderTokenUsageSince(user.id, provider, windowStart);
+ if (used >= limit) {
+ return {
+ exceeded: true,
+ provider,
+ windowType,
+ limit,
+ used,
+ remaining: 0,
+ windowStart: windowStart.toISOString(),
+ };
+ }
+ }
+
+ return null;
+}
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js
index 96744c5e..63445e2b 100644
--- a/src/sse/handlers/chat.js
+++ b/src/sse/handlers/chat.js
@@ -26,6 +26,7 @@ import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { getProjectIdForConnection } from "open-sse/services/projectId.js";
import { getDisabledModelResponse } from "../services/disabledModels.js";
+import { checkUserTokenLimit } from "@/lib/tokenLimitEnforcer.js";
/**
* Handle chat completion request
@@ -203,6 +204,14 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const disabledModelResponse = await getDisabledModelResponse(provider, model);
if (disabledModelResponse) return disabledModelResponse;
+ const tokenLimitResult = await checkUserTokenLimit(ownerId, provider);
+ if (tokenLimitResult) {
+ const { windowType, limit, used } = tokenLimitResult;
+ const message = `Token limit exceeded: ${windowType} limit of ${limit} tokens reached (${used} used)`;
+ log.warn("TOKEN_LIMIT", message, { userId: ownerId, provider, windowType, limit, used });
+ return errorResponse(HTTP_STATUS.RATE_LIMITED, message);
+ }
+
// Routing shown in the unified "▶" line (client model → provider/model)
// Extract userAgent from request
diff --git a/tests/unit/db-migration-chain.test.js b/tests/unit/db-migration-chain.test.js
index e95486d3..29e5fc05 100644
--- a/tests/unit/db-migration-chain.test.js
+++ b/tests/unit/db-migration-chain.test.js
@@ -34,13 +34,15 @@ describe("Schema migrations", () => {
const tables = db.all(`SELECT name FROM sqlite_master WHERE type='table'`).map(t => t.name);
expect(tables).toEqual(expect.arrayContaining([
- "_meta", "settings", "providerConnections", "providerNodes",
+ "_meta", "settings", "users", "userTokenLimits", "providerConnections", "providerNodes",
"proxyPools", "apiKeys", "combos", "kv", "usageHistory", "usageDaily", "requestDetails",
]));
expect(db.all(`PRAGMA table_info(providerConnections)`).map((column) => column.name)).toContain("ownerId");
expect(db.all(`PRAGMA index_list(providerConnections)`).map((index) => index.name)).toContain("idx_pc_owner");
expect(db.all(`PRAGMA table_info(combos)`).map((column) => column.name)).toContain("ownerId");
expect(db.all(`PRAGMA index_list(combos)`).map((index) => index.name)).toContain("idx_combo_owner_name");
+ expect(db.all(`PRAGMA index_list(userTokenLimits)`).map((index) => index.name)).toContain("idx_user_token_limits_user");
+ expect(db.all(`PRAGMA index_list(usageHistory)`).map((index) => index.name)).toContain("idx_uh_user_provider_ts");
});
it("existing DB at older schemaVersion → re-applies pending migrations on restart", async () => {
diff --git a/tests/unit/user-token-limit-enforcer.test.js b/tests/unit/user-token-limit-enforcer.test.js
new file mode 100644
index 00000000..b820fa73
--- /dev/null
+++ b/tests/unit/user-token-limit-enforcer.test.js
@@ -0,0 +1,93 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getUserById = vi.fn();
+const getUserProviderTokenUsageSince = vi.fn();
+const getUserTokenLimits = vi.fn();
+
+vi.mock("@/lib/db/index.js", () => ({
+ getUserById,
+ getUserProviderTokenUsageSince,
+ getUserTokenLimits,
+}));
+
+const {
+ checkUserTokenLimit,
+ getUserTokenLimitWindowStart,
+} = await import("@/lib/tokenLimitEnforcer.js");
+
+describe("user token limit enforcement", () => {
+ beforeEach(() => {
+ getUserById.mockReset();
+ getUserProviderTokenUsageSince.mockReset();
+ getUserTokenLimits.mockReset();
+ getUserById.mockResolvedValue({ id: "user-1", role: "user", isActive: true });
+ getUserTokenLimits.mockResolvedValue({
+ "orbit-provider": { session: 100, weekly: 1000 },
+ codex: { session: 200, weekly: 2000 },
+ });
+ });
+
+ it("calculates rolling session and Monday Vietnam weekly window starts", () => {
+ const now = new Date("2026-07-17T10:00:00.000Z");
+
+ expect(getUserTokenLimitWindowStart("session", now).toISOString())
+ .toBe("2026-07-17T05:00:00.000Z");
+ expect(getUserTokenLimitWindowStart("weekly", now).toISOString())
+ .toBe("2026-07-12T17:00:00.000Z");
+ });
+
+ it("blocks when the rolling session total reaches its limit", async () => {
+ getUserProviderTokenUsageSince.mockResolvedValueOnce(100);
+
+ const result = await checkUserTokenLimit(
+ "user-1",
+ "orbit-provider",
+ new Date("2026-07-17T10:00:00.000Z"),
+ );
+
+ expect(result).toMatchObject({
+ exceeded: true,
+ provider: "orbit-provider",
+ windowType: "session",
+ limit: 100,
+ used: 100,
+ });
+ expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(1);
+ });
+
+ it("checks weekly usage after the session window still has headroom", async () => {
+ getUserProviderTokenUsageSince
+ .mockResolvedValueOnce(80)
+ .mockResolvedValueOnce(1200);
+
+ const result = await checkUserTokenLimit(
+ "user-1",
+ "orbit-provider",
+ new Date("2026-07-17T10:00:00.000Z"),
+ );
+
+ expect(result).toMatchObject({ windowType: "weekly", limit: 1000, used: 1200 });
+ expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(2);
+ });
+
+ it("exempts administrators, unknown users, and unsupported providers", async () => {
+ getUserById.mockResolvedValueOnce({ id: "admin-1", role: "admin", isActive: true });
+ await expect(checkUserTokenLimit("admin-1", "codex")).resolves.toBeNull();
+
+ getUserById.mockResolvedValueOnce(null);
+ await expect(checkUserTokenLimit("missing", "codex")).resolves.toBeNull();
+
+ await expect(checkUserTokenLimit("user-1", "openai")).resolves.toBeNull();
+ expect(getUserTokenLimits).not.toHaveBeenCalled();
+ });
+
+ it("treats zero limits as unlimited", async () => {
+ getUserTokenLimits.mockResolvedValue({
+ "orbit-provider": { session: 0, weekly: 0 },
+ codex: { session: 0, weekly: 0 },
+ });
+
+ await expect(checkUserTokenLimit("user-1", "codex")).resolves.toBeNull();
+ expect(getUserProviderTokenUsageSince).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/user-token-limits-repo.test.js b/tests/unit/user-token-limits-repo.test.js
new file mode 100644
index 00000000..7da28bd3
--- /dev/null
+++ b/tests/unit/user-token-limits-repo.test.js
@@ -0,0 +1,101 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+let tempDir;
+const originalDataDir = process.env.DATA_DIR;
+
+beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-token-limits-"));
+ process.env.DATA_DIR = tempDir;
+ delete global._dbAdapter;
+ vi.resetModules();
+});
+
+afterEach(() => {
+ try { global._dbAdapter?.instance?.close?.(); } catch {}
+ delete global._dbAdapter;
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ if (originalDataDir === undefined) delete process.env.DATA_DIR;
+ else process.env.DATA_DIR = originalDataDir;
+});
+
+describe("user token limit repository", () => {
+ it("replaces and normalizes all supported provider limits", async () => {
+ const db = await import("@/lib/db/index.js");
+ const user = await db.createUser({ username: "limited-user", password: "password", role: "user" });
+
+ await expect(db.getUserTokenLimits(user.id)).resolves.toEqual({
+ "orbit-provider": { session: 0, weekly: 0 },
+ codex: { session: 0, weekly: 0 },
+ });
+
+ await db.replaceUserTokenLimits(user.id, {
+ "orbit-provider": { session: 100, weekly: 1000 },
+ codex: { session: 0, weekly: 2000 },
+ });
+
+ await expect(db.getUserTokenLimits(user.id)).resolves.toEqual({
+ "orbit-provider": { session: 100, weekly: 1000 },
+ codex: { session: 0, weekly: 2000 },
+ });
+ });
+
+ it("sums prompt and completion tokens by user, provider, and timestamp", async () => {
+ const db = await import("@/lib/db/index.js");
+ const { getAdapter } = await import("@/lib/db/driver.js");
+ const user = await db.createUser({ username: "usage-user", password: "password", role: "user" });
+ const adapter = await getAdapter();
+
+ for (const [timestamp, provider, prompt, completion] of [
+ ["2026-07-17T06:00:00.000Z", "codex", 30, 20],
+ ["2026-07-17T09:00:00.000Z", "codex", 40, 10],
+ ["2026-07-17T09:30:00.000Z", "orbit-provider", 500, 500],
+ ]) {
+ adapter.run(
+ `INSERT INTO usageHistory(timestamp, provider, userId, promptTokens, completionTokens)
+ VALUES(?, ?, ?, ?, ?)`,
+ [timestamp, provider, user.id, prompt, completion],
+ );
+ }
+
+ await expect(db.getUserProviderTokenUsageSince(
+ user.id,
+ "codex",
+ new Date("2026-07-17T08:00:00.000Z"),
+ )).resolves.toBe(50);
+ });
+
+ it("rejects negative and non-integer limits without changing stored values", async () => {
+ const db = await import("@/lib/db/index.js");
+ const user = await db.createUser({ username: "invalid-limit", password: "password", role: "user" });
+
+ await db.replaceUserTokenLimits(user.id, {
+ "orbit-provider": { session: 100, weekly: 0 },
+ codex: { session: 0, weekly: 0 },
+ });
+
+ await expect(db.replaceUserTokenLimits(user.id, {
+ "orbit-provider": { session: -1, weekly: 0 },
+ codex: { session: 0, weekly: 0 },
+ })).rejects.toThrow("Token limit must be a non-negative integer");
+
+ await expect(db.getUserTokenLimits(user.id)).resolves.toMatchObject({
+ "orbit-provider": { session: 100 },
+ });
+ });
+
+ it("rejects unsupported providers and window names", async () => {
+ const db = await import("@/lib/db/index.js");
+ const user = await db.createUser({ username: "invalid-scope", password: "password", role: "user" });
+
+ await expect(db.replaceUserTokenLimits(user.id, {
+ openai: { session: 100 },
+ })).rejects.toThrow("Unsupported token limit provider");
+
+ await expect(db.replaceUserTokenLimits(user.id, {
+ codex: { monthly: 100 },
+ })).rejects.toThrow("Unsupported token limit window");
+ });
+});
diff --git a/tests/unit/user-token-limits-route.test.js b/tests/unit/user-token-limits-route.test.js
new file mode 100644
index 00000000..d184bfcb
--- /dev/null
+++ b/tests/unit/user-token-limits-route.test.js
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const requireAdminUser = vi.fn();
+const getUserById = vi.fn();
+const getUserTokenLimits = vi.fn();
+const replaceUserTokenLimits = 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,
+ replaceUserTokenLimits,
+}));
+
+const { GET, PUT } = await import("@/app/api/users/[userId]/token-limits/route.js");
+const context = (userId = "user-1") => ({ params: Promise.resolve({ userId }) });
+const putRequest = (body) => new Request("https://9router.local/api/users/user-1/token-limits", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+});
+
+const limits = {
+ "orbit-provider": { session: 100, weekly: 1000 },
+ codex: { session: 200, weekly: 2000 },
+};
+
+describe("/api/users/[userId]/token-limits", () => {
+ beforeEach(() => {
+ requireAdminUser.mockReset();
+ getUserById.mockReset();
+ getUserTokenLimits.mockReset();
+ replaceUserTokenLimits.mockReset();
+ requireAdminUser.mockResolvedValue({ id: "admin-1", role: "admin" });
+ getUserById.mockResolvedValue({ id: "user-1", role: "user", isActive: true });
+ getUserTokenLimits.mockResolvedValue(limits);
+ replaceUserTokenLimits.mockResolvedValue(limits);
+ });
+
+ it("returns an administrator-only no-store response", async () => {
+ const response = await GET(new Request("https://9router.local"), context());
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ await expect(response.json()).resolves.toEqual({ limits });
+ expect(getUserTokenLimits).toHaveBeenCalledWith("user-1");
+ });
+
+ it("replaces all limits for a regular user", async () => {
+ const response = await PUT(putRequest({ limits }), context());
+
+ expect(response.status).toBe(200);
+ expect(replaceUserTokenLimits).toHaveBeenCalledWith("user-1", limits);
+ });
+
+ it("rejects non-admin access and administrator targets", async () => {
+ requireAdminUser.mockRejectedValueOnce(new Error("Forbidden"));
+ expect((await GET(new Request("https://9router.local"), context())).status).toBe(403);
+
+ getUserById.mockResolvedValueOnce({ id: "admin-2", role: "admin", isActive: true });
+ const response = await PUT(putRequest({ limits }), context("admin-2"));
+ expect(response.status).toBe(400);
+ expect(replaceUserTokenLimits).not.toHaveBeenCalled();
+ });
+
+ it("requires an explicit limits object", async () => {
+ const response = await PUT(putRequest({}), context());
+
+ expect(response.status).toBe(400);
+ expect(replaceUserTokenLimits).not.toHaveBeenCalled();
+ });
+});