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>
89 lines
3.4 KiB
Go
89 lines
3.4 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/quota"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
type fakeCapacity struct {
|
|
views []adminapi.QuotaView
|
|
set []adminapi.QuotaRequest
|
|
cleared []string
|
|
}
|
|
|
|
func (f *fakeCapacity) QuotaViews(context.Context) ([]adminapi.QuotaView, error) { return f.views, nil }
|
|
func (f *fakeCapacity) SetQuota(_ context.Context, r adminapi.QuotaRequest, _ string) (string, error) {
|
|
if r.Scope == "team:nope" {
|
|
return "", errors.New(`there is no team "nope"`)
|
|
}
|
|
f.set = append(f.set, r)
|
|
return r.Scope, nil
|
|
}
|
|
func (f *fakeCapacity) ClearQuota(_ context.Context, s string) (string, error) {
|
|
f.cleared = append(f.cleared, s)
|
|
return s, nil
|
|
}
|
|
func (f *fakeCapacity) Scopes(context.Context) ([]adminapi.ScopeName, error) {
|
|
return []adminapi.ScopeName{{Key: "org:o1", Name: "Acme"}, {Key: "team:t1", Name: "Acme / ml"}}, nil
|
|
}
|
|
|
|
func TestCapacityQuotasAreShownAndSetFromTheQuotasPage(t *testing.T) {
|
|
eight, sixteen := 8, 16
|
|
fc := &fakeCapacity{views: []adminapi.QuotaView{{Scope: "team:t1", Name: "Acme / ml",
|
|
Guarantee: store.Resources{GPUs: &eight}, Limit: store.Resources{GPUs: &sixteen},
|
|
Holds: quota.Amount{GPUs: 6}, UpdatedBy: "ops@acme"}}}
|
|
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
|
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.DiscardHandler))
|
|
p.SetCapacity(fc)
|
|
mux := http.NewServeMux()
|
|
p.RegisterAdminQuotas(mux, &fakeQuotas{}, pricedYes{})
|
|
|
|
body := mirrorGet(t, mux, "/admin/quotas").Body.String()
|
|
for _, want := range []string{`id="capacity"`, "Acme / ml", "6 of 8 (at most 16)", "ops@acme",
|
|
`<option value="team:t1">Acme / ml</option>`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the page lacks %q", want)
|
|
}
|
|
}
|
|
// "change" fills the form with the scope's figures.
|
|
edit := mirrorGet(t, mux, "/admin/quotas?cap=team:t1").Body.String()
|
|
if !strings.Contains(edit, `<option value="team:t1" selected>`) || !strings.Contains(edit, `name="limit_gpus" value="16"`) {
|
|
t.Error("change did not open the form on the scope's quota")
|
|
}
|
|
|
|
res := mirrorPost(t, mux, "/admin/quotas/capacity", url.Values{"csrf": {"tok"}, "scope": {"team:t1"},
|
|
"gpus": {"8"}, "cores": {""}, "limit_memory": {"64"}}).Body.String()
|
|
if len(fc.set) != 1 || *fc.set[0].Guarantee.GPUs != 8 || fc.set[0].Guarantee.CPUCores != nil || *fc.set[0].Limit.MemoryGB != 64 {
|
|
t.Errorf("sent %+v", fc.set)
|
|
}
|
|
if !strings.Contains(res, "capacity quota set on team:t1") {
|
|
t.Errorf("no word of it: %s", res)
|
|
}
|
|
for _, bad := range []url.Values{{"scope": {"team:t1"}, "gpus": {"eight"}}, {"scope": {"team:nope"}, "gpus": {"1"}}, {"gpus": {"1"}}} {
|
|
bad.Set("csrf", "tok")
|
|
n := len(fc.set)
|
|
out := mirrorPost(t, mux, "/admin/quotas/capacity", bad).Body.String()
|
|
if len(fc.set) != n || !strings.Contains(out, `class="error"`) {
|
|
t.Errorf("%v was not refused with a reason", bad)
|
|
}
|
|
}
|
|
mirrorPost(t, mux, "/admin/quotas/capacity", url.Values{"csrf": {"tok"}, "scope": {"team:t1"}, "clear": {"1"}})
|
|
if len(fc.cleared) != 1 || fc.cleared[0] != "team:t1" {
|
|
t.Errorf("cleared %v", fc.cleared)
|
|
}
|
|
// Without a CSRF token nothing happens.
|
|
mirrorPost(t, mux, "/admin/quotas/capacity", url.Values{"scope": {"team:t1"}, "clear": {"1"}})
|
|
if len(fc.cleared) != 1 {
|
|
t.Error("a change was made without the CSRF token")
|
|
}
|
|
}
|