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.
281 lines
8.6 KiB
Go
281 lines
8.6 KiB
Go
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, " ")
|
||
}
|