Files
llmgw/internal/web/templates/admin_quotas.html
T
Ilker ManapandClaude Opus 5 07809490e3 Show usage over time, per organization, team, project and person
Usage could be read as a total — per key, per user, per model, per group —
and the cluster's own charts covered the fleet's hardware. What nobody could
see was when a group used it. "Research doubled its traffic three weeks ago"
is not a question a column of totals can be asked, and it is the question
somebody asks before a quota conversation.

/admin/usage draws requests, tokens and spend over a chosen window, grouped
by any level of the tenancy tree or by person. Person is not a level of that
tree — one person holds keys in several projects — so the grouping is its
own type rather than store.Scope with a fourth value nothing else could use.

Three things learned from the charts already here. Buckets are gap-filled
per group, because a line drawn straight across an absence claims traffic
that did not happen, and because the chart sums its tail by timestamp and
can only do that if every series shares a clock. Series are ranked by total
rather than by peak — a counter's question is "who used the most", and
ranking by peak would colour the team that made one burst over the team that
ran steadily all week. And the spend chart is replaced by a sentence when no
model has a price, rather than drawn flat along the bottom.

The bucket query groups by ordinal and was planned against the cluster's
PostgreSQL before being believed, with a test alongside the others that run
there: three reports have shipped working on SQLite and failing on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:11:31 +02:00

107 lines
5.2 KiB
HTML

{{define "admin_quotas"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quotas · llmgw</title><link rel="stylesheet" href="/_static/base.css">
</head><body><div class="wrap">
<header>
<h1>Quotas</h1>
<p class="sub">What each organization, team and project may use · spend over the last {{.Since}}</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
<a href="/admin/usage">usage</a> <a href="/profile">your keys</a></nav>
</header>
{{if not .BudgetsBind}}
{{/* A budget is spend per period and spend comes from a price table. With no
prices every request costs nothing, so these numbers read as limits and
bind on nothing. Self-hosted models having no per-token price is ordinary;
showing somebody a budget without saying so is not. */}}
<p class="note error-row">No model has a price, so nothing is charged and every
budget below stays at zero however much is used. Rate and token limits are
unaffected — they count requests and tokens, not money. Set prices in the
<code>pricing</code> block of the server configuration to make budgets bind.</p>
{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
{{if .Rows}}
{{/* The tree is the interface. A quota belongs to a branch, so the form to set
one opens on that branch rather than asking somebody to name it again in a
box at the bottom. */}}
<table>
<tr><th>Where</th><th>RPM</th><th>TPM</th><th>Budget</th><th>Spent</th><th>Used</th><th></th></tr>
{{range .Rows}}
<tr{{if .Editing}} class="editing"{{end}}>
<td style="padding-left:{{.Indent}}.6rem">
{{if eq .Indent 0}}<strong>{{.Path}}</strong>{{else}}{{.Path}}{{end}}
<span class="opt">{{.Scope}}</span>
</td>
<td class="num">{{if .RPM}}{{.RPM}}{{else}}—{{end}}</td>
<td class="num">{{if .TPM}}{{.TPM}}{{else}}—{{end}}</td>
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}/{{.Period}}{{end}}{{else}}—{{end}}</td>
<td class="num">{{.Spend}}</td>
<td class="meter">{{if .HasPct}}<span style="width:{{pct .Percent 100}}%"></span><em>{{.Percent}}%</em>{{else}}—{{end}}</td>
<td>
{{if .Editing}}<a href="/admin/quotas">close</a>
{{else}}<a href="/admin/quotas?edit={{.Scope}}:{{.ID}}">{{if .HasAny}}change{{else}}set quota{{end}}</a>{{end}}
</td>
</tr>
{{if .Shadowed}}
{{/* A value that is written here and never reached. Resolution takes the most
restrictive along the chain, so without saying so the page would show a
number as though it applied and the first sign would be a refusal that
disagrees with the screen. */}}
<tr><td colspan="7" class="error-row">written here, but {{.Shadowed}}</td></tr>
{{end}}
{{if .Models}}<tr><td colspan="7" class="url-row">only: <code>{{.Models}}</code></td></tr>{{end}}
{{if .Editing}}
<tr class="editing"><td colspan="7">
<form method="post" action="/admin/quotas">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="id" value="{{.ID}}">
<p class="sub">Quota for <strong>{{.Crumb}}</strong> — shared by everything
beneath it. Whatever is set here replaces what was there; a field left blank
is no limit of that kind, not a limit of zero.</p>
<div class="field-row">
<label>Requests / minute
<input type="text" name="rpm" value="{{.RPM}}" placeholder="none"></label>
<label>Tokens / minute
<input type="text" name="tpm" value="{{.TPM}}" placeholder="none"></label>
<label>Budget
<input type="text" name="budget" value="{{.Budget}}" placeholder="none"></label>
<label>per
<select name="period">
<option value="month"{{if eq .Period "month"}} selected{{end}}>month</option>
<option value="day"{{if eq .Period "day"}} selected{{end}}>day</option>
</select></label>
</div>
<label>Only these models <span class="opt">comma separated; blank allows all</span>
<input type="text" name="models" value="{{.Models}}" placeholder="every model"></label>
<button type="submit">Save</button>
<button type="submit" name="clear" value="1" class="link danger"
{{if not .HasAny}}disabled{{end}}>Remove every limit here</button>
</form>
</td></tr>
{{end}}
{{end}}
</table>
{{else}}
<p class="empty">Nothing to give a quota to yet. Make an organization on the
<a href="/admin/users">accounts page</a> first.</p>
{{end}}
<p class="note">Limits compose downward by taking the most restrictive value, so
a team cannot raise what its organization set and a project cannot raise its
team's. Only an organization's own limit is bounded by nothing. Model lists
intersect rather than add: a project cannot allow a model its team withheld.</p>
<p class="note">Still not here, deliberately: per-key limits. A key is issued by
the person who owns it and bounded by the project it belongs to, and letting an
admin session reach individual keys would put every caller's throughput one
click away — <code>llmgw keys limits --scope key</code> for that.</p>
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
</div></body></html>{{end}}