Files
llmgw/internal/cli/model_canary.go
Ilker ManapandClaude Opus 5.5 f0477f140d Quality suites, and new weights held to one: ctl eval, model update/promote --eval, model gate
A suite is a set of cases in YAML or JSON lines, each scored by exact,
contains, regex, a number within a tolerance, a JSON Schema subset, or a
judge model with a rubric; weighed into one score against a threshold.
internal/bench/eval runs one on the control plane through Router.Replay, as
the gateway's own requests, and keeps every run and its cases in the store
(migration 0028: eval_runs, eval_cases).

- llmgw ctl eval MODEL --suite FILE|NAME, --history, --run, --suites; exits
  non-zero below the score. Suites by name from control.eval_dir, or the
  two built in from deploy/eval (arithmetic, json-format).
- model update and promote take --eval SUITE --min-score N, or a model's
  standing gate (ctl model gate, eval: in the catalog): a promote asks the
  canary first and leaves it below the score; an update tries the new
  weights as a canary given no share, then promotes or removes it.
- The console's model menu: ask a suite with its progress, its runs, its
  gate.
- The audit middleware no longer hands a handler a body cut off at 64 KiB.

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

162 lines
6.2 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"slices"
"text/tabwriter"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
)
func newModelCanaryCmd(client func() *ctlClient) *cobra.Command {
var (
cn catalog.Canary
abort, detach bool
)
c := &cobra.Command{
Use: "canary MODEL",
Short: "Try a model's newer weights on a share of its requests before moving to them",
Long: "The newer files of the model's repository are fetched and run beside it\n" +
"as MODEL-canary, one replica, and given --percent of MODEL's requests. A\n" +
"session (x-session-id, or the user field) stays on one side; the\n" +
"x-llmgw-variant header (canary or stable) pins a request to one. Clients\n" +
"cannot name the canary itself.\n\n" +
"The two sides are compared as they serve. Once the canary has served\n" +
"--min-requests, it is taken out by itself if its error rate is\n" +
"--max-error-increase above the model's, or its time to the first token\n" +
"(or, unstreamed, to the answer) --max-ttft-ratio times the model's, and\n" +
"canary_rolled_back goes to the notification webhooks.\n\n" +
"Run again with --percent to change its share, --abort to take it out;\n" +
"`llmgw ctl model promote MODEL` moves the model to it.",
Example: " llmgw ctl model canary qwen-7b --percent 10\n" +
" llmgw ctl model canary qwen-7b --percent 50\n" +
" llmgw ctl model canary qwen-7b --abort",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
var out map[string]string
if abort {
if err := client().delete(cmd.Context(), "/api/v1/models/"+name+"/canary", &out); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), out["result"])
return nil
}
var have []modelserve.CanaryView
if err := client().get(cmd.Context(), "/api/v1/canaries", &have); err != nil {
return err
}
if slices.ContainsFunc(have, func(v modelserve.CanaryView) bool { return v.Model == name }) {
if !cmd.Flags().Changed("percent") {
return fmt.Errorf("%s already has a canary: give --percent to change its share, or --abort", name)
}
body, _ := json.Marshal(map[string]int{"percent": cn.Percent})
if err := client().put(cmd.Context(), "/api/v1/models/"+name+"/canary", body, &out); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), out["result"])
return nil
}
body, _ := json.Marshal(cn)
var job modelserve.Job
if err := client().post(cmd.Context(), "/api/v1/models/"+name+"/canary", body, &job); err != nil {
return err
}
if detach {
return detached(cmd, job)
}
return followJob(cmd, client, job, "has a canary")
},
}
f := c.Flags()
f.IntVar(&cn.Percent, "percent", 10, "share of the model's requests the canary is given, 0 to 100")
f.IntVar(&cn.MinRequests, "min-requests", 50, "requests the canary serves before it is judged")
f.Float64Var(&cn.MaxErrorIncrease, "max-error-increase", 0.05, "error rate above the model's that rolls it back (0.05 is five points)")
f.Float64Var(&cn.MaxTTFTRatio, "max-ttft-ratio", 1.5, "times the model's time to the first token that rolls it back")
f.BoolVar(&abort, "abort", false, "take the canary out; the model serves all its requests again")
f.BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it")
return c
}
func newModelPromoteCmd(client func() *ctlClient) *cobra.Command {
var (
detach bool
gate gateFlags
)
c := &cobra.Command{
Use: "promote MODEL",
Short: "Move a model to its canary's weights, and take the canary out",
Long: "The update the canary was a trial of: the catalog names the canary's\n" +
"files, the model's replicas are replaced one at a time, and the canary\n" +
"is removed. `llmgw ctl model rollback MODEL` goes back.\n\n" +
"Held to a quality suite (--eval, or the model's own: `llmgw ctl model\n" +
"gate`), the canary is asked it first, and below its score nothing\n" +
"moves: the canary stays, with its share, until it is taken out.",
Example: " llmgw ctl model promote qwen-7b\n" +
" llmgw ctl model promote qwen-7b --eval ./support.yaml --min-score 0.9 --judge llama-70b",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
body, err := gate.body()
if err != nil {
return err
}
var job modelserve.Job
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/promote", body, &job); err != nil {
return err
}
if detach {
return detached(cmd, job)
}
return followJob(cmd, client, job, "runs the canary's weights")
},
}
c.Flags().BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it")
gate.add(c)
return c
}
func newModelCanariesCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "canaries",
Short: "Models trying a canary, and how each side has served since it began",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var vs []modelserve.CanaryView
if err := client().get(cmd.Context(), "/api/v1/canaries", &vs); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(vs)
}
if len(vs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No model has a canary.")
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "MODEL\tCANARY\tSHARE\tREQUESTS\tERRORS\tFIRST TOKEN\tANSWER")
for _, v := range vs {
side := func(label string, req int64, errRate, ttft, lat float64) {
fmt.Fprintf(w, "%s\t%s\t\t%d\t%.1f%%\t%s\t%s\n", "", label, req, 100*errRate, meanMs(ttft), meanMs(lat))
}
fmt.Fprintf(w, "%s\t%s\t%d%%\t\t\t\t\n", v.Model, v.Canary.Model, v.Canary.Percent)
if s := v.Serving; s != nil {
side(" canary", s.Candidate.Requests, s.Candidate.ErrorRate(), s.Candidate.TTFTMs, s.Candidate.LatencyMs)
side(" stable", s.Stable.Requests, s.Stable.ErrorRate(), s.Stable.TTFTMs, s.Stable.LatencyMs)
}
}
return w.Flush()
},
}
}
func meanMs(v float64) string {
if v <= 0 {
return "-"
}
return fmt.Sprintf("%.0f ms", v)
}