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.
This commit is contained in:
yetone committed 2026-10-10 13:51:09 +08:00
1 parent 34f2078db8
commit c98160c045
18 files changed
+654 -19

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
- Sealed tasks (#619) are checked here. A Codex subagent's task its lead's `spawn_agent` sealed goes only to a ChatGPT account, or back to the Responses provider that answered the lead (`leadProvider`, a relay of the ChatGPT backend such as Sub2API, #1109), relayed as it came even when web search is offered. A lead's turn carrying its subagent's sealed reply (`send_message`) goes back to the Responses provider that answered the lead's own conversation (`leadProvider` of the request's `conversationID`, #1237); otherwise it is refused before anyone is asked (`sealedReader`, `sealedTaskError`). Who answered is first what affinity remembers; when affinity has let it go (a day since, a restart past the 512 conversations `affinity.json` keeps, or the lead's last reply broken off, `unanswered`), the usage log's answers to the lead's thread and the request's own conversation in the last 30 days count too (`loggedAnswers`, #1367), and `leadFirst` reads the lead's key or account from them. A refused or failed call and a title answer nothing, and Codex's own sign-in is told of as the ChatGPT backend. The refusal names who sealed the task when it is known, and says how to go on with it.
- A model set for Codex's subagents (`settings.CodexSubagentModel`, the Agents page's Codex "subagent model", willz on Discord) is put on a Codex subagent's request (`x-openai-subagent` read as `kind:collab_spawn`) before this check, whatever model its lead's `spawn_agent` asked for (`codexSubagentPick` in [`codex_subagent.go`](../../internal/gateway/codex_subagent.go)): in `codexBackend` for one of Codex's own bare models, which then goes through `serveAgent`, and in `serve` for any other. Only a ChatGPT account's model can be set (`provider.SetCodexSubagentModel`), as nothing else reads the sealed task. When no ChatGPT account on in magpie serves it now, or a gateway key held to some models may not use it, the subagent goes on the model asked for. The route trace keeps both models and the reason it stayed (`Route.Subagent`), and the Routing page's `kindWhy` says it. The lead's turns, titles and every other kind are never moved.
- Rules and image inputs are handled here too.
- A pause rule (`Rule.Pause`, `pause=<model>`; John on Discord: a model of the group paused in its vendor's peak hours) leaves the model it names out of the group while it holds (`provider.PausedOut`, on `ruleClock`): the group's own rules and each nested group's on the way down to a member, a group named taking all its models, and a model named taking it wherever a group in the group has it (#625). It runs before planning, on every request rather than once a turn, so the model is not first, not a failover, and not kept by affinity, and comes back as soon as the hours end. It holds by hours, days and agents only (`cleanRules` refuses another condition), and is not a send-to rule: `Matches`, `MatchRule`, `ThenUses`, `Ruled` and the compaction rules leave it out. A send-to rule naming a paused model finds it unready and the group routes as without it. The trace keeps what was paused (`GroupRef.Paused`), and the Routing page says so. Every model paused answers 503 (`pausedError`) naming each rule. Embeddings go by the same pauses; manual groups have no rules (`Live`), so none pause there. `rules_pause_test.go`.
3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. An account past its share is held out of the candidates (`capHeld`): a usage cap's share, each window at its own where the account gives it one (`AccountWindowCaps`, willz on Discord: 50% of the five hours, 40% of the week) and at the account's cap otherwise, or 100% of a window for a Codex account set not to spend its credits (`provider.HoldCaps`, `WindowCaps.Of`, `CodexNoCredits`). The account is held while any window is past its own share, and `cappedError` and the trace name the fullest such window's share and cap. When every candidate is held and one held for its credits spends its resets by itself, `autoReset` spends a reset on it and it is tried (`planned.held`); otherwise a capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why.
4. The candidate loop calls `attempt` on each candidate in turn, through a `holdWriter`. The writer holds the reply until it is clearly passing. A candidate that fails before anything is sent rests (`failure`): out of credit for 30 minutes, out of quota until reset, rate limited until retry-after, otherwise a minute and longer on repeats. A 400, 403, 404 or 422 that says the model isn't served (`unservedWords`) and says nothing of quota, credit or a rate limit rests only that key's or account's model (`modelRefused`, keyed by `restID`, its backoff counted apart): its other models are still served first in order (#1235, SenseNova's 403 "model is not available in the current token plan"). A 400, 404, 410 or 422 that says the model is retired (`retiredWords`: OpenCode Zen's 410 `ModelDeprecated` "Model exo-free has been deprecated.", whose own model list still names it; MOMO on Discord) rests only that model the same way, is retryable so a group's next member or another provider of it is asked, and is remembered (`provider.Retire`, `retired.json` beside `providers.json`): the model leaves that provider's entries in the catalog (`buildEntries`), so /v1/models, every agent's model list and the found groups go without it, until a request on it is answered again (`Unretire` in `servedCandidate`) or a week has passed. A refresh of the vendor's list doesn't bring it back early, since that list is where it still is. A `provider/model` request for it is still sent. A subscription account that fails any other way with a window full, by magpie's latest reading of its windows, rests until that window renews. Both quota rests and window rests are held to `longestQuota` (8 days), so a reset time read wrong (a year off) doesn't bench the account for that long. The next candidate is then tried. A failed provider waits `fallbackCooldown` (1 minute) at the back. The vendor's safety filter refusing a reply rests nobody, whether it refuses the turn before any of the reply is said or once some of it has been said (`refused`, `refusedAfter`). The ChatGPT backend's 502 "response protection is unavailable" is about the request's content, not the account: the same history fails the same on each of its accounts and models. It rests nobody, isn't tried again after a moment, and isn't asked of the provider's other accounts or models; another provider left in a group is asked once (`protectionRefused`, `elsewhere`). One cause is known (#1270): a history that replays the backend's own `web_search_call` in a request that doesn't declare the `web_search` tool is refused so, whatever the account, model or turn (a plain turn, a thread's description, a compaction, Codex's own text compaction for a custom provider with `tools: []`). Every request to the ChatGPT backend with such a history therefore declares cached-only search (`{"type":"web_search","external_web_access":false}`, `provider.DeclareSearch`): an account's in `codexBody`, Codex's own sign-in relayed in `codexUpstreamOn`. It goes in the tools, or for Responses Lite (its header, `reasoning.context` `all_turns`, or an `additional_tools` item already there), which refuses a top-level `web_search`, in the `additional_tools` item. A request that offered no tools gets `tool_choice` `none` so the model can't search; one with a forced choice keeps it. A history without a search, or a request that declares search already, is relayed unchanged. siwc, API-key requests to openai.com and the legacy `/responses/compact` are left as they were.
5. The last candidate's error reaches the agent. A try that answered is recorded (`served`, `answered`) for routing, affinity and usage.
+9
View File
@@ -20,6 +20,9 @@ const ruleUsage = `usage:
a rule: a turn that matches it goes to <model>, one of the group's
(or group/<id>, a group in it), first; a model in the group at an
effort of its own is named with it (use=glm/glm-5.3-flash:high)
magpie group rule add <group> pause=<model> [time=HH:MM-HH:MM] [days=mon-fri] [agents=a,b…] [at=<n>]
a pause: while it holds, <model> is left out of the group — sent
nothing, not even when the others fail or a conversation was on it
magpie group rule rm <group> <n> remove rule n
magpie group rule mv <group> <n> <to> move rule n to place <to>
magpie group rule classifier <group> <model>
@@ -28,6 +31,8 @@ const ruleUsage = `usage:
Rules are looked at top first when you send a message (a new turn); the first that
matches puts its model first, and the group's others stay behind it if it fails.
A pause is looked at on every request and leaves its model out while it holds; its
conditions are time, days and agents only.
The agent's tool results within the turn stay with the model the turn began on.
Every condition given must hold:
tokens the request is at least this long (200000, 200k, 1m): estimated from its size, or
@@ -53,6 +58,7 @@ const ruleUsage = `usage:
magpie group rule add fast use=codex/gpt-5.6-sol:xhigh effort=high
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact
magpie group rule add cheap use=glm/glm-5.3 time=14:00-18:00 days=mon-fri
magpie group rule add cheap pause=glm/glm-5.3 time=14:00-18:00 days=mon-fri
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash intent="a quick question" classifier=groq/llama-3.1-8b-instant`
// parseTokens reads 200000, 200k, 1.5m.
@@ -167,6 +173,9 @@ func ruleCmd(args []string) error {
// ruleLine is a rule as magpie group shows it.
func ruleLine(r provider.Rule) string {
if r.Pause {
return strings.Join(r.Conditions(), " · ") + muted.Render(" → pause ") + r.Use
}
return strings.Join(r.Conditions(), " · ") + muted.Render(" → ") + r.Use
}
+13
View File
@@ -65,6 +65,19 @@ func (s *Server) retrieve(path, operation string) http.HandlerFunc {
s.record(call)
return
}
if isGroup {
// a model a pause rule holds for now is left out, as for a chat
var paused []provider.Paused
if ms, paused = provider.PausedOut(g.Live(), ms, agentOf(r), ruleClock()); len(ms) == 0 && len(paused) > 0 {
msg := pausedError(asked, paused)
call.Status, call.Error = 503, msg
writeError(w, provider.Chat, 503, msg)
s.record(call)
return
} else if len(paused) > 0 {
p, model = ms[0].Provider, ms[0].Model // not the paused one, when planning finds none
}
}
call.To = provider.Chat
// a routing group's members are tried as its routing orders them,
// each account or key of theirs too (#773), the next asked when one
+14
View File
@@ -1392,6 +1392,19 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
turnedAway()
return
}
// a model a pause rule holds for now is out of the group: not first,
// not a failover, not where the conversation was (John on Discord: a
// model paused in its vendor's peak hours)
var paused []provider.Paused
if isGroup {
ms, paused = provider.PausedOut(g, ms, agent, ruleClock())
if len(ms) == 0 && len(paused) > 0 {
call.Status, call.Error = 503, "every model paused"
writeError(w, from, 503, pausedError(call.Model, paused))
turnedAway()
return
}
}
// a Codex subagent's task its lead sealed — the lead answered by a
// ChatGPT account, the group's own or Codex's — goes only to a ChatGPT
// account, the lead's first (#619), or back to the Responses provider
@@ -1532,6 +1545,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// a routing group: every member's keys or accounts weighed together
cands, pl = s.planGroup(g, ms, from)
group = groupRef(g, ms)
group.Paused = paused
scope, mode, rotate = provider.GroupPrefix+g.ID, g.Affinity, g.Routing == provider.Rotate
leadScope = scope
if words != "" {
+15
View File
@@ -9,6 +9,7 @@ package gateway
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"slices"
"strings"
@@ -590,3 +591,17 @@ func anyImageInput(a, b *bool) *bool {
}
return nil
}
// pausedError tells an agent that every model of the group it asked for
// is paused now, by which rules and when.
func pausedError(asked string, paused []provider.Paused) string {
var bits []string
for _, p := range paused {
by := fmt.Sprintf("rule %d", p.Rule)
if p.Group != "" {
by = fmt.Sprintf("rule %d of %s", p.Rule, p.Group)
}
bits = append(bits, fmt.Sprintf("%s (%s: %s)", p.Member, by, strings.Join(p.When, " · ")))
}
return fmt.Sprintf("every model of %s is paused now by its rules: %s; change the rules on Magpie's Routing page, or ask again when they end", asked, strings.Join(bits, ", "))
}
+107
View File
@@ -0,0 +1,107 @@
package gateway
import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// peak is a vendor's peak hours, Monday to Friday.
var peak = &provider.TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}
// A rule that sends a turn to another member in a vendor's peak hours
// still leaves the vendor's model behind it: when the member sent to fails,
// the turn falls back onto it (what John on Discord asked to stop). A pause
// rule keeps it out of the group in those hours: not first, not a
// fallback, not where the conversation was, and back once they end.
func TestPauseKeepsAMemberOutInItsHours(t *testing.T) {
now := time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) // a Tuesday, in the hours
ruleClock = func() time.Time { return now }
t.Cleanup(func() { ruleClock = time.Now })
// "send to b/big" in the hours: a/small still answers when b fails
s, a, b := ruled(t, provider.Rule{Use: "b/big", Time: peak})
b.fail = 500
if code, out := postAs(t, s, "s0", chat("hello", nil, 0, "")); code != 200 || !strings.Contains(out, "from ka") {
t.Fatalf("send-to rule: %d %s", code, out)
}
// pause a/small in the hours
s, a, b = ruled(t, provider.Rule{Use: "a/small", Pause: true, Time: peak})
// before them, a/small answers a conversation first (the group's order)
now = time.Date(2026, 9, 29, 8, 50, 0, 0, time.Local)
if out, r := postOK(t, s, "s1", chat("hello", nil, 0, "")); !strings.Contains(out, "from ka") || len(r.Group.Paused) != 0 {
t.Fatalf("before the hours: %s %+v", out, r.Group)
}
// in them, the same conversation's next tool round goes to b/big: the
// pause moves it, though a/small answered it last
now = time.Date(2026, 9, 29, 9, 1, 0, 0, time.Local)
out, r := postOK(t, s, "s1", chat("hello", nil, 1, ""))
if !strings.Contains(out, "from kb") {
t.Fatalf("in the hours, mid-turn: %s", out)
}
if p := r.Group.Paused; len(p) != 1 || p[0].Member != "a/small" || p[0].Rule != 1 || strings.Join(p[0].When, " · ") != "time 09:00–18:00 Mon–Fri" {
t.Fatalf("trace: %+v", r.Group.Paused)
}
if strings.Join(r.Group.Members, ",") != "b/big" {
t.Fatalf("members: %v", r.Group.Members)
}
// b/big failing in them: a/small is not asked
b.fail = 500
before := a.n()
if code, out := postAs(t, s, "s2", chat("hi", nil, 0, "")); code == 200 || strings.Contains(out, "from ka") {
t.Fatalf("paused member answered as a fallback: %d %s", code, out)
}
if a.n() != before {
t.Fatalf("a/small asked %d times while paused", a.n()-before)
}
// after them, a/small is back
b.fail = 0
now = time.Date(2026, 9, 29, 18, 0, 0, 0, time.Local)
if out, _ := postOK(t, s, "s3", chat("hi", nil, 0, "")); !strings.Contains(out, "from ka") {
t.Fatalf("after the hours: %s", out)
}
// and on a Saturday's peak hours, as they hold on weekdays only
now = time.Date(2026, 10, 3, 10, 0, 0, 0, time.Local)
if out, _ := postOK(t, s, "s4", chat("hi", nil, 0, "")); !strings.Contains(out, "from ka") {
t.Fatalf("saturday: %s", out)
}
}
// Every model of a group paused: the agent is told so, and nobody is asked.
func TestEveryMemberPaused(t *testing.T) {
ruleClock = func() time.Time { return time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) }
t.Cleanup(func() { ruleClock = time.Now })
s, a, b := ruled(t, provider.Rule{Use: "a/small", Pause: true, Time: peak}, provider.Rule{Use: "b/big", Pause: true, Time: peak})
code, out := postAs(t, s, "s", chat("hello", nil, 0, ""))
if code != 503 || !strings.Contains(out, "paused now") || !strings.Contains(out, "a/small (rule 1: time 09:00–18:00 Mon–Fri)") || !strings.Contains(out, "b/big (rule 2") {
t.Fatalf("%d %s", code, out)
}
if a.n()+b.n() != 0 {
t.Fatalf("asked a %d b %d", a.n(), b.n())
}
// embeddings go by the same rules
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/embeddings", strings.NewReader(`{"model":"group/r","input":"x"}`)))
if rec.Code != 503 || !strings.Contains(rec.Body.String(), "paused now") {
t.Fatalf("embeddings: %d %s", rec.Code, rec.Body.String())
}
}
// A send-to rule naming the paused member doesn't bring it back: the
// group routes as it would were the member not ready.
func TestPauseOutranksASendToRule(t *testing.T) {
ruleClock = func() time.Time { return time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) }
t.Cleanup(func() { ruleClock = time.Now })
s, a, _ := ruled(t, provider.Rule{Use: "b/big", Pause: true, Time: peak}, provider.Rule{Use: "b/big", Tokens: 1})
out, r := postOK(t, s, "s", chat("hello", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule == nil || r.Rule.N != 2 || !r.Rule.Unready {
t.Fatalf("%s %+v", out, r.Rule)
}
if a.n() != 1 {
t.Fatalf("a asked %d", a.n())
}
}
+3
View File
@@ -122,6 +122,9 @@ type GroupRef struct {
Via []string `json:"via,omitempty"`
// Fast: those of Members sent in their vendor's fast mode
Fast []string `json:"fast,omitempty"`
// Paused: the models a pause rule left out of the request, so not in
// Members (provider.PausedOut)
Paused []provider.Paused `json:"paused,omitempty"`
}
// SubGroup is a routing group in the group a request asked for.
+28
View File
@@ -2827,6 +2827,13 @@ const I18N = {
"1 rule": "1 条规则",
"{n} rules": "{n} 条规则",
"Rule {n} needs a condition": "第 {n} 条规则还没有条件",
"pause": "暂停",
"goes first; the others stay behind it": "先发给它;其余的在它后面备用",
"left out of the group: not tried, not a fallback": "移出分组:不会被调用,也不做备用",
"left out of the group then: not tried, not a fallback": "这段时间移出分组:不会被调用,也不做备用",
"Rule {n}: a pause needs hours, days or agents": "第 {n} 条规则:暂停需要设时段、星期或 Agent",
"{model} is paused by rule {n} ({when}), so it is left out until that ends.": "{model} 被规则 {n}({when})暂停,在此之前不会被调用。",
"{model} is paused by rule {n} of {group} ({when}), so it is left out until that ends.": "{model} 被 {group} 的规则 {n}({when})暂停,在此之前不会被调用。",
"asks for": "意图是",
"asks for “{intent}”": "意图是“{intent}”",
"what it asks for, e.g. writing tests": "用户想做什么,例如:写测试",
@@ -6757,6 +6764,13 @@ const I18N = {
"1 rule": "1 條規則",
"{n} rules": "{n} 條規則",
"Rule {n} needs a condition": "第 {n} 條規則還沒有條件",
"pause": "暫停",
"goes first; the others stay behind it": "先發給它;其餘的在它後面備用",
"left out of the group: not tried, not a fallback": "移出群組:不會被呼叫,也不做備用",
"left out of the group then: not tried, not a fallback": "這段時間移出群組:不會被呼叫,也不做備用",
"Rule {n}: a pause needs hours, days or agents": "第 {n} 條規則:暫停需要設時段、星期或 Agent",
"{model} is paused by rule {n} ({when}), so it is left out until that ends.": "{model} 被規則 {n}({when})暫停,在此之前不會被呼叫。",
"{model} is paused by rule {n} of {group} ({when}), so it is left out until that ends.": "{model} 被 {group} 的規則 {n}({when})暫停,在此之前不會被呼叫。",
"asks for": "意圖是",
"asks for “{intent}”": "意圖是「{intent}」",
"what it asks for, e.g. writing tests": "使用者想做什麼,例如:寫測試",
@@ -10664,6 +10678,13 @@ const I18N = {
"1 rule": "1 件のルール",
"{n} rules": "{n} 件のルール",
"Rule {n} needs a condition": "ルール {n} に条件がありません",
"pause": "一時停止",
"goes first; the others stay behind it": "最初に送信し、ほかはその後ろで控える",
"left out of the group: not tried, not a fallback": "グループから外す:使われず、フォールバックにもならない",
"left out of the group then: not tried, not a fallback": "この間はグループから外す:使われず、フォールバックにもならない",
"Rule {n}: a pause needs hours, days or agents": "ルール {n}:一時停止には時間帯、曜日、またはエージェントが必要です",
"{model} is paused by rule {n} ({when}), so it is left out until that ends.": "{model} はルール {n}({when})で一時停止中のため、終わるまで使われません。",
"{model} is paused by rule {n} of {group} ({when}), so it is left out until that ends.": "{model} は {group} のルール {n}({when})で一時停止中のため、終わるまで使われません。",
"asks for": "インテント",
"asks for “{intent}”": "インテントが「{intent}」",
"what it asks for, e.g. writing tests": "何をしたいか(例:テストを書く)",
@@ -14588,6 +14609,13 @@ const I18N = {
"1 rule": "1 Regel",
"{n} rules": "{n} Regeln",
"Rule {n} needs a condition": "Regel {n} braucht eine Bedingung",
"pause": "pausieren",
"goes first; the others stay behind it": "kommt zuerst; die anderen bleiben dahinter",
"left out of the group: not tried, not a fallback": "aus der Gruppe genommen: nicht versucht, kein Ausweichmodell",
"left out of the group then: not tried, not a fallback": "in dieser Zeit aus der Gruppe genommen: nicht versucht, kein Ausweichmodell",
"Rule {n}: a pause needs hours, days or agents": "Regel {n}: eine Pause braucht Uhrzeiten, Tage oder Agenten",
"{model} is paused by rule {n} ({when}), so it is left out until that ends.": "{model} ist durch Regel {n} ({when}) pausiert und wird bis dahin ausgelassen.",
"{model} is paused by rule {n} of {group} ({when}), so it is left out until that ends.": "{model} ist durch Regel {n} von {group} ({when}) pausiert und wird bis dahin ausgelassen.",
"asks for": "verlangt",
"asks for “{intent}”": "verlangt „{intent}“",
"what it asks for, e.g. writing tests": "was verlangt wird, z. B. Tests schreiben",
+35 -7
View File
@@ -515,6 +515,14 @@
}
const rule = ruleWhy(r, false);
if (rule) out.push(rule);
// a member a pause rule holds for now is out of the group: not tried,
// not a fallback
for (const p of r.group?.paused || []) {
const when = (p.when || []).map(condText).join(", ");
out.push(p.group
? t("{model} is paused by rule {n} of {group} ({when}), so it is left out until that ends.", { model: p.member, n: p.rule, group: r.group?.subs?.find((s) => s.id === p.group)?.name || p.group, when })
: t("{model} is paused by rule {n} ({when}), so it is left out until that ends.", { model: p.member, n: p.rule, when }));
}
const smart = (x) => !x.routing && (x.kind === "account" || x.known);
const someKnown = r.order.some((x) => x.known);
for (const x of r.order.slice(1)) {
@@ -3306,7 +3314,7 @@
if (g.firstToken > 0 && !manual) tags.append(el("span", "tag", t("Next after {n} without a first token", { n: took(g.firstToken * 1000) })));
if (g.rules?.length) {
const r = el("span", "tag" + (manual ? " idle" : ""), t(g.rules.length === 1 ? "1 rule" : "{n} rules", { n: g.rules.length }));
r.title = (manual ? t("The rules wait while you pick the model by hand.") + "\n" : "") + g.rules.map((x, i) => `${i + 1}. ${ruleText(x)} → ${memberLabel(g, x.use)}`).join("\n");
r.title = (manual ? t("The rules wait while you pick the model by hand.") + "\n" : "") + g.rules.map((x, i) => `${i + 1}. ${ruleText(x)} → ${x.pause ? t("pause") + " " : ""}${memberLabel(g, x.use)}`).join("\n");
tags.append(r);
}
if (g.disabled) tags.append(el("span", "tag idle", t("switched off")));
@@ -3952,6 +3960,10 @@
drawTime(); warn();
});
drawTime();
// a pause holds by hours, days and agents only: the others are
// about a request, and a pause keeps the model out of every one
const byRequest = [tk, im, ef, it, cp];
for (const c of byRequest) c.hidden = !!r.pause;
when.append(tk, im, ef, ag, it, cp, tm, dayBtn);
// the member it sends to
const use = el("div", "rt-use");
@@ -3963,16 +3975,30 @@
const hint = el("span", "rt-rwarn");
const warn = () => {
const m = infoOf(r.use), bits = [];
if (r.images && m && m.ready && !m.images) bits.push(t("it doesn't take images"));
if (r.tokens && m?.context && m.context < r.tokens) bits.push(t("it takes {n} tokens", { n: m.context.toLocaleString() }));
if (r.pause) bits.push(t("left out of the group then: not tried, not a fallback"));
if (!r.pause && r.images && m && m.ready && !m.images) bits.push(t("it doesn't take images"));
if (!r.pause && r.tokens && m?.context && m.context < r.tokens) bits.push(t("it takes {n} tokens", { n: m.context.toLocaleString() }));
// a summary longer than it takes goes by the next rule, or the group
if (r.compact && m?.context && d.members.some((id) => (infoOf(id)?.context || 0) > m.context)) bits.push(t("longer conversations skip it: it takes {n} tokens", { n: m.context.toLocaleString() }));
if (!r.pause && r.compact && m?.context && d.members.some((id) => (infoOf(id)?.context || 0) > m.context)) bits.push(t("longer conversations skip it: it takes {n} tokens", { n: m.context.toLocaleString() }));
const from = clockOf(r.time?.from), to = clockOf(r.time?.to);
if (from && to && to < from) bits.push(t("runs past midnight, into the next day"));
hint.textContent = bits.join(" · ");
};
warn();
use.append(el("span", "w", t("send to")), ub, hint);
// what it does: send the turn there first, or keep the model out
// of the group while the rule holds (John on Discord: a model
// paused in its vendor's peak hours)
const act = el("button", "rt-cond rt-act" + (r.pause ? " on" : ""), t(r.pause ? "pause" : "send to"));
act.onclick = (ev) => pickFrom("action", act, ev, [
{ value: "send", label: t("send to"), note: t("goes first; the others stay behind it") },
{ value: "pause", label: t("pause"), note: t("left out of the group: not tried, not a fallback") },
], (v) => {
r.pause = v === "pause";
// what a pause doesn't take is cleared, not kept out of sight
if (r.pause) Object.assign(r, { tokens: 0, images: false, effort: "", intent: "", compact: false });
drawRules();
}, r.pause ? "pause" : "send");
use.append(act, ub, hint);
const ctl = el("span", "ctl");
if (i) { const up = el("button", "text", t("Up")); up.onclick = () => { d.rules.splice(i - 1, 0, d.rules.splice(i, 1)[0]); drawRules(); }; ctl.append(up); }
const rm = el("button", "text", t("Remove"));
@@ -3986,7 +4012,7 @@
focusIntent = null;
};
let focusIntent = null;
const newRule = () => ({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false, time: null });
const newRule = () => ({ use: d.members[d.members.length - 1], pause: false, tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false, time: null });
rAdd.onclick = () => { d.rules.push(newRule()); drawRules(); };
const addIntentRule = () => {
asking = newRule();
@@ -4132,8 +4158,10 @@
? t("Rule {n}: its hours are the whole day — pick days, or other hours", { n: badTime + 1 })
: t("Rule {n}: the hours are two times like 09:00 and 18:00", { n: badTime + 1 }), "warn");
}
const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact && !r.time);
const bare = d.rules.findIndex((r) => !r.pause && !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact && !r.time);
if (bare >= 0) return status(t("Rule {n} needs a condition", { n: bare + 1 }), "warn");
const bareP = d.rules.findIndex((r) => r.pause && !r.time && !r.agents.length);
if (bareP >= 0) return status(t("Rule {n}: a pause needs hours, days or agents", { n: bareP + 1 }), "warn");
if (d.rules.some((r) => r.intent) && !d.classifier) return status(t("Choose the model that tells which intent a message is"), "warn");
if (d.effort === "auto" && !d.classifier) return status(t("Choose the model that rates how hard a turn is"), "warn");
if (own && !d.levels.length) return status(t("Pick a level to offer, or leave them to its models"), "warn");
+194
View File
@@ -0,0 +1,194 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A routing group's rule can pause one of its models at some hours (John on
// Discord: pause a model of the group in its vendor's peak hours). A pause
// keeps the model out of the group then: not first, not a fallback. The
// group's rule reads as "→ pause <model>"; the trace tells the model was
// paused, by which rule and when; in the editor the rule's action is picked
// from the app menu, a pause shows only the conditions it holds by (hours,
// days, agents), isn't saved without one, and is saved as pause:true. In
// every language the page has, at a narrow width with nothing run past
// the side.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = [
{ id: "a/glm", name: "glm", providerName: "A", icon: "generic", context: 200000, ready: true },
{ id: "b/other", name: "other", providerName: "B", icon: "generic", context: 200000, ready: true },
];
const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri"];
const groups = () => ({
models,
groups: [{ id: "main", name: "Main", members: ["a/glm", "b/other"], routing: "order", ready: true,
memberInfo: [{ id: "a/glm", ready: true, context: 200000 }, { id: "b/other", ready: true, context: 200000 }],
rules: [{ use: "a/glm", pause: true, time: { from: "14:00", to: "18:00", days: WEEKDAYS } }] }],
pools: [],
});
const now = Date.now();
const route = {
id: 7, seq: 7, time: new Date(now - 2000).toISOString(), agent: "claude", model: "main", provider: "b", done: true, status: 200, ms: 900, tokens: 9000,
group: { id: "main", name: "Main", members: ["b/other"], paused: [{ member: "a/glm", rule: 1, use: "a/glm", when: ["time 14:00–18:00 Mon–Fri"] }] },
order: [{ id: "b", provider: "b", name: "B", kind: "key", model: "other", known: true }],
tries: [{ id: "b", model: "other", start: new Date(now - 2000).toISOString(), done: true, ms: 800, status: 200 }],
};
const words = {
en: { aside: "a/glm is paused by rule 1 (14:00–18:00 Mon–Fri), so it is left out until that ends.", title: "1. 14:00–18:00 Mon–Fri → pause A · glm", pause: "pause", send: "send to", edit: "Edit", add: "Add a rule", save: "Save", bare: "Rule 2: a pause needs hours, days or agents", warn: "left out of the group then: not tried, not a fallback" },
zh: { aside: "a/glm 被规则 1(周一–周五 14:00–18:00)暂停,在此之前不会被调用。", title: "1. 周一–周五 14:00–18:00 → 暂停 A · glm", pause: "暂停", send: "先发给", edit: "编辑", add: "添加规则", save: "保存", bare: "第 2 条规则:暂停需要设时段、星期或 Agent", warn: "这段时间移出分组:不会被调用,也不做备用" },
"zh-TW": { pause: "暫停", send: "先發給", edit: "編輯", add: "新增規則", save: "儲存" },
ja: { pause: "一時停止", send: "送信先" },
de: { pause: "pausieren", send: "senden an" },
};
function serve(lang, posts) {
const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
let first = true;
return async (r) => {
const url = new URL(r.request().url());
const json = (data) => r.fulfill({ json: data });
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
const routes = first ? [route] : [];
first = false;
return json({ mine: true, now: new Date().toISOString(), seq: 7, totals: { requests: 1, rerouted: 0, errors: 0 }, routes });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/groups") return json(groups());
if (url.pathname.startsWith("/api/groups/")) {
posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") });
return json(groups());
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await r.fulfill({ body: await fs.readFile(file), contentType });
};
}
const EDIT = { en: "Edit", zh: "编辑", "zh-TW": "編輯", ja: "編集", de: "Bearbeiten" };
async function open(engine, lang, width, t) {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width, height: 1400 } })).newPage();
page.setDefaultTimeout(5000);
const errors = [], posts = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=routing");
const group = page.locator(".rt-group").first();
await group.waitFor();
return { page, group, errors, posts };
}
// a click as the reader's: wheeled into the middle of the view first (a
// click never moves the page, so Playwright's own scroll is put back), then
// the mouse on it once it holds still
async function press(page, x) {
const d = await x.evaluate((e) => { const v = document.querySelector("#view-routing");
return Math.round(e.getBoundingClientRect().top - v.getBoundingClientRect().top - v.clientHeight / 2); });
if (Math.abs(d) > 100) {
await page.mouse.move(10, 300);
await page.mouse.wheel(0, d);
await page.waitForTimeout(400);
}
await page.waitForTimeout(300); // the page settled (WebKit's folds)
const b = await x.boundingBox();
await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2);
}
// nothing in the rules runs past the editor's side
const overrun = (page) => page.evaluate(() => {
const ed = document.querySelector(".rt-gedit").getBoundingClientRect();
return [...document.querySelectorAll(".rt-rule, .rt-rule *")].filter((e) => e.offsetParent && e.getBoundingClientRect().right > ed.right + 1).map((e) => e.className || e.tagName);
});
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: a rule that pauses a model at some hours`, async (t) => {
const { page, group, errors, posts } = await open(engine, lang, 1100, t);
// the trace: the model paused, by which rule and when
await page.waitForFunction((s) => document.documentElement.innerText.includes(s), w.aside);
// the group's rule, in words
const title = await group.evaluate((g) => [...g.querySelectorAll("[title]")].map((e) => e.title).join("\n"));
assert(title.includes(w.title), `the rule reads as a pause: ${title}`);
// the editor: the pause rule shows its action, and only hours, days, agents
await press(page, group.locator("button", { hasText: w.edit }));
const ed = page.locator(".rt-gedit");
const rows = ed.locator(".rt-rule");
await rows.first().waitFor();
const act1 = rows.first().locator(".rt-act");
assert.equal(await act1.textContent(), w.pause);
assert.equal(await rows.first().locator(".rt-cond.tk").isVisible(), false, "no tokens on a pause");
assert.equal(await rows.first().locator(".rt-cond.in").isVisible(), false, "no intent on a pause");
assert.equal(await rows.first().locator(".rt-cond.tm").getAttribute("class"), "rt-cond tm on");
assert.equal(await rows.first().locator(".rt-rwarn").textContent(), w.warn);
// a second rule: a send-to rule turned into a pause from the app menu
const addBtn = ed.locator("button", { hasText: w.add });
await press(page, addBtn);
const row2 = rows.nth(1);
const act2 = row2.locator(".rt-act");
assert.equal(await act2.textContent(), w.send);
// a condition set before it turns to a pause is cleared, not kept out of sight
await row2.locator(".rt-cond.tk input").fill("50000");
assert.equal(await row2.locator(".rt-cond.tk").isVisible(), true);
await press(page, act2);
assert.equal(await page.locator("select").count(), 0, "the app menu, not a native select");
await page.locator("#list li").filter({ hasText: w.pause }).first().click();
const row2b = rows.nth(1);
assert.equal(await row2b.locator(".rt-act").textContent(), w.pause);
assert.equal(await row2b.locator(".rt-cond.tk").isVisible(), false);
assert.equal(await row2b.locator(".rt-cond.tm").isVisible(), true);
// not saved without hours, days or agents
const saveBtn = ed.locator("button.primary", { hasText: w.save });
await press(page, saveBtn);
await page.waitForFunction(() => document.querySelector("#status").textContent).catch(() => {});
assert.equal(await page.locator("#status").textContent(), w.bare, JSON.stringify(posts));
assert.equal(posts.filter((p) => p.path === "/api/groups/save").length, 0);
// its hours typed: saved as pause:true, the tokens it had gone
const tm2 = row2b.locator(".rt-cond.tm");
await tm2.locator("input").nth(0).fill("09:00");
await tm2.locator("input").nth(1).fill("12:00");
await tm2.locator("input").nth(1).blur();
await press(page, saveBtn);
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
const save = posts.find((p) => p.path === "/api/groups/save");
assert(save, `saved: ${JSON.stringify(posts)}`);
assert.deepEqual(save.body.rules.map((r) => [r.use, !!r.pause, r.tokens || 0, r.time]), [
["a/glm", true, 0, { from: "14:00", to: "18:00", days: WEEKDAYS }],
["b/other", true, 0, { from: "09:00", to: "12:00", days: [] }],
]);
assert.deepEqual(errors, []);
});
}
// narrow, in every language: the action reads in it, nothing runs past
for (const lang of Object.keys(words)) {
test(`${engine} ${lang}: a pause rule at a narrow width`, async (t) => {
const { page, group, errors } = await open(engine, lang, 420, t);
await press(page, group.locator("button", { hasText: EDIT[lang] }));
const row = page.locator(".rt-gedit .rt-rule").first();
await row.waitFor();
assert.equal(await row.locator(".rt-act").textContent(), words[lang].pause);
await press(page, row.locator(".rt-act"));
const li = page.locator("#list li");
assert.equal(await li.count(), 2);
assert(await li.filter({ hasText: words[lang].send }).count() >= 1, "send to offered");
await page.keyboard.press("Escape");
assert.deepEqual(await overrun(page), []);
assert.deepEqual(errors, []);
});
}
}
+5 -2
View File
@@ -58,8 +58,11 @@ const (
const EffortAuto = "auto"
// Ruled reports whether the group decides anything as a user's turn
// begins: a rule to put a member first, or the turn's effort.
func (g Group) Ruled() bool { return len(g.Rules) > 0 || g.Effort == EffortAuto }
// begins: a rule to put a member first, or the turn's effort. A pause
// rule decides nothing then (PausedOut is looked at on every request).
func (g Group) Ruled() bool {
return slices.ContainsFunc(g.Rules, func(r Rule) bool { return !r.Pause }) || g.Effort == EffortAuto
}
// Manual is a group's routing when the user picks which member it uses,
// as CC Switch has one provider on at a time (#317): every request goes to
+86 -3
View File
@@ -15,6 +15,13 @@ package provider
// turn is never moved to another model halfway through (the gateway keeps
// that; see gateway/rules.go). The member a rule names goes first; the
// group's other members stay behind it for failover.
//
// A pause rule (Rule.Pause) sends nothing first: while it holds, the
// member it names is out of the group altogether — not first, not a
// failover, not where a conversation was — as a member switched off is
// (Group.Off), but only at its hours, on its days or for its agents. It is
// looked at on every request, not as a turn begins: a paused model is
// paid nothing in its hours, so a turn on it moves when they begin.
import (
"fmt"
@@ -54,6 +61,11 @@ type Rule struct {
// Time: the turn begins within these hours of the day, local time — a
// vendor's peak-price hours sent to another, say.
Time *TimeWindow `json:"time,omitempty"`
// Pause: rather than sending to Use first, the rule leaves Use out of
// the group while it holds (John on Discord: a model paused in a
// vendor's peak hours). Only Time and Agents can be its conditions,
// what is known of every request before it is read.
Pause bool `json:"pause,omitempty"`
}
// TimeWindow is hours of the day, local time: From until To, "HH:MM" each,
@@ -266,6 +278,8 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) {
return nil, fmt.Errorf("rule %d: an intent is at most %d characters", n, MaxIntent)
case len(r.Conditions()) == 0:
return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents, intent, compacting or time)", n)
case r.Pause && (r.Tokens > 0 || r.Images || r.Effort != "" || r.Intent != "" || r.Compact):
return nil, fmt.Errorf("rule %d: a pause holds by hours, days or agents only", n)
}
if len(r.Agents) == 0 {
r.Agents = nil
@@ -292,7 +306,8 @@ type RuleRequest struct {
At time.Time
}
// Matches reports whether the request is one the rule is for.
// Matches reports whether the request is one the rule sends to its member
// first; a pause rule sends none (see Pauses).
func (r Rule) Matches(q RuleRequest) bool {
if r.Intent != "" && !strings.EqualFold(r.Intent, q.Intent) {
return false
@@ -303,6 +318,9 @@ func (r Rule) Matches(q RuleRequest) bool {
// MatchesBesidesIntent is Matches but for the rule's intent: whether the
// classifier's answer is all it waits on.
func (r Rule) MatchesBesidesIntent(q RuleRequest) bool {
if r.Pause {
return false
}
if r.Tokens > 0 && q.Tokens < r.Tokens {
return false
}
@@ -410,11 +428,11 @@ func ruledEntry(e *Entry, g Group, ms []Member, entries []Entry) {
sees := func(x Entry) bool { return x.Images && (x.ImageInput == nil || *x.ImageInput) }
for i, r := range g.Rules {
x, ok := of(r.Use)
if !ok {
if !ok || r.Pause {
continue
}
if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && !r.Compact && r.Time == nil && sees(x) && !e.Images &&
!slices.ContainsFunc(g.Rules[:i], func(b Rule) bool { y, ok := of(b.Use); return !ok || !sees(y) }) {
!slices.ContainsFunc(g.Rules[:i], func(b Rule) bool { y, ok := of(b.Use); return !b.Pause && (!ok || !sees(y)) }) {
e.Images, e.ImageInput = true, x.ImageInput
}
if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || r.Compact || r.Time != nil || x.Context <= e.Context {
@@ -433,6 +451,9 @@ func ruledEntry(e *Entry, g Group, ms []Member, entries []Entry) {
}
for _, before := range g.Rules[:i] {
// one not ready now leaves what it matches to the group's order
if before.Pause {
continue
}
if y, ok := of(before.Use); !ok {
fits = 0
} else if y.Context < fits {
@@ -444,3 +465,65 @@ func ruledEntry(e *Entry, g Group, ms []Member, entries []Entry) {
}
}
}
// Pauses reports whether a pause rule holds for a request from agent at
// the time given.
func (r Rule) Pauses(agent string, at time.Time) bool {
if !r.Pause || r.Time == nil && len(r.Agents) == 0 {
return false
}
if r.Time != nil && !r.Time.Holds(at) {
return false
}
return len(r.Agents) == 0 || slices.Contains(r.Agents, strings.ToLower(agent))
}
// Paused is a model a pause rule left out of a request's group.
type Paused struct {
Member string `json:"member"` // "provider/model[:effort]"
Group string `json:"group,omitempty"` // the group in the group whose rule it is; "" the group's own
Rule int `json:"rule"` // the rule, from 1
Use string `json:"use"` // the member the rule names: the model, or a group in the group
When []string `json:"when"`
}
// PausedOut leaves out of ms the models a pause rule holds for now: the
// group's own, or those of a group in it on the way to them. A rule naming
// a group in the group pauses every model of it.
func PausedOut(g Group, ms []Member, agent string, at time.Time) (kept []Member, paused []Paused) {
if !slices.ContainsFunc(g.Rules, func(r Rule) bool { return r.Pause }) &&
!slices.ContainsFunc(ms, func(m Member) bool {
return slices.ContainsFunc(m.Via, func(v Group) bool { return slices.ContainsFunc(v.Rules, func(r Rule) bool { return r.Pause }) })
}) {
return ms, nil
}
for _, m := range ms {
var hit *Paused
for d := 0; d <= len(m.Via) && hit == nil; d++ {
by, in := g, ""
if d > 0 {
by, in = m.Via[d-1], m.Via[d-1].ID
}
for i, r := range by.Rules {
if d < len(m.Path) && pathNames(m.Path[d:], r.Use) && r.Pauses(agent, at) {
hit = &Paused{Member: WithMemberEffort(m.Provider.ID+"/"+m.Model, m.Effort), Group: in, Rule: i + 1, Use: r.Use, When: r.Conditions()}
break
}
}
}
if hit != nil {
paused = append(paused, *hit)
continue
}
kept = append(kept, m)
}
return kept, paused
}
// pathNames reports whether a member's way down from a group (its id
// there first, its model last) is what that group's rule names: the
// member itself, or the model when a group in the group, ahead of it, has
// it too (#625).
func pathNames(path []string, id string) bool {
return path[0] == id || !strings.HasPrefix(id, GroupPrefix) && len(path) > 1 && path[len(path)-1] == id
}
+110
View File
@@ -0,0 +1,110 @@
package provider
import (
"encoding/json"
"strings"
"testing"
)
// A pause rule holds by its hours, days or agents, and only those; it puts
// nobody first, and a group of pause rules alone decides nothing as a turn
// begins.
func TestPauseRuleCleanAndMatch(t *testing.T) {
peak := &TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}
rules, err := cleanRules([]Rule{{Use: "a/x", Pause: true, Time: peak}, {Use: "b/y", Pause: true, Agents: []string{"Codex"}}}, []string{"a/x", "b/y"})
if err != nil {
t.Fatal(err)
}
for _, bad := range []Rule{
{Use: "a/x", Pause: true, Tokens: 100},
{Use: "a/x", Pause: true, Images: true},
{Use: "a/x", Pause: true, Intent: "tests"},
{Use: "a/x", Pause: true, Compact: true},
{Use: "a/x", Pause: true, Effort: "high"},
{Use: "a/x", Pause: true},
} {
if _, err := cleanRules([]Rule{bad}, []string{"a/x"}); err == nil {
t.Errorf("%+v saved", bad)
}
}
q := RuleRequest{Agent: "codex", At: at(2, 10, 0)}
if MatchRule(rules, q) != -1 || len(ThenUses(rules, q, 0, "")) != 0 {
t.Fatal("a pause rule sent a request to its model")
}
if (Group{Rules: rules}).Ruled() || !(Group{Rules: append(rules, Rule{Use: "a/x", Tokens: 1})}).Ruled() {
t.Fatal("Ruled")
}
for _, c := range []struct {
r Rule
agent string
hour int
want bool
}{
{rules[0], "claude", 10, true},
{rules[0], "claude", 19, false},
{rules[1], "codex", 19, true},
{rules[1], "claude", 10, false},
{Rule{Use: "a/x", Time: peak}, "claude", 10, false}, // a send-to rule pauses nothing
} {
if got := c.r.Pauses(c.agent, at(2, c.hour, 0)); got != c.want {
t.Errorf("%+v %s %d: %v", c.r, c.agent, c.hour, got)
}
}
// saved as "pause": true, and back
b, _ := json.Marshal(rules[0])
if !strings.Contains(string(b), `"pause":true`) {
t.Fatal(string(b))
}
}
// Typed: pause=<model> with hours, days or agents, read back the same.
func TestParsePauseRule(t *testing.T) {
g := Group{ID: "g", Members: []string{"zhipu/glm-5.3", "kimi/k3"}}
r, _, _, err := ParseRule(g, RuleWords("pause=glm-5.3 time=14:00-18:00 days=mon-fri"))
if err != nil || !r.Pause || r.Use != "zhipu/glm-5.3" || r.Time == nil {
t.Fatalf("%+v %v", r, err)
}
r.Time.clean()
if got := r.Line(); got != "pause=zhipu/glm-5.3 time=14:00-18:00 days=mon-fri" {
t.Fatal(got)
}
for _, bad := range []string{"pause=glm-5.3", "pause=glm-5.3 tokens=200k time=09:00-10:00", "pause=glm-5.3 images agents=codex"} {
if _, _, _, err := ParseRule(g, RuleWords(bad)); err == nil {
t.Errorf("%s parsed", bad)
}
}
}
// A pause leaves out the models it holds for: the group's own, every model
// of a group in it it names, or the model a group in the group pauses.
func TestPausedOut(t *testing.T) {
peak := &TimeWindow{From: "09:00", To: "18:00"}
sub := Group{ID: "cheap", Members: []string{"c/m", "d/m"}, Rules: []Rule{{Use: "d/m", Pause: true, Time: peak}}}
ms := []Member{
{ID: "a/x", Path: []string{"a/x"}, Provider: Provider{ID: "a"}, Model: "x"},
{ID: "group/cheap", Path: []string{"group/cheap", "c/m"}, Via: []Group{sub}, Provider: Provider{ID: "c"}, Model: "m"},
{ID: "group/cheap", Path: []string{"group/cheap", "d/m"}, Via: []Group{sub}, Provider: Provider{ID: "d"}, Model: "m"},
{ID: "b/y", Path: []string{"b/y"}, Provider: Provider{ID: "b"}, Model: "y", Effort: "high"},
}
names := func(ms []Member) (out []string) {
for _, m := range ms {
out = append(out, m.Provider.ID+"/"+m.Model)
}
return out
}
// the group's own model, at its effort, and a group in it's own pause
g := Group{ID: "top", Rules: []Rule{{Use: "b/y", Pause: true, Time: peak}}}
kept, paused := PausedOut(g, ms, "claude", at(2, 10, 0))
if strings.Join(names(kept), ",") != "a/x,c/m" || len(paused) != 2 || paused[0].Member != "d/m" || paused[0].Group != "cheap" || paused[1].Member != "b/y:high" || paused[1].Group != "" {
t.Fatalf("%v %+v", names(kept), paused)
}
// a group in the group named: all its models
g.Rules = []Rule{{Use: "group/cheap", Pause: true, Time: peak}}
if kept, _ = PausedOut(g, ms, "claude", at(2, 10, 0)); strings.Join(names(kept), ",") != "a/x,b/y" {
t.Fatal(names(kept))
}
// outside the hours, nobody
if kept, paused = PausedOut(g, ms, "claude", at(2, 19, 0)); len(kept) != 4 || len(paused) != 0 {
t.Fatal(names(kept), paused)
}
}
+20 -4
View File
@@ -1,8 +1,9 @@
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 — by magpie
// group rule and the TUI's routing page.
// 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"
@@ -94,6 +95,12 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err
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 {
@@ -159,11 +166,17 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err
}
at = n
default:
return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, compact, time, days, classifier, at)", w)
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> is missing: one of %s", strings.Join(g.Members, ", "))
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")
@@ -234,6 +247,9 @@ func RuleWords(line string) []string {
// 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))
}
+8 -3
View File
@@ -268,7 +268,8 @@ func (m *model) openClassifier(g provider.Group) {
// ruleHint is what a rule is typed as.
const ruleHint = `use=<model>, and any of: tokens=200k · images · effort=on|low|medium|high|xhigh|max · agents=codex,claude
intent="a quick question" (the group's classifier=<model> tells it) · compact
time=09:00-18:00 (local; 22:00-08:00 runs past midnight) · days=mon-fri · at=<n> for its place`
time=09:00-18:00 (local; 22:00-08:00 runs past midnight) · days=mon-fri · at=<n> for its place
or pause=<model> time=… days=… agents=…: the model is left out of the group then, not even a fallback`
// openRule asks for a rule: a new one, or rule i (from 0) typed again.
func (m *model) openRule(g provider.Group, i int) {
@@ -625,7 +626,7 @@ func (m model) viewGroup() string {
b.WriteString(pad + " " + sText.Render(h.Pattern) + n + "\n")
}
}
b.WriteString("\n" + pad + " " + sFaint.Render("rules · as a turn begins, the first that matches sends it to its model first") + "\n")
b.WriteString("\n" + pad + " " + sFaint.Render("rules · as a turn begins, the first that matches sends it to its model first; a pause leaves its model out while it holds") + "\n")
if len(g.Rules) == 0 {
b.WriteString(pad + " " + sMuted.Render("none · n adds one") + "\n")
}
@@ -634,7 +635,11 @@ func (m model) viewGroup() string {
if len(g.Members)+i == m.gsel {
marker, use = sCursor.Render("▸ "), sNameOn.Render(r.Use)
}
b.WriteString(pad + marker + sFaint.Render(fmt.Sprintf("%d ", i+1)) + sMuted.Render(strings.Join(r.Conditions(), " · ")+" → ") + use + "\n")
arrow := " → "
if r.Pause {
arrow = " → pause "
}
b.WriteString(pad + marker + sFaint.Render(fmt.Sprintf("%d ", i+1)) + sMuted.Render(strings.Join(r.Conditions(), " · ")+arrow) + use + "\n")
}
if slices.ContainsFunc(g.Rules, func(r provider.Rule) bool { return r.Intent != "" }) {
b.WriteString("\n" + pad + " " + sFaint.Render("classifier ") + sText.Render(dash(g.Classifier)) + sFaint.Render(" · tells which intent a message is") + "\n")
+2
View File
@@ -191,6 +191,7 @@
<li>サブエージェントはそれ自体が独立した会話なので、親のエージェントはそのままに、ルールでサブエージェントだけを別の送り先に送れます。</li>
<li>インテントは、ユーザーが求めていることを数語で表したものです(「テストを書く・直す」「ちょっとした質問」など)。ターンの開始時に、グループの分類モデル(magpie にある任意のモデル。小さく高速で推論なしのものが最適です)に、一致しうるインテントのうちユーザーのメッセージがどれに当たるかを一度だけ尋ねます。送られるのはそのメッセージだけで、会話全体ではありません。分類モデルが失敗した、遅すぎた(8 秒)、または判断できなかった場合は、どのインテントも一致せず、そのターンは他のルールに従います。インテントのないルールでいずれにせよ決まる場合は問い合わせず、同じメッセージについて二度尋ねることもありません。その呼び出しは使用量に magpie 自身の呼び出しとして表示されます。何を尋ねるのか、回答がどう使われるのか、失敗したらどうなるのかの詳細は<a href="/docs/ja/intent">インテントルーティング</a>を参照してください。</li>
<li><span class="ui">compacting</span> は、エージェントが会話を圧縮するとき(Claude Code の <code>/compact</code> と自動圧縮、Codex、OpenCode、Pi、Gemini CLI、Qwen Code、Kimi Code)に一致します。これにより、会話は元のモデルのまま、要約だけをより安く速いモデルに書かせられます。圧縮は単独で判断されます。圧縮が起きたターンとそのキャッシュは元の送り先に留まります。会話がルールのモデルが受け付けられる長さを超えている場合は、次に一致するルール、またはグループの順序で決まります。</li>
<li><b>時間帯と曜日。</b>ルールは、このコンピューターの時計で、特定の時間帯や曜日だけ有効にできます(ベンダーのピーク時間など)。その間のターンを別のモデルに送るルールでは、ベンダーのモデルはフォールバックとして後ろに残ります。完全に外すには、ルールの <span class="ui">送信先</span> を <span class="ui">一時停止</span> に変えます。ルールが有効な間(時間帯、曜日、エージェント)、そのモデルはグループから外れ、先頭にもフォールバックにもならず、会話の継続でも使われません。時間帯が終われば戻ります。一時停止は時間帯、曜日、エージェントだけで判断し、グループのモデルがすべて一時停止中ならエージェントにそう伝えます。</li>
<li>ルーティングタブには、各ターンをどのルールがどこへ送ったか、分類モデルが何と答えたかが表示されます。</li>
</ul>
<h3 id="nested">グループの中のグループ</h3>
@@ -216,6 +217,7 @@ magpie group set opus-anywhere models+=openrouter/anthropic/claude-opus-5.5
magpie group rule add opus-anywhere use=openrouter/anthropic/claude-opus-5.5 tokens=150k <span class="c"># 長いターンはまずこれへ</span>
magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent=<span class="s">"ちょっとした質問"</span> classifier=groq/llama-3.1-8b-instant
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact <span class="c"># /compact の要約は安いモデルへ</span>
magpie group rule add opus-anywhere pause=copilot/claude-opus-5.5 time=14:00-18:00 days=mon-fri <span class="c"># その時間帯は外す</span>
magpie group set coder models+=group/opus-anywhere <span class="c"># グループの中のグループ</span>
magpie group rule opus-anywhere <span class="c"># ルール一覧。rule rm|mv &lt;n&gt;、rule classifier &lt;group&gt; &lt;model&gt;</span>
magpie group rm opus-anywhere <span class="c"># magpie が見つけたものは非表示に。magpie group restore &lt;id&gt; で戻す</span>
+2
View File
@@ -191,6 +191,7 @@
<li>A subagent is a conversation of its own, so a rule can send it elsewhere while its agent keeps where it is.</li>
<li>An intent is a few words for what the user asks for — “writing or fixing tests”, “a quick question”. As a turn begins, the group’s classifier — any model magpie has; a small, fast one without reasoning is best — is asked once which of the intents that could match the user’s message is. Only the message is sent, not the conversation. If it fails, is slow (8 s) or can’t say, no intent matches and the turn goes by the other rules. It isn’t asked when a rule without an intent would decide anyway, and the same message isn’t asked about twice. Its calls show in Usage as magpie’s own. Exactly what it is asked, how its answer is used and what happens when it fails: <a href="/docs/intent">Intent routing</a>.</li>
<li><span class="ui">compacting</span> matches an agent compacting its conversation — Claude Code’s <code>/compact</code> and auto-compact, Codex, OpenCode, Pi, Gemini CLI, Qwen Code, Kimi Code — so a cheaper, faster model writes the summary while the conversation stays on its own. A compaction is decided on its own: the turn it happens in, and its cache, stay where they were. When the conversation is longer than the rule’s model takes, the next rule that matches, or the group’s order, decides instead.</li>
<li><b>Hours and days.</b> A rule can hold only at some hours, on some days, by this computer’s clock — a vendor’s peak hours, say. A rule that sends those turns elsewhere still leaves the vendor’s model behind it for failover. To keep a model out entirely, turn the rule’s <span class="ui">send to</span> into <span class="ui">pause</span>: while it holds (its hours and days, its agents), that model is left out of the group — never first, never a fallback, not kept for the conversation it was in — and it is back once they end. A pause holds by hours, days and agents only; if every model of the group is paused, the agent is told so.</li>
<li>The Routing tab says which rule sent each turn where, and what the classifier said.</li>
</ul>
<h3 id="nested">Groups in groups</h3>
@@ -216,6 +217,7 @@ magpie group set opus-anywhere models+=openrouter/anthropic/claude-opus-5.5
magpie group rule add opus-anywhere use=openrouter/anthropic/claude-opus-5.5 tokens=150k <span class="c"># long turns to it first</span>
magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent=<span class="s">"a quick question"</span> classifier=groq/llama-3.1-8b-instant
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact <span class="c"># /compact summaries to a cheap model</span>
magpie group rule add opus-anywhere pause=copilot/claude-opus-5.5 time=14:00-18:00 days=mon-fri <span class="c"># left out in those hours</span>
magpie group set coder models+=group/opus-anywhere <span class="c"># a group in a group</span>
magpie group rule opus-anywhere <span class="c"># its rules; rule rm|mv &lt;n&gt;, rule classifier &lt;group&gt; &lt;model&gt;</span>
magpie group rm opus-anywhere <span class="c"># one magpie found is hidden; magpie group restore &lt;id&gt;</span>
+2
View File
@@ -191,6 +191,7 @@
<li>子代理是单独的会话,规则可以把它发到别处,主代理仍留在原处。</li>
<li>意图是用几个词描述用户想做什么,例如“写或修测试”“一个简单问题”。每一轮开始时,magpie 会问一次组里指定的意图判断模型:用户这条消息属于哪个可能命中的意图。这个模型可以是 magpie 里的任意模型,建议选小而快、不带推理的。发过去的只有这条消息,不包括整段对话。它出错、超时(8 秒)或给不出答案时,意图条件都不命中,这一轮按其他规则走。如果前面有不带意图的规则已经能决定,就不会去问;同一条消息也不会问两次。它的调用在用量里显示为 magpie 自己的调用。它具体被问了什么、回答怎么用、失败时怎么办,详见<a href="/docs/zh/intent">意图路由</a>。</li>
<li><span class="ui">压缩上下文时</span>:Agent 压缩对话上下文时命中——Claude Code 的 <code>/compact</code> 和自动压缩、Codex、OpenCode、Pi、Gemini CLI、Qwen Code、Kimi Code——让更便宜、更快的模型来写总结,对话本身仍留在原来的模型上。压缩请求单独决定:它所在的这一轮和缓存都留在原处。对话比规则里的模型能装下的还长时,改由下一条命中的规则或组本来的顺序决定。</li>
<li><b>时段和星期。</b>规则可以只在某些时段、某几天生效,按这台电脑的时钟——比如厂商的高峰期。把这段时间的对话“先发给”别的模型,厂商那个模型仍排在后面做故障转移。要把它完全拿掉,把规则里的 <span class="ui">先发给</span> 改成 <span class="ui">暂停</span>:规则生效期间(它的时段、星期和 Agent),这个模型移出分组——不排第一、不做备用、也不会因为会话粘性被留住——时段过了自动回来。暂停只按时段、星期和 Agent 判断;组里所有模型都被暂停时,会告诉 Agent。</li>
<li>路由标签页会显示每一轮是被哪条规则送到哪里的,以及意图判断模型给出的答案。</li>
</ul>
<h3 id="nested">路由组里放路由组</h3>
@@ -216,6 +217,7 @@ magpie group set opus-anywhere models+=openrouter/anthropic/claude-opus-5.5
magpie group rule add opus-anywhere use=openrouter/anthropic/claude-opus-5.5 tokens=150k <span class="c"># 长对话优先发给它</span>
magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent=<span class="s">"一个简单问题"</span> classifier=groq/llama-3.1-8b-instant
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact <span class="c"># /compact 的总结交给便宜的模型</span>
magpie group rule add opus-anywhere pause=copilot/claude-opus-5.5 time=14:00-18:00 days=mon-fri <span class="c"># 这段时间不用它</span>
magpie group set coder models+=group/opus-anywhere <span class="c"># 路由组里放路由组</span>
magpie group rule opus-anywhere <span class="c"># 查看规则;rule rm|mv &lt;n&gt;,rule classifier &lt;组&gt; &lt;模型&gt; 换判断模型</span>
magpie group rm opus-anywhere <span class="c"># 自动发现的组会被隐藏;magpie group restore &lt;id&gt; 恢复</span>