Files
llmgw/internal/web/admin_serve.go
T
Ilker ManapandClaude Opus 5.5 b0273ffb5f Qwen3Guard is the judge, and judges on a GPU and on CPUs are asked in turn
Llama Guard 3 1B could not tell ordinary coding requests from harmful ones:
on the PoC it scored "continue" 0.51, "continue the code above" 0.89 and
"how do I kill all child processes in bash" 0.92 unsafe, against 0.97 to
1.00 for harmful requests, at Q4_K_M and Q8_0 alike, with its own template.
A site had "continue" refused. Qwen3Guard-Gen 0.6B gave the ordinary ones
0.15 at the most and the harmful ones 1.00, in 0.8 GB on CPUs.

- format qwen3guard: its prompt written out as its template writes it, for
  requests and answers, and its Safe / Unsafe / Controversial verdict read;
  controversial counts as the policy's judge.controversial says, safe by
  default.
- judge.models: judges asked in turn, the first that answers deciding; a
  failure is recorded against the judge that failed. guard judge use sets
  the order, replacing categories none of the judges names.
- known judges (Qwen3Guard 0.6B/4B/8B, Llama Guard 1B/8B): guard judge
  enable serves any of them, --gpu the 4B on the roomiest GPU class as a
  standard model; the leader fetches and serves one a policy names that the
  catalog lacks.
- server init installs qwen3guard-0.6b with the baseline's categories.
- a judge reads a long text by its start and end, which fit its context,
  rather than overflowing it and refusing the request under fail: closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:22:47 +02:00

618 lines
19 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
)
// CatalogAdmin adds a model to the catalogue in force.
type CatalogAdmin interface {
Add(ctx context.Context, m catalog.Model, by string) error
Declared(name string) bool
// Sources maps a weights source to the catalog models that use it.
Sources(ctx context.Context) (map[string][]catalog.Model, error)
// Scale changes how a model added at runtime runs: always, on demand, or
// not at all. One declared in configuration is refused.
Scale(ctx context.Context, name string, sc catalog.Scaling, by string) (catalog.Model, error)
// SetClass, SetOwner and Resources change what a model's capacity is
// worth, whose quota it counts against, and what a replica reserves.
SetClass(ctx context.Context, name, class, by string) (catalog.Model, error)
SetOwner(ctx context.Context, name, owner, by string) (catalog.Model, error)
Resources(ctx context.Context, name, profile string, memoryGB, cpuCores int, by string) (catalog.Model, error)
SetAdapters(ctx context.Context, name string, adapters []catalog.Adapter, by string) (catalog.Model, error)
}
// serveForm is the offer to put one mirrored version into service.
//
// Everything a catalogue entry needs that the mirror already knows is filled
// in: the name, the source, and the engine that reads that kind of file. What
// is left is what nobody can infer — the context pool and how many replicas —
// and those are fields rather than guesses presented as facts.
type serveForm struct {
Model string
Version string
Source string
Engine string
// Profile is the profile name, which doubles as how a node_selector would
// be written later. Named for the hardware rather than the model, because
// a profile is about where a model runs. Derived from the placement, not
// typed: a name is only a name, and a field for it read as though calling
// a profile "gpu" would put it on one.
Profile string
// Placements are where it can run: each GPU class the fleet has, and for
// llama.cpp the CPU. One is selected.
Placements []placeChoice
// GPUs is how many cards a replica takes, when placed on a GPU class.
GPUs int
// Parallel is how many requests one replica serves at once.
Parallel int
// FreeGPUs is how many cards no replica holds, across the fleet: what
// choosing the CPU leaves unused.
FreeGPUs int
// Files is how many files the version holds, above one for a set.
Files int
// MinMemoryGB is the host RAM to ask for, estimated from the weights.
//
// Filled in rather than left blank. Blank writes no constraint at all, and
// a CPU profile has no card to be short of, so every ready node becomes a
// candidate: a 4.7 GB model went onto a 7.9 GB machine and left it with
// 3.5 GB for a KV pool. Nothing was wrong with placement — it was never
// told what the model needed.
MinMemoryGB int
// CPUCores is the cores a replica computes on: half the smallest node it
// fits on, or zero when no node says how many it has.
CPUCores int
// MemoryBasis says how MinMemoryGB was arrived at, for the form to show.
MemoryBasis string
// CtxPool is the engine context pool the form starts with, and the one
// the memory figure assumes.
CtxPool int
// Refusal is set when this version cannot be served as it stands, and says
// why. Offered and then failing at placement is worse than not offered.
Refusal string
// Warning is set when it can be served but probably will not run.
Warning string
// Already is true when a model of this name is in the catalogue.
Already bool
}
// planServe works out what serving this version would mean.
func planServe(l *Lang, st mirror.Status, files []mirror.Status, base string,
fleet Source, cat CatalogAdmin, check func(source string) gguf.Verdict,
) serveForm {
var header gguf.Header
f := serveForm{
Model: st.Model,
Version: st.Version,
Source: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
URL(base),
Engine: engineFor(st.File),
Profile: "cpu",
}
// A version of several files is served as the version, not as one of them.
// The source is the directory — a trailing slash — and the agent fetches
// every file in it and hands the engine the folder.
if len(files) > 1 {
f.Source = strings.TrimSuffix(
mirror.Entry{Model: st.Model, Version: st.Version}.URL(base), "/") + "/"
f.Files = len(files)
// From whichever file says, because a version is a mixture only when
// somebody has put one there by hand. The weights decide the engine.
for _, one := range files {
if e := engineFor(one.File); e != "" {
f.Engine = e
break
}
}
var total int64
for _, one := range files {
total += one.BytesDone
}
st.BytesTotal = total
}
switch f.Engine {
case "":
f.Refusal = l.T("serve.refuse.no_engine", extOf(st.File))
return f
case "llamacpp":
f.Profile = "cpu"
if check != nil {
v := check(f.Source)
if !v.Servable {
f.Refusal = l.T("serve.refuse.llamacpp", v.Reason)
return f
}
header = v.Header
}
default:
f.Profile = "gpu"
}
f.GPUs, f.Parallel = 1, defaultParallel
f.Placements, f.FreeGPUs = placementsFor(l, fleet, f.Engine)
for _, pc := range f.Placements {
if pc.Selected {
f.Profile = pc.profile()
}
}
// Whether any node can actually run it. Agents report their installed
// engines now, so this is a fact rather than the guess it started as —
// which was cards, and would have passed a GPU node with no vLLM on it.
//
// A warning, not a refusal: an engine can be installed this afternoon, and
// a node that reports nothing is an older agent rather than an empty one.
if !runsAnywhere(fleet, f.Engine) {
f.Warning = l.T("serve.warn.not_installed", f.Engine, extOf(st.File))
if f.Engine != "llamacpp" && gpus(fleet) == 0 {
f.Warning += ". " + l.T("serve.warn.no_gpu")
}
}
f.CtxPool = defaultCtxPool
f.MinMemoryGB, f.MemoryBasis = memoryFor(st.BytesTotal, st.BytesDone), l.T("serve.basis.estimate")
weights := st.BytesTotal
if weights <= 0 {
weights = st.BytesDone
}
if b := header.MemoryBytes(weights, defaultCtxPool); b > 0 {
kvHeads := header.HeadCountKV
if kvHeads == 0 {
kvHeads = header.HeadCount
}
// From the model's own shape: its KV cache for the pool the form
// starts with, which ranges from under half a gigabyte to over five
// across the models on the live mirror at the same 16k — a spread the
// flat "half again" could not see.
f.MinMemoryGB = int((b + 1<<30 - 1) >> 30)
f.MemoryBasis = l.T("serve.basis.shape",
l.Num(float64(weights)/(1<<30), 1), l.Num(float64(int64(defaultCtxPool)*header.KVBytesPerToken())/(1<<30), 1),
l.Int(defaultCtxPool), header.BlockCount, kvHeads,
l.Num(float64(int64(1<<30)+weights/20)/(1<<30), 1))
}
if fleet != nil {
var cpus []int
for _, n := range fleet.Nodes() {
if n.State == "ready" && (n.MemoryGB == 0 || n.MemoryGB >= f.MinMemoryGB) {
cpus = append(cpus, n.CPUCount)
}
}
f.CPUCores = catalog.SuggestCores(cpus)
}
if biggest := biggestNode(fleet); biggest > 0 && f.MinMemoryGB > biggest {
big := l.T("serve.warn.too_big", biggest, f.MinMemoryGB)
if f.Warning != "" {
big = l.T("serve.warn.and", strings.TrimSuffix(f.Warning, "."), big)
}
f.Warning = big
}
if cat != nil && cat.Declared(st.Model) {
f.Already = true
f.Refusal = l.T("serve.refuse.declared", st.Model)
}
return f
}
// engineFor is the engine that reads this kind of weights file.
func engineFor(file string) string {
switch strings.ToLower(extOf(file)) {
case ".gguf":
return "llamacpp"
case ".safetensors", ".bin":
return "vllm"
}
return ""
}
func extOf(file string) string {
if i := strings.LastIndex(file, "."); i >= 0 {
return file[i:]
}
return file
}
// runsAnywhere reports whether some node has this engine.
//
// True when nothing is known, so a fleet of agents older than the engine probe
// is not told every model is unservable. That matches what placement does with
// the same absence, and the two disagreeing would be worse than either.
func runsAnywhere(fleet Source, engine string) bool {
if fleet == nil || engine == "" {
return true
}
anyReported := false
for _, n := range fleet.Nodes() {
if len(n.Engines) == 0 {
continue
}
anyReported = true
for _, e := range n.Engines {
if e == engine {
return true
}
}
}
return !anyReported
}
func gpus(fleet Source) int {
if fleet == nil {
return 0
}
n := 0
for _, node := range fleet.Nodes() {
n += node.GPUTotal
}
return n
}
// buildModel turns the submitted form into a catalogue entry. Its refusals
// are in l, the language of whoever filled the form in.
func buildModel(l *Lang, get func(string) string) (catalog.Model, error) {
m := catalog.Model{
Name: strings.TrimSpace(get("name")),
Description: strings.TrimSpace(get("description")),
Source: strings.TrimSpace(get("source")),
RoutingProfile: "chat",
}
if m.Name == "" || m.Source == "" {
return m, errors.New(l.T("serve.err.name_source"))
}
for _, a := range strings.Split(get("aliases"), ",") {
if a = strings.TrimSpace(a); a != "" {
m.Aliases = append(m.Aliases, a)
}
}
ctxLen, err := positive(l, get("context_length"), "serve.what.context_length")
if err != nil {
return m, err
}
m.ContextLength = ctxLen
if v := strings.TrimSpace(get("max_output")); v != "" {
if m.MaxOutput, err = positive(l, v, "serve.what.max_output"); err != nil {
return m, err
}
}
min, err := positive(l, get("replicas"), "serve.what.replicas")
if err != nil {
return m, err
}
m.Scaling = scalingFor(get("mode"), min, 0)
engine := strings.TrimSpace(get("engine"))
if engine == "" {
return m, errors.New(l.T("serve.err.engine"))
}
p := catalog.Profile{Engine: engine, Weight: 1, Priority: 100}
gpus := 1
if v := strings.TrimSpace(get("gpus")); v != "" {
if gpus, err = positive(l, v, "serve.what.gpus"); err != nil {
return m, err
}
gpus = max(gpus, 1)
}
place := placeChoice{Value: strings.TrimSpace(get("placement"))}
switch class, onGPU := strings.CutPrefix(place.Value, "gpu:"); {
case onGPU && class != "":
// Placement is decided by gpu_class, which is what the selector
// sets; the count is gpus_per_node for llama.cpp, which spreads
// layers over a node's cards, and tp for the others.
p.GPUClass = class
if engine == "llamacpp" {
p.GPUsPerNode = gpus
} else {
p.TP = gpus
}
place.GPUs = gpus
case place.Value == "" || place.Value == "cpu":
if engine != "llamacpp" {
return m, errors.New(l.T("serve.err.needs_gpu", engine))
}
place.Value = "cpu"
default:
return m, errors.New(l.T("serve.err.placement", place.Value))
}
p.Name = place.profile()
if v := strings.TrimSpace(get("profile")); v != "" {
p.Name = v
}
// The pool, not the per-request limit: llama.cpp divides it across its
// parallel slots, so the number a client may use is smaller and is what
// context_length above says.
if v := strings.TrimSpace(get("ctx_size")); v != "" {
if _, err := positive(l, v, "serve.what.ctx_pool"); err != nil {
return m, err
}
p.Args = []string{"--ctx-size", v}
}
if v := strings.TrimSpace(get("parallel")); v != "" && engine == "llamacpp" {
n, err := positive(l, v, "serve.what.parallel")
if err != nil {
return m, err
}
if n > 0 {
p.Args = append(p.Args, "--parallel", strconv.Itoa(n))
}
}
if v := strings.TrimSpace(get("min_memory_gb")); v != "" {
if p.MinMemoryGB, err = positive(l, v, "serve.what.memory"); err != nil {
return m, err
}
}
if v := strings.TrimSpace(get("cpu_cores")); v != "" {
if p.CPUCores, err = positive(l, v, "serve.what.cores"); err != nil {
return m, err
}
}
if k, v := strings.TrimSpace(get("selector_key")), strings.TrimSpace(get("selector_value")); k != "" && v != "" {
// What keeps a large model off the small nodes. A CPU profile has no
// card to be short of, so without this every ready node looks like a
// candidate and the first one picked is the one that gets OOM-killed.
p.NodeSelector = map[string]string{k: v}
}
m.Profiles = []catalog.Profile{p}
return m, nil
}
// positive reads a whole number from a form field; what is the key naming
// the field, for the refusal.
func positive(l *Lang, v, what string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return 0, errors.New(l.T("serve.err.whole_number", l.T(what)))
}
return n, nil
}
func init() {
registerKeys("serve.what.context_length", "serve.what.max_output", "serve.what.replicas",
"serve.what.gpus", "serve.what.ctx_pool", "serve.what.parallel", "serve.what.memory",
"serve.what.cores")
}
// memoryFor estimates the host RAM a model needs, from the size of its weights.
//
// Weights plus half again, plus two. The half covers the KV pool, which grows
// with the same things the weights do — layers and heads — and the two are the
// engine's own footprint and the page cache it will not get back. It is an
// estimate and the field it fills is editable; what it is not is blank, which
// is what put a 4.7 GB model on a 7.9 GB node.
//
// The honest number comes from running it: `llmgw ctl bench` measures what a
// replica can actually do, and that is a different question from what it needs
// to start.
func memoryFor(total, done int64) int {
size := total
if size <= 0 {
size = done
}
if size <= 0 {
return 0
}
gb := float64(size) / (1 << 30)
return int(gb*1.5) + 2
}
// biggestNode is the most memory any single node reports, or 0 when none says.
func biggestNode(fleet Source) int {
if fleet == nil {
return 0
}
most := 0
for _, n := range fleet.Nodes() {
if n.MemoryGB > most {
most = n.MemoryGB
}
}
return most
}
// Run modes a page offers for a model, in the words a person would use.
const (
modeAlways = "always"
modeOnDemand = "ondemand"
modeStopped = "stopped"
)
// defaultIdle is how long an on-demand model set from a page stays loaded
// after its last request.
const defaultIdle = 15 * time.Minute
// scalingFor turns a run mode into scaling: replicas is how many run, or may
// run on demand; idle keeps an on-demand model's timeout when it has one.
func scalingFor(mode string, replicas int, idle time.Duration) catalog.Scaling {
if replicas < 1 {
replicas = 1
}
switch mode {
case modeOnDemand:
if idle <= 0 {
idle = defaultIdle
}
return catalog.Scaling{Min: 0, Max: replicas, IdleTimeout: idle}
case modeStopped:
return catalog.Scaling{Min: 0, Max: 0}
default:
return catalog.Scaling{Min: replicas, Max: replicas}
}
}
// modeOf names how a model runs.
func modeOf(sc catalog.Scaling) string {
switch {
case sc.Min > 0:
return modeAlways
case sc.Max > 0:
return modeOnDemand
default:
return modeStopped
}
}
// defaultCtxPool is the llama.cpp context pool the serve form starts with.
// --parallel divides it among slots; it does not multiply it.
const defaultCtxPool = 16384
// defaultParallel is the requests a llama.cpp replica serves at once when
// the form is not changed: the agent's own default.
const defaultParallel = 4
// placeChoice is one place a model can run: a GPU class, or the CPU.
type placeChoice struct {
// Value is "gpu:<class>" or "cpu", as the form submits it.
Value string
Label string
// GPUs is how many cards a replica takes, for naming the profile.
GPUs int
Selected bool
}
// profile names the profile for where it runs: the class, with the count when
// it is more than one card ("t4", "t4-x4"), or "cpu".
func (c placeChoice) profile() string {
class, ok := strings.CutPrefix(c.Value, "gpu:")
if !ok || class == "" {
return "cpu"
}
if c.GPUs > 1 {
return fmt.Sprintf("%s-x%d", class, c.GPUs)
}
return class
}
// placementsFor lists where engine can run on this fleet, with one selected,
// and how many GPUs are free.
//
// A GPU when the fleet has one: a GGUF model was put on the CPU by this form
// on a cluster of GPUs, ran at a tenth of their speed, and nothing on the way
// said why — the form had no choice to offer and named the profile "cpu". The
// class selected is the one with the most cards free.
func placementsFor(l *Lang, fleet Source, engine string) ([]placeChoice, int) {
var out []placeChoice
free, best := 0, -1
if fleet != nil {
for _, c := range fleet.Summary().GPUs {
n := c.Free()
free += n
out = append(out, placeChoice{
Value: "gpu:" + c.Class,
Label: l.T("serve.place.gpu", c.Class, n, c.Total),
})
if best < 0 || n > fleet.Summary().GPUs[best].Free() {
best = len(out) - 1
}
}
}
if best >= 0 {
out[best].Selected = true
}
if engine == "llamacpp" {
label := l.T("serve.place.cpu_slower")
if len(out) == 0 {
label = l.T("serve.place.cpu_only")
}
out = append(out, placeChoice{Value: "cpu", Label: label, Selected: best < 0})
}
return out, free
}
// freeGPUs is how many cards no replica holds across the fleet.
func freeGPUs(fleet Source) int {
if fleet == nil {
return 0
}
n := 0
for _, c := range fleet.Summary().GPUs {
n += c.Free()
}
return n
}
// describeServe says, in one line, what a model will run as: the summary
// shown before it is added and in the message after. The form's script says
// the same with the same sentences, so the two cannot drift apart in wording.
func describeServe(l *Lang, m catalog.Model) string {
if len(m.Profiles) == 0 {
return ""
}
p := m.Profiles[0]
engine := map[string]string{"llamacpp": "llama.cpp", "vllm": "vLLM", "sglang": "SGLang", "trtllm": "TensorRT-LLM"}[p.Engine]
if engine == "" {
engine = p.Engine
}
where := l.T("serve.on_cpu", engine)
if p.GPUClass != "" {
n := max(p.GPUsPerNode, p.TP, 1)
where = l.T("serve.on_gpu_one", engine, p.GPUClass)
if n > 1 {
where = l.T("serve.on_gpus", engine, n, p.GPUClass)
}
}
parts := []string{where}
if p.MinMemoryGB > 0 {
parts = append(parts, l.T("serve.host_memory", l.Int(int64(p.MinMemoryGB))))
}
if p.GPUClass == "" && p.CPUCores > 0 {
parts = append(parts, l.N("serve.cores", int64(p.CPUCores)))
}
pool, parallel := argInt(p.Args, "--ctx-size"), argInt(p.Args, "--parallel")
switch {
case pool > 0 && parallel > 0:
parts = append(parts, l.T("serve.pool_parallel", l.Int(int64(pool)), l.Int(int64(parallel)), l.Int(int64(pool/parallel))))
case pool > 0:
parts = append(parts, l.T("serve.pool", l.Int(int64(pool))))
}
return strings.Join(parts, ", ")
}
// argInt is the number after flag in args, or 0.
func argInt(args []string, flag string) int {
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
n, _ := strconv.Atoi(args[i+1])
return n
}
}
return 0
}
// asJudge makes a model built from the serve form a judge when its weights
// are one's — Llama Guard, Qwen3Guard, ShieldGemma: role guard, that judge's
// format, under the name the policies give their judge when they give one
// and the form gave another. It says whether it did, and the name the form
// gave when it was changed.
func (p *AccountPages) asJudge(m *catalog.Model) (judge bool, renamed string) {
format, ok := guard.FormatOfWeights(m.Source)
if !ok {
return false, ""
}
m.Role, m.Guard, m.RoutingProfile = catalog.RoleGuard, &catalog.GuardModel{Format: format}, ""
if p.guardLibrary == nil {
return true, ""
}
asked := map[string]bool{}
for _, pol := range p.guardLibrary().Policies {
for _, j := range pol.Judge.Judges() {
asked[j] = true
}
}
if asked[m.Name] || len(asked) != 1 {
return true, ""
}
for name := range asked {
renamed, m.Name = m.Name, name
}
return true, renamed
}