Files
yetone c98160c045 gateway: a group's rule can pause one of its models at set hours, days or agents, leaving it out of the group then, not first, not a fallback, not kept by affinity (John on Discord)
John asked for a pause in a group's Rules, e.g. a model paused in its
vendor's peak hours. A time rule could only send those turns to another
member first: the paused-for model stayed behind it for failover, went
first when the other was unready, and was kept by affinity mid-turn.

What changed:
- provider: Rule.Pause (`"pause": true`, typed `pause=<model>`). It holds
  by time/days and agents only; cleanRules and ParseRule refuse any other
  condition, and refuse a pause with none. Pause rules are not send-to rules:
  MatchesBesidesIntent, ruledEntry and Ruled() leave them out.
  PausedOut(g, ms, agent, at) drops members a pause holds for, from the
  group's own rules and each nested group's (a group named = all its
  models; a model named = wherever a nested group has it, #625).
- gateway: serve and embeddings run PausedOut on ruleClock before planning,
  on every request. Every member paused -> 503 naming each rule
  (pausedError). The trace's GroupRef carries Paused.
- GUI: the rule editor's "send to" is now an app-menu action button (send
  to / pause); a pause hides the request conditions (clearing them),
  keeps hours, days and agents, and isn't saved without one. The group
  card's rule reads "→ pause <model>", and the Routing trace says which
  model was paused by which rule. Strings in zh, zh-TW, ja, de.
- CLI/TUI: `magpie group rule add <g> pause=<model> time=… days=…`.
- docs: gateway-routing.md step 2; start.html en/zh/ja "Hours and days".

Run:
- gofmt -l clean; go vet -tags nogui ./...; GOOS=linux and GOOS=windows
  go vet -tags nogui on gateway, provider, tui, root; go build ./...
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok.
- rules_pause_test.go (3 tests) fails with the PausedOut call in serve
  removed (the paused model answers mid-turn, as a fallback, and the
  all-paused case returns 200). grouprule_pause_test.go covers clean,
  parse/Line, Ruled, Pauses and nested PausedOut.
- GUI: routing-pause.test.cjs in Chromium and WebKit, en/zh at 1100px
  and en/zh/zh-TW/ja/de at 420px: 14/14; all 7 Chromium cases fail on
  the old routing.js. routing-*, group-*, i18n-keys, gui-ja, gui-de,
  click-scroll: 618 pass, 4 fail under --test-concurrency=4
  (routing-blocked, -idle-frame, -side-calls, -wb-refused), all of which
  pass run alone; routing-side-calls failed 1 of 6 alone and passes
  with the old routing.js too, so it is a load flake not touched here.
2026-10-10 13:51:09 +08:00

281 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package provider
// A rule as it is typed: use=<model> tokens=200k images effort=high
// agents=a,b intent="…" compact time=09:00-18:00 days=mon-fri, or
// pause=<model> time=… days=… agents=… — by magpie group rule and the
// TUI's routing page.
import (
"fmt"
"math"
"slices"
"strconv"
"strings"
)
// ParseTokens reads 200000, 200k, 1.5m.
func ParseTokens(v string) (int, error) {
s := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(v, "_", "")))
mul := 1.0
switch {
case strings.HasSuffix(s, "k"):
mul, s = 1e3, strings.TrimSuffix(s, "k")
case strings.HasSuffix(s, "m"):
mul, s = 1e6, strings.TrimSuffix(s, "m")
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || f < 0 || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, fmt.Errorf("tokens %q is not a length (200000, 200k, 1m)", v)
}
n := f * mul
if n >= float64(math.MaxInt) {
return 0, fmt.Errorf("tokens %q is not a length (200000, 200k, 1m)", v)
}
return int(n), nil
}
// ParseDays reads days of the week: "mon-fri", "sat,sun", "mon,wed-fri";
// a range may run past Sunday (fri-mon).
func ParseDays(v string) ([]string, error) {
var out []string
for _, part := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) {
a, b, isRange := strings.Cut(strings.ReplaceAll(part, "–", "-"), "-")
from, ok := ParseDay(a)
if !ok {
return nil, fmt.Errorf("days: %q is not a day of the week (mon … sun)", a)
}
to := from
if isRange {
if to, ok = ParseDay(b); !ok {
return nil, fmt.Errorf("days: %q is not a day of the week (mon … sun)", b)
}
}
for i := slices.Index(Weekdays, from); ; i = (i + 1) % 7 {
if !slices.Contains(out, Weekdays[i]) {
out = append(out, Weekdays[i])
}
if Weekdays[i] == to {
break
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("days names the days of the week it holds on: days=mon-fri")
}
return out, nil
}
// ParseTimeWindow reads hours of the day: 09:00-18:00, 22:00-08:00.
func ParseTimeWindow(v string) (*TimeWindow, error) {
from, to, ok := strings.Cut(strings.ReplaceAll(strings.TrimSpace(v), "–", "-"), "-")
if !ok {
return nil, fmt.Errorf("time is hours of the day, local time: time=09:00-18:00 (or 22:00-08:00, past midnight), not %q", v)
}
w := &TimeWindow{From: strings.TrimSpace(from), To: strings.TrimSpace(to)}
if err := w.clean(); err != nil {
return nil, err
}
return w, nil
}
// UnknownRuleWord starts the error for a word ParseRule doesn't know.
const UnknownRuleWord = "unknown "
// ParseRule makes a rule of k=v words; the model is resolved among the
// group's members. classifier is the group's classifier when one was
// given, at the place asked for (from 1) when one was.
func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err error) {
for _, w := range words {
k, v, hasV := strings.Cut(w, "=")
k = strings.ToLower(strings.TrimSpace(k))
switch k {
case "use", "model", "to":
id, err := GroupMember(g, v)
if err != nil {
return r, 0, "", err
}
r.Use = id
case "pause", "skip":
id, err := GroupMember(g, v)
if err != nil {
return r, 0, "", err
}
r.Use, r.Pause = id, true
case "tokens", "context", "longer":
n, err := ParseTokens(v)
if err != nil {
return r, 0, "", err
}
r.Tokens = n
case "images", "image":
switch strings.ToLower(v) {
case "", "yes", "true", "on", "1":
r.Images = true
case "no", "false", "off", "0":
r.Images = false
default:
return r, 0, "", fmt.Errorf("images takes no value (or yes/no), not %q", v)
}
case "compact", "compacting", "compaction":
switch strings.ToLower(v) {
case "", "yes", "true", "on", "1":
r.Compact = true
case "no", "false", "off", "0":
r.Compact = false
default:
return r, 0, "", fmt.Errorf("compact takes no value (or yes/no), not %q", v)
}
case "effort", "reasoning", "thinking":
if !hasV {
v = "on"
}
r.Effort = strings.ToLower(strings.TrimSpace(v))
case "agents", "agent":
r.Agents = strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' })
case "intent", "asks", "about":
r.Intent = strings.TrimSpace(v)
if r.Intent == "" {
return r, 0, "", fmt.Errorf(`intent says what the message asks for: intent="writing or fixing tests"`)
}
case "time", "hours", "between":
w, err := ParseTimeWindow(v)
if err != nil {
return r, 0, "", err
}
if r.Time != nil {
w.Days = r.Time.Days
}
r.Time = w
case "days", "day":
days, err := ParseDays(v)
if err != nil {
return r, 0, "", err
}
if r.Time == nil {
r.Time = &TimeWindow{From: "00:00", To: "00:00"} // the whole day, unless time= says
}
r.Time.Days = days
case "classifier", "classify", "by":
if classifier = strings.TrimSpace(v); classifier == "" {
return r, 0, "", fmt.Errorf("classifier=<model>: the model that tells which intent a message is")
}
case "at":
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return r, 0, "", fmt.Errorf("at is a place from 1, not %q", v)
}
at = n
default:
return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, pause, tokens, images, effort, agents, intent, compact, time, days, classifier, at)", w)
}
}
if r.Use == "" {
return r, 0, "", fmt.Errorf("use=<model> (or pause=<model>) is missing: one of %s", strings.Join(g.Members, ", "))
}
if r.Pause && (r.Tokens > 0 || r.Images || r.Effort != "" || r.Intent != "" || r.Compact) {
return r, 0, "", fmt.Errorf("pause=<model> holds by time=, days= or agents= only")
}
if r.Pause && r.Time == nil && len(r.Agents) == 0 {
return r, 0, "", fmt.Errorf("pause=<model> needs time=, days= or agents=: when the model is left out")
}
if r.Intent != "" && classifier == "" && g.Classifier == "" {
return r, 0, "", fmt.Errorf("a rule with an intent needs the group's classifier, the model that tells which intent a message is: add classifier=<model>, best a small fast one")
}
return r, at, classifier, nil
}
// GroupMember is the group's member a typed model names: its id, its
// model id without the provider, or the model's last name — each with the
// effort fixed on the member ("m:high") or, when only one member is of
// that model, without it.
func GroupMember(g Group, in string) (string, error) {
in = strings.TrimPrefix(strings.TrimSpace(in), "magpie/")
plain := func(m string) string { model, _ := MemberEffort(m); return model }
for _, match := range []func(string) bool{
func(m string) bool { return m == in },
func(m string) bool { return strings.EqualFold(m, in) },
func(m string) bool { _, bare, _ := strings.Cut(m, "/"); return strings.EqualFold(bare, in) },
func(m string) bool { return strings.EqualFold(m[strings.LastIndex(m, "/")+1:], in) },
func(m string) bool { return strings.EqualFold(plain(m), in) },
func(m string) bool { _, bare, _ := strings.Cut(plain(m), "/"); return strings.EqualFold(bare, in) },
} {
var hits []string
for _, m := range g.Members {
if match(m) {
hits = append(hits, m)
}
}
switch len(hits) {
case 0:
continue
case 1:
return hits[0], nil
default:
return "", fmt.Errorf("%s is %s: name one", in, strings.Join(hits, " and "))
}
}
return "", fmt.Errorf("%s is not in %s (its models: %s)", in, g.ID, strings.Join(g.Members, ", "))
}
// RuleWords splits a typed line into its words, a "quoted" part in one:
// intent="a quick question".
func RuleWords(line string) []string {
var out []string
var w strings.Builder
quoted, some := false, false
for _, c := range line {
switch {
case c == '"':
quoted, some = !quoted, true
case !quoted && (c == ' ' || c == '\t'):
if some {
out = append(out, w.String())
}
w.Reset()
some = false
default:
w.WriteRune(c)
some = true
}
}
if some {
out = append(out, w.String())
}
return out
}
// Line is the rule as ParseRule reads it back.
func (r Rule) Line() string {
out := []string{"use=" + r.Use}
if r.Pause {
out[0] = "pause=" + r.Use
}
if r.Tokens > 0 {
out = append(out, fmt.Sprintf("tokens=%d", r.Tokens))
}
if r.Images {
out = append(out, "images")
}
if r.Effort != "" {
out = append(out, "effort="+r.Effort)
}
if len(r.Agents) > 0 {
out = append(out, "agents="+strings.Join(r.Agents, ","))
}
if r.Intent != "" {
out = append(out, `intent="`+strings.ReplaceAll(r.Intent, `"`, "")+`"`)
}
if r.Compact {
out = append(out, "compact")
}
if r.Time != nil {
if r.Time.From != r.Time.To || len(r.Time.Days) == 0 {
out = append(out, "time="+r.Time.From+"-"+r.Time.To)
}
if len(r.Time.Days) > 0 {
out = append(out, "days="+strings.ReplaceAll(strings.ToLower(r.Time.DaysText()), "–", "-"))
}
}
return strings.Join(out, " ")
}