feat(frontend): admin quota management, a usage meter and the quota chat error

The admin dashboard gets a Quotas tab for the instance default, team
allowances and user overrides, with a notice listing models that cost
limits cannot see. A Quota action on the Users tab shows a user's effective
limits, the layer each comes from and their usage, next to the editor for
their override. Each budget is either not set at that layer, a limit, or
unlimited.

Users with a quota see a usage meter with the reset time on the Analytics
settings page, and a refused chat request shows the used amount, the limit
and the reset time in the user's language.
This commit is contained in:
Alex committed 2026-09-21 12:11:12 +01:00
1 parent 21f43b2966
commit 1c9c94eba7
23 files changed
+1334 -3

No files matched your search

+2
View File
@@ -119,6 +119,8 @@ const EVENT_LABELS: Record<string, string> = {
scim_created: 'Provisioned',
scim_deactivated: 'Deactivated (SCIM)',
scim_activated: 'Activated (SCIM)',
quota_policy_set: 'Quota set',
quota_policy_deleted: 'Quota removed',
};
export function eventLabel(event: string): string {
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { selectToken } from '../preferences/preferenceSlice';
import { fmtNumber } from './AdminUI';
import {
fmtUsd,
formToPolicy,
isEmptyForm,
policyToForm,
usagePercent,
type Budget,
type BudgetMode,
type QuotaForm,
type QuotaPolicy,
} from './quotaUtils';
const MODES: { value: BudgetMode; label: string }[] = [
{ value: 'inherit', label: 'Not set here' },
{ value: 'limit', label: 'Limit' },
{ value: 'unlimited', label: 'Unlimited' },
];
export function UsageBar({
label,
budget,
kind,
caption,
}: {
label: string;
budget: Budget;
kind: 'tokens' | 'cost';
caption?: string;
}) {
const fmt = (n: number) => (kind === 'cost' ? fmtUsd(n) : fmtNumber(n));
const percent = usagePercent(budget.used, budget.limit);
const tone =
percent >= 100
? 'bg-red-500'
: percent >= 80
? 'bg-amber-500'
: 'bg-[#7D54D1]';
return (
<div>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">
{fmt(budget.used)}
{budget.limit === null ? ' · no limit' : ` of ${fmt(budget.limit)}`}
</span>
</div>
{budget.limit !== null ? (
<div
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div
className={`h-full rounded-full ${tone}`}
style={{ width: `${percent}%` }}
/>
</div>
) : null}
{caption ? (
<p className="text-muted-foreground mt-1 text-xs">{caption}</p>
) : null}
</div>
);
}
function BudgetField({
label,
hint,
mode,
value,
step,
onMode,
onValue,
}: {
label: string;
hint: string;
mode: BudgetMode;
value: string;
step: string;
onMode: (mode: BudgetMode) => void;
onValue: (value: string) => void;
}) {
return (
<div>
<p className="text-foreground text-sm font-medium">{label}</p>
<div className="mt-1 flex items-center gap-2">
<Select value={mode} onValueChange={(v) => onMode(v as BudgetMode)}>
<SelectTrigger className="w-40" aria-label={`${label} mode`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mode === 'limit' ? (
<Input
type="number"
min="0"
step={step}
inputMode="decimal"
value={value}
aria-label={label}
placeholder={hint}
onChange={(e) => onValue(e.target.value)}
className="flex-1"
/>
) : null}
</div>
</div>
);
}
/**
* Edits the ``all``-bucket policy of one subject. Saving a form with neither
* budget set removes the policy, since a policy without an opinion is not stored.
*/
export default function QuotaEditor({
scope,
subjectId,
policy,
inheritHint,
onSaved,
}: {
scope: QuotaScope;
subjectId: string | null;
policy: QuotaPolicy | null;
inheritHint: string;
onSaved: () => void;
}) {
const token = useSelector(selectToken);
const [form, setForm] = useState<QuotaForm>(() => policyToForm(policy));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setForm(policyToForm(policy));
setError(null);
}, [policy, scope, subjectId]);
const patch = (fields: Partial<QuotaForm>) =>
setForm((prev) => ({ ...prev, ...fields }));
const submit = async (request: () => Promise<Response>) => {
setBusy(true);
setError(null);
try {
const res = await request();
const json = await res.json().catch(() => ({}));
if (res.ok && json.success !== false) onSaved();
else setError(json.message || 'Could not save the quota.');
} catch {
setError('Could not save the quota.');
} finally {
setBusy(false);
}
};
const remove = () =>
submit(() => adminService.deleteQuota(scope, subjectId, 'all', token));
const save = () => {
if (isEmptyForm(form)) {
if (policy) remove();
return;
}
const result = formToPolicy(form);
if (!result.ok) {
setError(result.error);
return;
}
submit(() => adminService.setQuota(scope, subjectId, result.policy, token));
};
return (
<div className="space-y-4">
<p className="text-muted-foreground text-xs">{inheritHint}</p>
<BudgetField
label="Tokens"
hint="e.g. 2000000"
step="1"
mode={form.tokenMode}
value={form.tokenLimit}
onMode={(tokenMode) => patch({ tokenMode })}
onValue={(tokenLimit) => patch({ tokenLimit })}
/>
<BudgetField
label="Cost (USD)"
hint="e.g. 25"
step="0.01"
mode={form.costMode}
value={form.costLimit}
onMode={(costMode) => patch({ costMode })}
onValue={(costLimit) => patch({ costLimit })}
/>
<div>
<p className="text-foreground text-sm font-medium">Note</p>
<Input
value={form.note}
maxLength={500}
aria-label="Note"
placeholder="Optional, visible to admins only"
onChange={(e) => patch({ note: e.target.value })}
className="mt-1"
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{policy ? (
<Button
type="button"
variant="destructive-outline"
size="sm"
disabled={busy}
onClick={remove}
>
Remove
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={busy || (isEmptyForm(form) && !policy)}
onClick={save}
>
Save
</Button>
</div>
</div>
);
}
+343
View File
@@ -0,0 +1,343 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from '../components/ui/table';
import { selectToken } from '../preferences/preferenceSlice';
import {
LoadError,
Loading,
Pill,
fmtDate,
fmtNumber,
fmtRelative,
} from './AdminUI';
import QuotaEditor from './QuotaEditor';
import { describeBudget, type QuotaPolicy } from './quotaUtils';
type TeamPolicy = QuotaPolicy & {
team_name?: string | null;
team_slug?: string | null;
member_count?: number | null;
};
type Editing = {
scope: QuotaScope;
subjectId: string | null;
title: string;
policy: QuotaPolicy | null;
};
const HINTS: Record<QuotaScope, string> = {
instance:
'Applies to every user that no team allowance or user override covers.',
team: 'Each member gets this allowance; it is not a shared pool. A member of several teams gets the most generous one.',
user: 'Overrides team allowances and the instance default for this user.',
};
function PolicyCells({ policy }: { policy: QuotaPolicy }) {
return (
<>
<TableCell className="tabular-nums">
{describeBudget(policy.token_limit, policy.token_unlimited, 'tokens')}
</TableCell>
<TableCell className="tabular-nums">
{describeBudget(policy.cost_limit_usd, policy.cost_unlimited, 'cost')}
</TableCell>
<TableCell className="text-muted-foreground max-w-56 truncate">
{policy.note || '—'}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{fmtRelative(policy.updated_at)}
</TableCell>
</>
);
}
export default function Quotas() {
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(null);
const [teams, setTeams] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Editing | null>(null);
const [teamPick, setTeamPick] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const [quotasRes, teamsJson] = await Promise.all([
adminService.getQuotas(token),
teamsService.listAll(token).catch(() => ({})),
]);
setData(await quotasRes.json().catch(() => ({ success: false })));
setTeams(teamsJson?.teams ?? []);
} catch {
setData({ success: false });
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
// The editor covers the ``all`` bucket; other buckets are listed read-only.
const isAll = (p: QuotaPolicy) => p.bucket === 'all';
const instancePolicy: QuotaPolicy | null =
(data?.instance ?? []).find(isAll) ?? null;
const teamPolicies: TeamPolicy[] = data?.teams ?? [];
const userPolicies: QuotaPolicy[] = data?.users ?? [];
const teamsWithoutPolicy = useMemo(() => {
const covered = new Set(
teamPolicies.filter(isAll).map((p) => String(p.subject_id)),
);
return teams.filter((team) => !covered.has(String(team.id)));
}, [teams, teamPolicies]);
if (data === null && loading) return <Loading />;
if (!data?.success) return <LoadError message="Failed to load quotas." />;
const bucketPill = (policy: QuotaPolicy) =>
isAll(policy) ? null : <Pill tone="muted">{policy.bucket} traffic</Pill>;
return (
<div className="mt-6 space-y-8">
<p className="text-muted-foreground text-sm">
Usage is counted per user over each calendar {data.period} (UTC). The
current window resets {fmtDate(data.resets_at)}. A request is refused
once a budget is used up; the request that crosses it still completes.
</p>
{(data.unpriced_models ?? []).length > 0 ? (
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-foreground font-medium">
Models without a price are invisible to cost limits
</p>
<p className="text-muted-foreground mt-1">
These were used this {data.period} and recorded at $0:{' '}
{(data.unpriced_models as any[])
.map((m) => `${m.model_id} (${fmtNumber(m.tokens)} tokens)`)
.join(', ')}
. Use a token limit for them, or declare their rates in the model
catalog.
</p>
</div>
) : null}
<section>
<div className="flex items-center justify-between gap-3">
<p className="text-foreground font-bold">Instance default</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'instance',
subjectId: null,
title: 'Instance default',
policy: instancePolicy,
})
}
>
{instancePolicy ? 'Edit' : 'Set default'}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{instancePolicy
? `Tokens: ${describeBudget(instancePolicy.token_limit, instancePolicy.token_unlimited, 'tokens')} · Cost: ${describeBudget(instancePolicy.cost_limit_usd, instancePolicy.cost_unlimited, 'cost')}`
: 'No default: users without a team allowance or override are unlimited.'}
</p>
</section>
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-foreground font-bold">Team allowances</p>
{teamsWithoutPolicy.length > 0 ? (
<div className="flex items-center gap-2">
<Select value={teamPick} onValueChange={setTeamPick}>
<SelectTrigger className="w-52" aria-label="Team">
<SelectValue placeholder="Choose a team" />
</SelectTrigger>
<SelectContent>
{teamsWithoutPolicy.map((team) => (
<SelectItem key={team.id} value={String(team.id)}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!teamPick}
onClick={() => {
const team = teams.find((tm) => String(tm.id) === teamPick);
setEditing({
scope: 'team',
subjectId: teamPick,
title: team?.name ?? 'Team',
policy: null,
});
}}
>
Add allowance
</Button>
</div>
) : null}
</div>
{teamPolicies.length === 0 ? (
<p className="text-muted-foreground mt-1 text-sm">
No team has an allowance.
</p>
) : (
<TableContainer className="mt-3">
<Table>
<TableHead>
<TableRow>
<TableHeader>Team</TableHeader>
<TableHeader>Members</TableHeader>
<TableHeader>Tokens</TableHeader>
<TableHeader>Cost</TableHeader>
<TableHeader>Note</TableHeader>
<TableHeader>Updated</TableHeader>
<TableHeader className="text-right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{teamPolicies.map((policy) => (
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
<TableCell>
<span className="mr-2">
{policy.team_name ?? policy.subject_id}
</span>
{bucketPill(policy)}
</TableCell>
<TableCell className="tabular-nums">
{fmtNumber(policy.member_count)}
</TableCell>
<PolicyCells policy={policy} />
<TableCell className="text-right">
{isAll(policy) ? (
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'team',
subjectId: policy.subject_id,
title: policy.team_name ?? 'Team',
policy,
})
}
>
Edit
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</section>
<section>
<p className="text-foreground font-bold">User overrides</p>
{userPolicies.length === 0 ? (
<p className="text-muted-foreground mt-1 text-sm">
No user has an override. Add one from a user&apos;s menu on the
Users tab.
</p>
) : (
<TableContainer className="mt-3">
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader>Tokens</TableHeader>
<TableHeader>Cost</TableHeader>
<TableHeader>Note</TableHeader>
<TableHeader>Updated</TableHeader>
<TableHeader className="text-right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{userPolicies.map((policy) => (
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
<TableCell>
<span className="mr-2 break-all">
{policy.subject_id}
</span>
{bucketPill(policy)}
</TableCell>
<PolicyCells policy={policy} />
<TableCell className="text-right">
{isAll(policy) ? (
<Button
variant="outline"
size="sm"
onClick={() =>
setEditing({
scope: 'user',
subjectId: policy.subject_id,
title: policy.subject_id ?? 'User',
policy,
})
}
>
Edit
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</section>
<Modal
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
title={editing ? `Quota · ${editing.title}` : 'Quota'}
>
{editing ? (
<QuotaEditor
scope={editing.scope}
subjectId={editing.subjectId}
policy={editing.policy}
inheritHint={HINTS[editing.scope]}
onSaved={() => {
setEditing(null);
setTeamPick('');
load();
}}
/>
) : null}
</Modal>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService from '../api/services/adminService';
import teamsService from '../api/services/teamsService';
import { Modal } from '../components/ui/modal';
import { selectToken } from '../preferences/preferenceSlice';
import { LoadError, Loading, fmtDate } from './AdminUI';
import QuotaEditor, { UsageBar } from './QuotaEditor';
import {
sourceLabel,
type BucketStatus,
type Budget,
type QuotaPolicy,
} from './quotaUtils';
/** A user's effective limits and usage, with the editor for their override. */
export default function UserQuotaModal({
userId,
onClose,
}: {
userId: string | null;
onClose: () => void;
}) {
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(null);
const [teamNames, setTeamNames] = useState<Record<string, string>>({});
const load = useCallback(async () => {
if (!userId) return;
setData(null);
try {
const [res, teamsJson] = await Promise.all([
adminService.getUserQuota(userId, token),
teamsService.listAll(token).catch(() => ({})),
]);
setData(await res.json().catch(() => ({ success: false })));
setTeamNames(
Object.fromEntries(
(teamsJson?.teams ?? []).map((team: any) => [
String(team.id),
team.name,
]),
),
);
} catch {
setData({ success: false });
}
}, [userId, token]);
useEffect(() => {
load();
}, [load]);
const overall: BucketStatus | undefined = (data?.effective ?? []).find(
(status: BucketStatus) => status.bucket === 'all',
);
const override: QuotaPolicy | null =
(data?.policies ?? []).find((p: QuotaPolicy) => p.bucket === 'all') ?? null;
const caption = (budget: Budget) =>
sourceLabel(
budget,
budget.source_id ? teamNames[budget.source_id] : undefined,
);
return (
<Modal
open={userId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
title={userId ? `Quota · ${userId}` : 'Quota'}
>
{data === null ? (
<Loading />
) : !data.success ? (
<LoadError message="Failed to load this user's quota." />
) : (
<div className="space-y-5">
{overall ? (
<div className="space-y-3">
<UsageBar
label={`Tokens this ${data.period}`}
kind="tokens"
budget={overall.tokens}
caption={caption(overall.tokens)}
/>
<UsageBar
label={`Cost this ${data.period}`}
kind="cost"
budget={overall.cost}
caption={caption(overall.cost)}
/>
<p className="text-muted-foreground text-xs">
Resets {fmtDate(overall.resets_at)}
</p>
</div>
) : null}
<div className="border-border border-t pt-4">
<p className="text-foreground mb-2 text-sm font-bold">
User override
</p>
<QuotaEditor
scope="user"
subjectId={userId}
policy={override}
inheritHint="Overrides team allowances and the instance default for this user. Leave a budget as “Not set here” to keep what the user inherits."
onSaved={load}
/>
</div>
</div>
)}
</Modal>
);
}
+16 -1
View File
@@ -1,5 +1,6 @@
import {
Eye,
Gauge,
LogOut,
ShieldCheck,
ShieldOff,
@@ -40,6 +41,7 @@ import {
fmtNumber,
fmtRelative,
} from './AdminUI';
import UserQuotaModal from './UserQuotaModal';
type AdminUser = {
user_id: string;
@@ -70,6 +72,7 @@ export default function Users() {
const [busy, setBusy] = useState<string | null>(null);
const [menuUserId, setMenuUserId] = useState<string | null>(null);
const [detail, setDetail] = useState<any | null>(null);
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{
ok: boolean;
message: string;
@@ -157,7 +160,14 @@ export default function Users() {
isAdmin: boolean,
active: boolean,
): Action[] => {
const acts: Action[] = [];
const acts: Action[] = [
{
key: 'quota',
label: 'Quota',
icon: Gauge,
perform: () => setQuotaUserId(userId),
},
];
if (isAdmin) {
acts.push({
key: 'revoke',
@@ -433,6 +443,11 @@ export default function Users() {
/>
) : null}
<UserQuotaModal
userId={quotaUserId}
onClose={() => setQuotaUserId(null)}
/>
<Modal
open={detail !== null}
onOpenChange={(open) => {
+3
View File
@@ -11,6 +11,7 @@ import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
import Admins from './Admins';
import Audit from './Audit';
import Overview from './Overview';
import Quotas from './Quotas';
import Usage from './Usage';
import Users from './Users';
@@ -19,6 +20,7 @@ const TABS = [
{ key: 'users', label: 'Users', path: '/admin/users' },
{ key: 'admins', label: 'Admins', path: '/admin/roles' },
{ key: 'usage', label: 'Usage', path: '/admin/usage' },
{ key: 'quotas', label: 'Quotas', path: '/admin/quotas' },
{ key: 'audit', label: 'Audit', path: '/admin/audit' },
];
@@ -63,6 +65,7 @@ export default function Admin() {
<Route path="users" element={<Users />} />
<Route path="roles" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="quotas" element={<Quotas />} />
<Route path="audit" element={<Audit />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import {
describeBudget,
formToPolicy,
isEmptyForm,
policyToForm,
sourceLabel,
usagePercent,
type QuotaPolicy,
} from './quotaUtils';
const policy = (fields: Partial<QuotaPolicy>): QuotaPolicy => ({
scope: 'user',
subject_id: 'u1',
bucket: 'all',
token_limit: null,
token_unlimited: false,
cost_limit_usd: null,
cost_unlimited: false,
enabled: true,
...fields,
});
describe('policyToForm', () => {
it('starts a missing policy as inherit', () => {
const form = policyToForm(null);
expect(form.tokenMode).toBe('inherit');
expect(form.costMode).toBe('inherit');
expect(isEmptyForm(form)).toBe(true);
});
it('keeps zero as a limit, not as inherit', () => {
const form = policyToForm(policy({ token_limit: 0, cost_unlimited: true }));
expect(form.tokenMode).toBe('limit');
expect(form.tokenLimit).toBe('0');
expect(form.costMode).toBe('unlimited');
});
});
describe('formToPolicy', () => {
const base = policyToForm(null);
it('round-trips limits and trims the note', () => {
const result = formToPolicy({
...base,
tokenMode: 'limit',
tokenLimit: ' 5000 ',
costMode: 'limit',
costLimit: '2.5',
note: ' trial ',
});
expect(result).toEqual({
ok: true,
policy: {
bucket: 'all',
token_limit: 5000,
token_unlimited: false,
cost_limit_usd: 2.5,
cost_unlimited: false,
note: 'trial',
},
});
});
it('sends unlimited without a limit', () => {
const result = formToPolicy({
...base,
tokenMode: 'unlimited',
tokenLimit: '99',
});
expect(result.ok && result.policy.token_limit).toBeNull();
expect(result.ok && result.policy.token_unlimited).toBe(true);
});
it.each(['', '1.5', '-1', 'abc', '1e3'])(
'rejects token limit %j',
(tokenLimit) => {
expect(formToPolicy({ ...base, tokenMode: 'limit', tokenLimit }).ok).toBe(
false,
);
},
);
it.each(['', '-0.01', 'abc', 'Infinity'])(
'rejects cost limit %j',
(costLimit) => {
expect(formToPolicy({ ...base, costMode: 'limit', costLimit }).ok).toBe(
false,
);
},
);
});
describe('usagePercent', () => {
it('handles unlimited, zero and overshoot', () => {
expect(usagePercent(50, null)).toBe(0);
expect(usagePercent(0, 0)).toBe(100);
expect(usagePercent(25, 100)).toBe(25);
expect(usagePercent(500, 100)).toBe(100);
});
});
describe('labels', () => {
it('describes budgets', () => {
expect(describeBudget(null, true, 'tokens')).toBe('Unlimited');
expect(describeBudget(null, false, 'cost')).toBe('—');
expect(describeBudget(1000, false, 'tokens')).toContain('tokens');
});
it('names the layer a limit came from', () => {
expect(sourceLabel({ limit: null, used: 0 })).toBe('No limit set');
expect(sourceLabel({ limit: 1, used: 0, source: 'team' }, 'Eng')).toBe(
'Team: Eng',
);
expect(sourceLabel({ limit: 1, used: 0, source: 'default' })).toBe(
'Plan default',
);
});
});
+130
View File
@@ -0,0 +1,130 @@
// Pure helpers behind the quota editor and usage bars.
export type BudgetMode = 'inherit' | 'limit' | 'unlimited';
export type QuotaPolicy = {
scope: 'instance' | 'team' | 'user';
subject_id: string | null;
bucket: string;
token_limit: number | null;
token_unlimited: boolean;
cost_limit_usd: number | null;
cost_unlimited: boolean;
enabled: boolean;
note?: string | null;
updated_by?: string | null;
updated_at?: string | null;
};
export type Budget = {
limit: number | null;
used: number;
source?: string | null;
source_id?: string | null;
};
export type BucketStatus = {
bucket: string;
tokens: Budget;
cost: Budget;
resets_at: string;
};
export type QuotaForm = {
tokenMode: BudgetMode;
tokenLimit: string;
costMode: BudgetMode;
costLimit: string;
note: string;
};
const mode = (limit: number | null, unlimited: boolean): BudgetMode => {
if (unlimited) return 'unlimited';
return limit === null || limit === undefined ? 'inherit' : 'limit';
};
export function policyToForm(policy?: QuotaPolicy | null): QuotaForm {
return {
tokenMode: policy
? mode(policy.token_limit, policy.token_unlimited)
: 'inherit',
tokenLimit: policy?.token_limit != null ? String(policy.token_limit) : '',
costMode: policy
? mode(policy.cost_limit_usd, policy.cost_unlimited)
: 'inherit',
costLimit:
policy?.cost_limit_usd != null ? String(policy.cost_limit_usd) : '',
note: policy?.note ?? '',
};
}
export type FormResult =
{ ok: true; policy: Record<string, unknown> } | { ok: false; error: string };
// An empty form (both budgets inherited) is not a policy: the caller deletes instead.
export function isEmptyForm(form: QuotaForm): boolean {
return form.tokenMode === 'inherit' && form.costMode === 'inherit';
}
export function formToPolicy(form: QuotaForm): FormResult {
const policy: Record<string, unknown> = {
bucket: 'all',
token_limit: null,
token_unlimited: form.tokenMode === 'unlimited',
cost_limit_usd: null,
cost_unlimited: form.costMode === 'unlimited',
note: form.note.trim() || null,
};
if (form.tokenMode === 'limit') {
const raw = form.tokenLimit.trim();
if (!/^\d+$/.test(raw))
return { ok: false, error: 'Token limit must be a whole number.' };
const tokens = Number(raw);
if (!Number.isSafeInteger(tokens))
return { ok: false, error: 'Token limit is too large.' };
policy.token_limit = tokens;
}
if (form.costMode === 'limit') {
const raw = form.costLimit.trim();
const cost = Number(raw);
if (raw === '' || !Number.isFinite(cost) || cost < 0)
return { ok: false, error: 'Cost limit must be a number, 0 or more.' };
policy.cost_limit_usd = cost;
}
return { ok: true, policy };
}
export function usagePercent(used: number, limit: number | null): number {
if (limit === null || limit === undefined) return 0;
if (limit <= 0) return 100;
return Math.min(100, Math.max(0, (used / limit) * 100));
}
export function fmtUsd(value?: number | null): string {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value != null && value < 1 ? 4 : 2,
}).format(value ?? 0);
}
export function describeBudget(
limit: number | null,
unlimited: boolean,
kind: 'tokens' | 'cost',
): string {
if (unlimited) return 'Unlimited';
if (limit === null || limit === undefined) return '—';
return kind === 'cost'
? fmtUsd(limit)
: `${new Intl.NumberFormat().format(limit)} tokens`;
}
export function sourceLabel(budget: Budget, teamName?: string): string {
if (!budget.source) return 'No limit set';
if (budget.source === 'user') return 'User override';
if (budget.source === 'team')
return teamName ? `Team: ${teamName}` : 'Team allowance';
if (budget.source === 'instance') return 'Instance default';
return 'Plan default';
}
+7
View File
@@ -2,6 +2,7 @@ const endpoints = {
USER: {
CONFIG: '/api/config',
ME: '/api/user/me',
QUOTA: '/api/user/quota',
NEW_TOKEN: '/api/generate_token',
OIDC_LOGIN: '/api/auth/oidc/login',
OIDC_TOKEN: '/api/auth/oidc/token',
@@ -173,6 +174,12 @@ const endpoints = {
USAGE: '/api/admin/usage',
AUDIT: '/api/admin/audit',
DEVICE_AUDIT: '/api/admin/devices/audit',
QUOTAS: '/api/admin/quotas',
QUOTA_INSTANCE: '/api/admin/quotas/instance',
QUOTA_TEAM: (id: string) =>
`/api/admin/quotas/teams/${encodeURIComponent(id)}`,
QUOTA_USER: (id: string) =>
`/api/admin/quotas/users/${encodeURIComponent(id)}`,
},
CONVERSATION: {
ANSWER: '/api/answer',
+25
View File
@@ -10,6 +10,14 @@ const qs = (params: Record<string, string | number | undefined>): string => {
return str ? `?${str}` : '';
};
export type QuotaScope = 'instance' | 'team' | 'user';
const quotaUrl = (scope: QuotaScope, subjectId?: string | null): string => {
if (scope === 'team') return endpoints.ADMIN.QUOTA_TEAM(subjectId ?? '');
if (scope === 'user') return endpoints.ADMIN.QUOTA_USER(subjectId ?? '');
return endpoints.ADMIN.QUOTA_INSTANCE;
};
const adminService = {
getOverview: (token: string | null): Promise<any> =>
apiClient.get(endpoints.ADMIN.OVERVIEW, token),
@@ -54,6 +62,23 @@ const adminService = {
token: string | null,
): Promise<any> =>
apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token),
getQuotas: (token: string | null): Promise<any> =>
apiClient.get(endpoints.ADMIN.QUOTAS, token),
getUserQuota: (userId: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.ADMIN.QUOTA_USER(userId), token),
setQuota: (
scope: QuotaScope,
subjectId: string | null,
policy: Record<string, unknown>,
token: string | null,
): Promise<any> => apiClient.put(quotaUrl(scope, subjectId), policy, token),
deleteQuota: (
scope: QuotaScope,
subjectId: string | null,
bucket: string,
token: string | null,
): Promise<any> =>
apiClient.delete(`${quotaUrl(scope, subjectId)}${qs({ bucket })}`, token),
};
export default adminService;
+2
View File
@@ -7,6 +7,8 @@ const userService = {
throttledApiClient.get(endpoints.USER.CONFIG, null),
getMe: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.ME, token),
getQuota: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.QUOTA, token),
getNewToken: (): Promise<any> =>
throttledApiClient.get(endpoints.USER.NEW_TOKEN, null),
// Token deliberately null: a stale Authorization header must not be able
@@ -1,7 +1,10 @@
import i18n from 'i18next';
import { baseURL } from '../api/client';
import conversationService from '../api/services/conversationService';
import { Doc } from '../models/misc';
import { Answer, FEEDBACK, RetrievalPayload } from './conversationModels';
import { isQuotaError, quotaErrorMessage } from './quotaError';
import { ToolCallsType } from './types';
/**
@@ -48,7 +51,9 @@ async function _handlePreStreamHttpError(
if (text) {
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
if (isQuotaError(parsed)) {
message = quotaErrorMessage(parsed, i18n.t.bind(i18n), i18n.language);
} else if (parsed && typeof parsed === 'object') {
message =
(typeof parsed.message === 'string' && parsed.message) ||
(typeof parsed.error === 'string' && parsed.error) ||
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { isQuotaError, quotaErrorMessage } from './quotaError';
const t = ((key: string, values: Record<string, string>) =>
`${key}|${values.used}|${values.limit}|${values.resetsAt}`) as any;
describe('isQuotaError', () => {
it('matches only the quota error code', () => {
expect(isQuotaError({ error_code: 'quota-exceeded' })).toBe(true);
expect(isQuotaError({ message: 'Exceeding usage limit' })).toBe(false);
expect(isQuotaError(null)).toBe(false);
expect(isQuotaError('quota-exceeded')).toBe(false);
});
});
describe('quotaErrorMessage', () => {
it('formats token budgets as numbers', () => {
const message = quotaErrorMessage(
{
dimension: 'tokens',
usage: 1200000,
limit: 1000000,
resets_at: '2026-10-01T00:00:00+00:00',
},
t,
'en-US',
);
const [key, used, limit, resetsAt] = message.split('|');
expect(key).toBe('conversation.quotaExceeded.tokens');
expect([used, limit]).toEqual(['1,200,000', '1,000,000']);
expect(resetsAt).not.toBe('');
});
it('formats cost budgets as dollars', () => {
const message = quotaErrorMessage(
{ dimension: 'cost', usage: 5.25, limit: 5 },
t,
'en-US',
);
expect(message).toBe('conversation.quotaExceeded.cost|$5.25|$5.00|');
});
it('tolerates a malformed reset time', () => {
const message = quotaErrorMessage(
{ dimension: 'tokens', usage: 1, limit: 1, resets_at: 'soon' },
t,
'en-US',
);
expect(message.endsWith('|')).toBe(true);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { TFunction } from 'i18next';
export type QuotaErrorBody = {
error_code?: string;
dimension?: string;
usage?: number;
limit?: number;
resets_at?: string;
};
export function isQuotaError(body: unknown): body is QuotaErrorBody {
return (
!!body &&
typeof body === 'object' &&
(body as QuotaErrorBody).error_code === 'quota-exceeded'
);
}
/** The chat message for a 429 ``quota-exceeded`` body, in the user's language. */
export function quotaErrorMessage(
body: QuotaErrorBody,
t: TFunction,
locale?: string,
): string {
const isCost = body.dimension === 'cost';
const amount = (value?: number) =>
isCost
? new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD',
}).format(value ?? 0)
: new Intl.NumberFormat(locale).format(value ?? 0);
const reset = body.resets_at ? new Date(body.resets_at) : null;
const resetsAt =
reset && !Number.isNaN(reset.getTime())
? new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(reset)
: '';
return t(
isCost
? 'conversation.quotaExceeded.cost'
: 'conversation.quotaExceeded.tokens',
{ used: amount(body.usage), limit: amount(body.limit), resetsAt },
);
}
+11
View File
@@ -375,6 +375,13 @@
"toolCalls": "Werkzeugaufrufe",
"runSuccess": "Erfolgsquote",
"feedback": "Feedback"
},
"quota": {
"title": "Ihr Nutzungskontingent",
"resets": "Wird zurückgesetzt: {{resetsAt}}",
"tokens": "Tokens",
"cost": "Kosten",
"usedOf": "{{used}} von {{limit}}"
}
},
"logs": {
@@ -1262,6 +1269,10 @@
"running": "Läuft…",
"denied": "Vom Benutzer abgelehnt",
"failed": "fehlgeschlagen"
},
"quotaExceeded": {
"tokens": "Sie haben {{used}} von Ihrem Kontingent von {{limit}} Tokens verbraucht. Es wird am {{resetsAt}} zurückgesetzt.",
"cost": "Sie haben {{used}} von Ihrem Nutzungsbudget von {{limit}} verbraucht. Es wird am {{resetsAt}} zurückgesetzt."
}
},
"agents": {
+11
View File
@@ -380,6 +380,13 @@
"toolCalls": "Tool Calls",
"runSuccess": "Run Success",
"feedback": "Feedback"
},
"quota": {
"title": "Your usage quota",
"resets": "Resets {{resetsAt}}",
"tokens": "Tokens",
"cost": "Cost",
"usedOf": "{{used}} of {{limit}}"
}
},
"logs": {
@@ -1273,6 +1280,10 @@
"running": "Running…",
"denied": "Denied by user",
"failed": "failed"
},
"quotaExceeded": {
"tokens": "You've used {{used}} of your {{limit}} token quota. It resets {{resetsAt}}.",
"cost": "You've used {{used}} of your {{limit}} usage budget. It resets {{resetsAt}}."
}
},
"agents": {
+11
View File
@@ -375,6 +375,13 @@
"toolCalls": "Llamadas a Herramientas",
"runSuccess": "Éxito de Ejecución",
"feedback": "Retroalimentación"
},
"quota": {
"title": "Tu cuota de uso",
"resets": "Se restablece el {{resetsAt}}",
"tokens": "Tokens",
"cost": "Coste",
"usedOf": "{{used}} de {{limit}}"
}
},
"logs": {
@@ -1262,6 +1269,10 @@
"running": "Ejecutando…",
"denied": "Denegado por el usuario",
"failed": "falló"
},
"quotaExceeded": {
"tokens": "Has usado {{used}} de tu cuota de {{limit}} tokens. Se restablece el {{resetsAt}}.",
"cost": "Has usado {{used}} de tu presupuesto de uso de {{limit}}. Se restablece el {{resetsAt}}."
}
},
"agents": {
+11
View File
@@ -375,6 +375,13 @@
"toolCalls": "ツール呼び出し",
"runSuccess": "実行成功率",
"feedback": "フィードバック"
},
"quota": {
"title": "利用クォータ",
"resets": "{{resetsAt}} にリセット",
"tokens": "トークン",
"cost": "コスト",
"usedOf": "{{used}} / {{limit}}"
}
},
"logs": {
@@ -1262,6 +1269,10 @@
"running": "実行中…",
"denied": "ユーザーによって拒否されました",
"failed": "失敗"
},
"quotaExceeded": {
"tokens": "トークンクォータ {{limit}} のうち {{used}} を使用しました。{{resetsAt}} にリセットされます。",
"cost": "利用予算 {{limit}} のうち {{used}} を使用しました。{{resetsAt}} にリセットされます。"
}
},
"agents": {
+11
View File
@@ -375,6 +375,13 @@
"toolCalls": "Вызовы инструментов",
"runSuccess": "Успешность запусков",
"feedback": "Обратная связь"
},
"quota": {
"title": "Ваша квота использования",
"resets": "Сброс: {{resetsAt}}",
"tokens": "Токены",
"cost": "Стоимость",
"usedOf": "{{used}} из {{limit}}"
}
},
"logs": {
@@ -1282,6 +1289,10 @@
"running": "Выполняется…",
"denied": "Отклонено пользователем",
"failed": "не удалось"
},
"quotaExceeded": {
"tokens": "Вы использовали {{used}} из квоты в {{limit}} токенов. Квота сбросится {{resetsAt}}.",
"cost": "Вы использовали {{used}} из бюджета в {{limit}}. Бюджет сбросится {{resetsAt}}."
}
},
"agents": {
+11
View File
@@ -375,6 +375,13 @@
"toolCalls": "工具呼叫",
"runSuccess": "執行成功率",
"feedback": "回饋"
},
"quota": {
"title": "您的用量配額",
"resets": "{{resetsAt}} 重設",
"tokens": "權杖",
"cost": "費用",
"usedOf": "{{used}} / {{limit}}"
}
},
"logs": {
@@ -1262,6 +1269,10 @@
"running": "執行中…",
"denied": "已被使用者拒絕",
"failed": "失敗"
},
"quotaExceeded": {
"tokens": "您已使用 {{limit}} 權杖配額中的 {{used}}。配額將於 {{resetsAt}} 重設。",
"cost": "您已使用 {{limit}} 用量預算中的 {{used}}。預算將於 {{resetsAt}} 重設。"
}
},
"agents": {
+11
View File
@@ -375,6 +375,13 @@
"toolCalls": "工具调用",
"runSuccess": "运行成功率",
"feedback": "反馈"
},
"quota": {
"title": "您的用量配额",
"resets": "{{resetsAt}} 重置",
"tokens": "令牌",
"cost": "费用",
"usedOf": "{{used}} / {{limit}}"
}
},
"logs": {
@@ -1262,6 +1269,10 @@
"running": "正在运行…",
"denied": "已被用户拒绝",
"failed": "失败"
},
"quotaExceeded": {
"tokens": "您已使用 {{limit}} 令牌配额中的 {{used}}。配额将于 {{resetsAt}} 重置。",
"cost": "您已使用 {{limit}} 用量预算中的 {{used}}。预算将于 {{resetsAt}} 重置。"
}
},
"agents": {
+3 -1
View File
@@ -26,6 +26,7 @@ import { useDarkTheme, useLoaderState } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { htmlLegendPlugin } from '../utils/chartUtils';
import { formatDate } from '../utils/dateTimeUtils';
import UsageQuota from './components/UsageQuota';
/**
* Resolve a CSS custom property on `:root` to a concrete color string.
@@ -377,6 +378,7 @@ export default function Analytics({ agentId }: AnalyticsProps) {
return (
<div className="mt-8">
{agentId ? null : <UsageQuota />}
<div className="mb-5 flex flex-row flex-wrap items-center justify-between gap-3">
<p className="text-muted-foreground text-sm leading-6">
{t('settings.analytics.subtitle')}
@@ -412,7 +414,7 @@ export default function Analytics({ agentId }: AnalyticsProps) {
<div
key={card.label}
title={card.hint}
className={`border-border dark:border-border rounded-2xl border px-6 py-5${card.hint ? ' cursor-help' : ''}`}
className={`border-border dark:border-border rounded-2xl border px-6 py-5${card.hint ? 'cursor-help' : ''}`}
>
<p className="text-muted-foreground text-sm">{card.label}</p>
<p className="text-foreground dark:text-foreground mt-1 text-2xl font-bold">
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { selectToken } from '../../preferences/preferenceSlice';
type Budget = { limit: number | null; used: number };
type Bucket = {
bucket: string;
tokens: Budget;
cost: Budget;
resets_at: string;
};
function Meter({
label,
budget,
format,
}: {
label: string;
budget: Budget;
format: (value: number) => string;
}) {
const { t } = useTranslation();
if (budget.limit === null) return null;
const percent =
budget.limit <= 0
? 100
: Math.min(100, Math.max(0, (budget.used / budget.limit) * 100));
const tone =
percent >= 100
? 'bg-red-500'
: percent >= 80
? 'bg-amber-500'
: 'bg-[#7D54D1]';
return (
<div className="min-w-48 flex-1">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="text-foreground tabular-nums">
{t('settings.analytics.quota.usedOf', {
used: format(budget.used),
limit: format(budget.limit),
})}
</span>
</div>
<div
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div
className={`h-full rounded-full ${tone}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
/** The caller's usage against the quota an admin set; renders nothing when unlimited. */
export default function UsageQuota() {
const { t, i18n } = useTranslation();
const token = useSelector(selectToken);
const [bucket, setBucket] = useState<Bucket | null>(null);
useEffect(() => {
let cancelled = false;
userService
.getQuota(token)
.then((res: Response) => (res.ok ? res.json() : null))
.then((json: { buckets?: Bucket[] } | null) => {
if (cancelled) return;
const buckets = json?.buckets ?? [];
setBucket(
buckets.find((b) => b.bucket === 'all') ?? buckets[0] ?? null,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [token]);
if (!bucket) return null;
const number = new Intl.NumberFormat(i18n.language);
const usd = new Intl.NumberFormat(i18n.language, {
style: 'currency',
currency: 'USD',
});
const reset = new Date(bucket.resets_at);
const resetsAt = Number.isNaN(reset.getTime())
? ''
: new Intl.DateTimeFormat(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(reset);
return (
<div className="border-border mb-6 rounded-2xl border px-6 py-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-foreground font-bold">
{t('settings.analytics.quota.title')}
</p>
{resetsAt ? (
<p className="text-muted-foreground text-xs">
{t('settings.analytics.quota.resets', { resetsAt })}
</p>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-6">
<Meter
label={t('settings.analytics.quota.tokens')}
budget={bucket.tokens}
format={(value) => number.format(value)}
/>
<Meter
label={t('settings.analytics.quota.cost')}
budget={bucket.cost}
format={(value) => usd.format(value)}
/>
</div>
</div>
);
}