The quotas page gains a capacity section over the same API methods the CLI uses; a model's card sets its class, owner, memory and cores, applying only what changed. Headroom counts the replicas a model could get by preempting what its class outranks, and the card and ctl model target use it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
250 lines
13 KiB
HTML
250 lines
13 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="{{stylesheet}}">
|
|
{{/* Links and forms work as plain navigation; htmx, when it loads, swaps only
|
|
#quotas so opening a form or saving one does not jump the page. */}}
|
|
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false}'>
|
|
<script src="/_static/htmx-2.0.11.min.js" defer></script>
|
|
</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>
|
|
{{template "nav" .Nav}}
|
|
</header>
|
|
|
|
<div id="quotas" hx-boost="true" hx-target="#quotas" hx-select="#quotas"
|
|
hx-swap="outerHTML" hx-push-url="false">
|
|
|
|
{{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="banner-warn" role="note">No model has a price, so nothing is charged and
|
|
budgets stay at zero however much is used. Rate and token limits still apply. Set
|
|
prices in the <code>pricing</code> block of the server configuration to make
|
|
budgets bind.</p>
|
|
{{end}}
|
|
|
|
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
|
{{if .Message}}<p class="ok" role="status">{{.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 class="rows quota-tree">
|
|
<tr><th>Where</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
|
<th class="num">Budget</th><th>Spent</th><th></th></tr>
|
|
{{range .Rows}}
|
|
<tr class="q-depth-{{.Indent}}{{if .Editing}} editing{{end}}">
|
|
<td class="q-where">
|
|
<span class="q-name">{{.Path}}</span> <span class="q-kind">{{.Scope}}</span>
|
|
{{if .Models}}<div class="q-sub">only <code>{{.Models}}</code></div>
|
|
{{else if .ModelsFrom}}<div class="q-sub">models limited by {{.ModelsFrom}}</div>{{end}}
|
|
{{if .Member}}<div class="q-sub">each member here: {{.Member}}</div>{{end}}
|
|
{{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. */}}
|
|
<div class="q-sub q-warn">{{.Shadowed}}</div>{{end}}
|
|
</td>
|
|
<td class="num">{{template "quota-value" dict "Own" .RPM "Up" .RPMUp "From" .RPMFrom}}</td>
|
|
<td class="num">{{template "quota-value" dict "Own" .TPM "Up" .TPMUp "From" .TPMFrom}}</td>
|
|
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}
|
|
{{else}}{{template "quota-value" dict "Own" "" "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</td>
|
|
<td class="q-spent">
|
|
<span class="q-spend">{{.Spend}}</span>
|
|
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}" title="{{.Percent}}% of its budget"><i style="width:{{pct .Percent 100}}%"></i></span>
|
|
<span class="q-pct">{{.Percent}}%</span>{{end}}
|
|
</td>
|
|
<td class="q-act">
|
|
{{if .Editing}}<a href="/admin/quotas">close</a>
|
|
{{else}}<a href="/admin/quotas?edit={{.Scope}}:{{.ID}}">{{if .HasAny}}change{{else}}set{{end}}</a>{{end}}
|
|
{{if .MemberEditing}}<a href="/admin/quotas">close</a>
|
|
{{else}}<a href="/admin/quotas?edit={{.Scope}}-member:{{.ID}}" title="A limit each member here has separately">per member</a>{{end}}
|
|
</td>
|
|
</tr>
|
|
{{if .MemberEditing}}
|
|
<tr class="editing"><td colspan="6">
|
|
{{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $.CSRF
|
|
"Title" (printf "Each member of %s, separately" .Crumb)
|
|
"Note" "Every member gets this much here, counted apart from the others — a fair share rather than one pool.")}}
|
|
</td></tr>
|
|
{{end}}
|
|
|
|
{{if .Editing}}
|
|
<tr class="editing"><td colspan="6">
|
|
<form method="post" action="/admin/quotas" class="q-form">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="scope" value="{{.Scope}}">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<p class="q-form-title">Quota for <strong>{{.Crumb}}</strong>, shared by everything beneath it.
|
|
A blank field means 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="{{if .RPMUp}}{{.RPMUp}} from {{.RPMFrom}}{{else}}none{{end}}"></label>
|
|
<label>Tokens / minute
|
|
<input type="text" name="tpm" value="{{.TPM}}" placeholder="{{if .TPMUp}}{{.TPMUp}} from {{.TPMFrom}}{{else}}none{{end}}"></label>
|
|
<label>Budget
|
|
<input type="text" name="budget" value="{{.Budget}}" placeholder="{{if .BudgetUp}}{{.BudgetUp}} from {{.BudgetFrom}}{{else}}none{{end}}"></label>
|
|
<label class="q-period">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>
|
|
<div class="q-form-actions">
|
|
<button type="submit">Save</button>
|
|
<a href="/admin/quotas">Cancel</a>
|
|
<button type="submit" name="clear" value="1" class="link danger"
|
|
{{if not .HasAny}}disabled{{end}}>Remove every limit here</button>
|
|
</div>
|
|
</form>
|
|
</td></tr>
|
|
{{end}}
|
|
{{end}}
|
|
</table>
|
|
<p class="q-legend"><span class="q-up">60 ↑</span> is held by a limit set higher
|
|
up — hover it to see where. A value set here is in normal type.</p>
|
|
{{else}}
|
|
<p class="empty">Nothing to give a quota to yet. Make an organization on the
|
|
<a href="/admin/users">people page</a> first.</p>
|
|
{{end}}
|
|
|
|
<h2 id="people-limits">People</h2>
|
|
<p class="note">A person's own limit covers everything they do, across all their
|
|
keys, projects and the chat page. {{.Personal}}</p>
|
|
{{if .People}}
|
|
<table class="rows quota-tree people-limits">
|
|
<tr><th>Person</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
|
<th class="num">Budget</th><th>Spent</th><th></th></tr>
|
|
{{range .People}}
|
|
<tr{{if .Editing}} class="editing"{{end}}>
|
|
<td class="q-where"><span class="q-name">{{.Email}}</span>
|
|
{{if .Models}}<div class="q-sub">only <code>{{.Models}}</code></div>{{end}}</td>
|
|
<td class="num">{{or .RPM "—"}}</td>
|
|
<td class="num">{{or .TPM "—"}}</td>
|
|
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}—{{end}}</td>
|
|
<td class="q-spent"><span class="q-spend">{{.Spend}}</span>
|
|
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span>
|
|
<span class="q-pct">{{.Percent}}%</span>{{end}}</td>
|
|
<td class="q-act">{{if .Editing}}<a href="/admin/quotas">close</a>
|
|
{{else}}<a href="/admin/quotas?edit=user:{{.ID}}">{{if .HasAny}}change{{else}}set{{end}}</a>{{end}}</td>
|
|
</tr>
|
|
{{if .Editing}}
|
|
<tr class="editing"><td colspan="6">
|
|
{{template "quota-form" (dict "Scope" "user" "ID" .ID "CSRF" $.CSRF
|
|
"F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny)
|
|
"Title" (printf "%s, everywhere" .Email)
|
|
"Note" "Across every key this person holds, every project they use and the chat page.")}}
|
|
</td></tr>
|
|
{{end}}
|
|
{{end}}
|
|
</table>
|
|
{{else}}<p class="empty">Nobody has been approved yet.</p>{{end}}
|
|
|
|
{{if .HasCapacity}}
|
|
<h2 id="capacity">Capacity</h2>
|
|
<p class="note">What a part of the tree is guaranteed of the cluster, and the most
|
|
it may hold: GPUs, CPU cores and memory, counted from the replicas of the models
|
|
it owns and of those beneath it. What it holds within its guarantee is never taken
|
|
for someone else's model; beyond it, it borrows free room and gives it back when
|
|
asked. A model's owner is set on its card on the mirror page.</p>
|
|
{{if .Capacity}}
|
|
<table class="rows quota-tree">
|
|
<tr><th>Where</th><th class="num">GPUs</th><th class="num">Cores</th><th class="num">Memory GB</th><th>Set by</th><th></th></tr>
|
|
{{range .Capacity}}<tr>
|
|
<td class="q-where"><span class="q-name">{{or .Name .Scope}}</span></td>
|
|
<td class="num">{{or .GPUs "—"}}</td>
|
|
<td class="num">{{or .Cores "—"}}</td>
|
|
<td class="num">{{or .Memory "—"}}</td>
|
|
<td class="note">{{.UpdatedBy}}</td>
|
|
<td class="q-act"><a href="/admin/quotas?cap={{.Scope}}#capacity">change</a>
|
|
<form method="post" action="/admin/quotas/capacity" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="scope" value="{{.Scope}}">
|
|
<button type="submit" name="clear" value="1" class="link danger">remove</button>
|
|
</form></td>
|
|
</tr>{{end}}
|
|
</table>
|
|
<p class="q-legend">Each figure is held of guaranteed (at most the limit); — is not governed.</p>
|
|
{{else}}<p class="empty">No capacity quotas: every model shares the cluster by its class alone.</p>{{end}}
|
|
|
|
{{if .Scopes}}
|
|
<form method="post" action="/admin/quotas/capacity" class="q-form capacity-form">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<p class="q-form-title"><strong>{{if .CapForm.Scope}}Change{{else}}Set{{end}} a capacity quota.</strong>
|
|
A blank field is not governed; 0 is none at all.</p>
|
|
<label>Where
|
|
<select name="scope" required>
|
|
<option value="">choose…</option>
|
|
{{range .Scopes}}<option value="{{.Key}}"{{if eq .Key $.CapForm.Scope}} selected{{end}}>{{.Name}}</option>{{end}}
|
|
</select></label>
|
|
<div class="field-row">
|
|
<label>Guaranteed GPUs <input type="text" name="gpus" value="{{.CapForm.GPUs}}" placeholder="—"></label>
|
|
<label>Cores <input type="text" name="cores" value="{{.CapForm.Cores}}" placeholder="—"></label>
|
|
<label>Memory GB <input type="text" name="memory" value="{{.CapForm.Memory}}" placeholder="—"></label>
|
|
</div>
|
|
<div class="field-row">
|
|
<label>At most GPUs <input type="text" name="limit_gpus" value="{{.CapForm.LGPUs}}" placeholder="—"></label>
|
|
<label>Cores <input type="text" name="limit_cores" value="{{.CapForm.LCores}}" placeholder="—"></label>
|
|
<label>Memory GB <input type="text" name="limit_memory" value="{{.CapForm.LMem}}" placeholder="—"></label>
|
|
</div>
|
|
<div class="q-form-actions"><button type="submit">Save</button>
|
|
{{if .CapForm.Scope}}<a href="/admin/quotas#capacity">Cancel</a>{{end}}</div>
|
|
</form>
|
|
{{else}}<p class="empty">Nothing to give a capacity quota to yet. Make an organization on the
|
|
<a href="/admin/users">people page</a> first.</p>{{end}}
|
|
{{end}}
|
|
|
|
<details class="explain">
|
|
<summary>How limits combine</summary>
|
|
<p>Along a branch the most restrictive value wins, 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: a
|
|
project cannot allow a model its team withheld.</p>
|
|
<p>Per-key limits are not set here: a key is bounded by its project, and
|
|
putting every caller's throughput one click from an admin session is more
|
|
than this page should do — <code>llmgw keys limits --scope key</code>.</p>
|
|
</details>
|
|
</div>
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
|
</div></body></html>{{end}}
|
|
|
|
{{/* A limit as the table shows it: the row's own value, else the one it is
|
|
held to from above, marked as inherited and saying from where. */}}
|
|
{{/* The form for a limit that is not a branch's own: per member, or a person. */}}
|
|
{{define "quota-form"}}
|
|
<form method="post" action="/admin/quotas" class="q-form">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="hidden" name="scope" value="{{.Scope}}">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<p class="q-form-title"><strong>{{.Title}}</strong>. {{.Note}} A blank field means no limit of that kind.</p>
|
|
<div class="field-row">
|
|
<label>Requests / minute <input type="text" name="rpm" value="{{.F.RPM}}" placeholder="none"></label>
|
|
<label>Tokens / minute <input type="text" name="tpm" value="{{.F.TPM}}" placeholder="none"></label>
|
|
<label>Budget <input type="text" name="budget" value="{{.F.Budget}}" placeholder="none"></label>
|
|
<label class="q-period">per
|
|
<select name="period">
|
|
<option value="month"{{if eq .F.Period "month"}} selected{{end}}>month</option>
|
|
<option value="day"{{if eq .F.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="{{.F.Models}}" placeholder="every model"></label>
|
|
<div class="q-form-actions">
|
|
<button type="submit">Save</button>
|
|
<a href="/admin/quotas">Cancel</a>
|
|
<button type="submit" name="clear" value="1" class="link danger"
|
|
{{if not .F.HasAny}}disabled{{end}}>Remove these limits</button>
|
|
</div>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{define "quota-value"}}{{if .Own}}{{.Own}}{{else if .Up}}<span class="q-up" title="set on {{.From}}">{{.Up}} ↑</span>{{else}}<span class="q-none">—</span>{{end}}{{end}}
|