diff --git a/internal/agent/route.go b/internal/agent/route.go
index b835016a..1392c4e8 100644
--- a/internal/agent/route.go
+++ b/internal/agent/route.go
@@ -17,10 +17,19 @@ import (
// magpieID is the provider id agents know the gateway by.
const magpieID = "magpie"
-// viaMagpie lists the catalog for a picker, one group per provider.
+// RoutingGroups is the picker's group of routing groups.
+const RoutingGroups = "Routing groups"
+
+// viaMagpie lists the catalog for a picker, one group per provider, and
+// the routing groups in one of their own.
func viaMagpie(prefix string) []Option {
var out []Option
for _, e := range provider.Catalog() {
+ if e.Group != "" {
+ out = append(out, Option{Value: prefix + e.ID, Label: e.Name, Note: "routing group · via magpie",
+ Icon: e.Provider.Icon, Group: RoutingGroups, Ref: e.ID})
+ continue
+ }
note := e.Provider.Name + " · via magpie"
if a := e.Provider.Account; a != nil {
note = a.User + " · via magpie"
@@ -61,7 +70,11 @@ func firstOf(xs []string) string {
func magpieModels() []catalog.Model {
var out []catalog.Model
for _, e := range provider.Catalog() {
- out = append(out, catalog.Model{ID: e.ID, Name: e.Name + " · " + e.Provider.Name, Provider: firstOf(e.Provider.Catalogs()), Efforts: e.Efforts})
+ by := e.Provider.Name
+ if e.Group != "" {
+ by = "routing group"
+ }
+ out = append(out, catalog.Model{ID: e.ID, Name: e.Name + " · " + by, Provider: firstOf(e.Provider.Catalogs()), Efforts: e.Efforts})
}
return out
}
diff --git a/internal/gateway/affinity.go b/internal/gateway/affinity.go
new file mode 100644
index 00000000..96483b76
--- /dev/null
+++ b/internal/gateway/affinity.go
@@ -0,0 +1,195 @@
+package gateway
+
+// Affinity: a conversation stays with the key or account that answered it,
+// so that what the vendor cached of it — the whole conversation so far, on
+// every request — is read again rather than sent afresh to someone else
+// and paid for in full. Routing still decides who goes first when nobody
+// has answered yet, and whenever the one that did is resting or all but
+// used up.
+//
+// How long it stays is the provider's or group's affinity: for the whole
+// session; within a turn only — while the agent sends tool results back,
+// until the user speaks again; never; or, by default, worked out from the
+// conversation itself: within a turn always, and across turns while what
+// the vendor said it read from its cache the last time is worth keeping
+// and not yet gone cold.
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+const (
+ // cacheWorth is how many tokens read from the vendor's cache make a
+ // conversation worth keeping where it is across turns.
+ cacheWorth = 1024
+ // cacheCold is how long a vendor keeps a prompt cached without it being
+ // read: five minutes at Anthropic and at OpenAI, the shortest there is.
+ cacheCold = 5 * time.Minute
+ // stickKeep is how long a conversation's last answerer is remembered.
+ stickKeep = 24 * time.Hour
+)
+
+// Affinity is what the trace tells of a request's conversation and whether
+// it stayed with who answered it last.
+type Affinity struct {
+ Mode string `json:"mode"` // the provider's or group's: "", session, turn, off
+ Turn int `json:"turn"` // the user's turns in the conversation so far
+ Within bool `json:"within,omitempty"` // the agent sending tool results back: mid-turn
+ Last string `json:"last,omitempty"` // who answered the conversation last
+ LastTurn int `json:"lastTurn,omitempty"`
+ At time.Time `json:"at,omitempty"` // when
+ CacheRead int `json:"cacheRead,omitempty"` // tokens that answer read from the vendor's cache
+ Kept bool `json:"kept,omitempty"` // Last was put first for it
+ // Why it was kept — "session", "turn", "cache" — or not: "off", "first"
+ // (nobody has answered it yet), "new-turn", "no-cache" (the vendor
+ // read too little from its cache to keep), "cold" (too long ago),
+ // "resting", "spent", "gone" (no longer one to route to).
+ Why string `json:"why"`
+}
+
+type stick struct {
+ rest string
+ turn int
+ at time.Time
+ cacheRead int
+}
+
+var sticks = struct {
+ sync.Mutex
+ m map[string]stick // scope|conversation → who answered it last
+}{m: map[string]stick{}}
+
+// turnOf counts the user's turns in a request's conversation, and says
+// whether it is the agent handing tool results back within one.
+func turnOf(from provider.Protocol, body []byte) (turn int, within bool) {
+ req, err := parse(from, body)
+ if err != nil {
+ return 0, false
+ }
+ for i, m := range req.Messages {
+ if m.Role != "user" {
+ continue
+ }
+ text, result := false, false
+ for _, p := range m.Parts {
+ switch p.Kind {
+ case Text, Image:
+ text = true
+ case ToolResult:
+ result = true
+ }
+ }
+ if text && !result {
+ turn++
+ }
+ if i == len(req.Messages)-1 {
+ within = result
+ }
+ }
+ return turn, within
+}
+
+// affine puts first whoever answered the conversation last, while its
+// affinity says to keep it there.
+func affine(scope, mode string, rotate bool, in http.Header, from provider.Protocol, body []byte, cs []candidate, pl planned) ([]candidate, planned, *Affinity, string) {
+ key := scope + "|" + conversationID(in, body)
+ a := &Affinity{Mode: mode}
+ a.Turn, a.Within = turnOf(from, body)
+ sticks.Lock()
+ st, had := sticks.m[key]
+ sticks.Unlock()
+ if had && time.Since(st.at) > stickKeep {
+ had = false
+ }
+ if had {
+ a.Last, a.LastTurn, a.At, a.CacheRead = st.rest, st.turn, st.at, st.cacheRead
+ }
+ at := -1
+ for i, c := range cs {
+ if had && c.rest == st.rest {
+ at = i
+ break
+ }
+ }
+ switch {
+ case mode == provider.AffinityOff:
+ a.Why = "off"
+ case !had:
+ a.Why = "first"
+ case at < 0:
+ a.Why = "gone"
+ case pl.order[at].Rest != nil:
+ a.Why = "resting"
+ case pl.order[at].Known && pl.order[at].Used >= usedShare:
+ a.Why = "spent"
+ case mode == provider.AffinitySession:
+ a.Why = "session"
+ case a.Within:
+ a.Why = "turn"
+ case mode == provider.AffinityTurn, rotate:
+ a.Why = "new-turn"
+ case st.cacheRead < cacheWorth:
+ a.Why = "no-cache"
+ case time.Since(st.at) > cacheCold:
+ a.Why = "cold"
+ default:
+ a.Why = "cache"
+ }
+ switch a.Why {
+ case "session", "turn", "cache":
+ a.Kept = true
+ if at > 0 {
+ cs = append(append([]candidate{cs[at]}, cs[:at]...), cs[at+1:]...)
+ order := append(append([]Weighed{pl.order[at]}, pl.order[:at]...), pl.order[at+1:]...)
+ pl.order = order
+ }
+ for j := range pl.order {
+ pl.order[j].Turn = false // kept, whoever's turn it was
+ }
+ case "new-turn":
+ if rotate {
+ cs, pl = after(cs, pl, at)
+ }
+ }
+ return cs, pl, a, key
+}
+
+// after puts first the one after cs[at], round from the end, that isn't
+// resting.
+func after(cs []candidate, pl planned, at int) ([]candidate, planned) {
+ for d := 1; d < len(cs); d++ {
+ i := (at + d) % len(cs)
+ if pl.order[i].Rest != nil {
+ continue
+ }
+ if i > 0 {
+ cs = append(append([]candidate{cs[i]}, cs[:i]...), cs[i+1:]...)
+ pl.order = append(append([]Weighed{pl.order[i]}, pl.order[:i]...), pl.order[i+1:]...)
+ }
+ for j := range pl.order {
+ pl.order[j].Turn = j == 0 // its turn after the last's, not by the count
+ }
+ break
+ }
+ return cs, pl
+}
+
+// answered remembers who answered a conversation, and what it read from
+// the vendor's cache doing so.
+func answered(key, rest string, turn, cacheRead int) {
+ now := time.Now()
+ sticks.Lock()
+ defer sticks.Unlock()
+ sticks.m[key] = stick{rest: rest, turn: turn, at: now, cacheRead: cacheRead}
+ if len(sticks.m) > 4096 {
+ for k, st := range sticks.m {
+ if now.Sub(st.at) > stickKeep {
+ delete(sticks.m, k)
+ }
+ }
+ }
+}
diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go
index 57c33528..17736502 100644
--- a/internal/gateway/fallback.go
+++ b/internal/gateway/fallback.go
@@ -186,6 +186,59 @@ func (s *Server) plan(p provider.Provider, model string, from provider.Protocol)
seen[fp.ID+"/"+fm] = true
out = append(out, add(fp, fm, true)...)
}
+ return restLast(out, pl)
+}
+
+// planGroup is plan for a routing group: every member's keys or accounts
+// weighed together as the group's routing says — in order, member by
+// member, each as its own provider's routing orders it; else all as one,
+// so a subscription whose allowance renews soonest goes first whichever
+// provider it is of. A member's fallbacks are not the group's.
+func (s *Server) planGroup(g provider.Group, ms []provider.Member, from provider.Protocol) ([]candidate, planned) {
+ var pl planned
+ var out []candidate
+ of := map[string]provider.Provider{} // candidate → its member's provider
+ var all []candidate
+ for _, m := range ms {
+ cs, left := perKeyOf(m.Provider, m.Model, from)
+ for _, c := range left {
+ w := weighed(c, m.Provider, weighing{}, false, from)
+ w.Unlisted = true
+ pl.left = append(pl.left, w)
+ }
+ if g.Routing == provider.Ordered {
+ cs, wg := weigh(m.Provider, cs, m.Model, from)
+ for i, c := range cs {
+ w := weighed(c, m.Provider, wg, false, from)
+ w.Turn = i == 0 && m.Provider.Routing == provider.Rotate && len(cs) > 1
+ pl.order = append(pl.order, w)
+ }
+ out = append(out, cs...)
+ continue
+ }
+ for _, c := range cs {
+ of[c.rest+"/"+c.model] = m.Provider
+ }
+ all = append(all, cs...)
+ }
+ if g.Routing != provider.Ordered {
+ cs, wg := weigh(provider.Provider{ID: provider.GroupPrefix + g.ID, Routing: g.Routing}, all, "", from)
+ for i, c := range cs {
+ w := weighed(c, of[c.rest+"/"+c.model], wg, false, from)
+ w.Routing = g.Routing
+ w.Turn = i == 0 && g.Routing == provider.Rotate && len(cs) > 1
+ pl.order = append(pl.order, w)
+ }
+ out = cs
+ }
+ if len(out) == 0 {
+ return nil, pl
+ }
+ return restLast(out, pl)
+}
+
+// restLast moves those resting after a recent failure behind the rest.
+func restLast(out []candidate, pl planned) ([]candidate, planned) {
if len(out) == 1 {
if r, ok := restOf(out[0].rest); ok {
pl.order[0].Rest = &r // tried all the same: there is no other
diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index 51fa2a34..f29739ce 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -365,8 +365,39 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
}
// the primary, then its fallbacks while it can't take the request and
// nothing has been sent yet
- cands, pl := s.plan(p, model, from)
- tr := s.trace.begin(Route{Time: start, Agent: call.Agent, Model: call.Model, Provider: p.ID, Order: pl.order, Left: pl.left})
+ var cands []candidate
+ var pl planned
+ var group *GroupRef
+ // a conversation's affinity is to one model: another's cache is its
+ // own, and an agent's side requests (a title, a summary) to a smaller
+ // model leave the conversation where it is
+ scope, mode, rotate := p.ID+"/"+model, p.Affinity, p.Routing == provider.Rotate
+ if g, ms, ok := provider.FindGroup(call.Model); ok {
+ // a routing group: every member's keys or accounts weighed together
+ cands, pl = s.planGroup(g, ms, from)
+ group = &GroupRef{ID: g.ID, Name: g.Name, Routing: g.Routing, Affinity: g.Affinity, Auto: g.Auto}
+ for _, m := range ms {
+ group.Members = append(group.Members, m.Provider.ID+"/"+m.Model)
+ }
+ scope, mode, rotate = provider.GroupPrefix+g.ID, g.Affinity, g.Routing == provider.Rotate
+ } else {
+ cands, pl = s.plan(p, model, from)
+ }
+ if len(cands) == 0 {
+ call.Status, call.Error = 404, "no member ready"
+ writeError(w, from, 404, fmt.Sprintf("none of %s's models is ready", call.Model))
+ finishCapture()
+ s.record(call)
+ return
+ }
+ // the conversation stays with who answered it last, while its
+ // affinity says: what the vendor cached of it is read again
+ var aff *Affinity
+ var stuck string
+ if len(cands) > 1 {
+ cands, pl, aff, stuck = affine(scope, mode, rotate, r.Header, from, body, cands, pl)
+ }
+ tr := s.trace.begin(Route{Time: start, Agent: call.Agent, Model: call.Model, Provider: p.ID, Group: group, Affinity: aff, Order: pl.order, Left: pl.left})
var skipped []string
for i, c := range cands {
last := i == len(cands)-1 || r.Context().Err() != nil
@@ -387,6 +418,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
model = c.model
if call.Status < 400 {
served(c.rest, call.Usage.Input+call.Usage.Output+call.Usage.CacheRead+call.Usage.CacheWrite)
+ if aff != nil {
+ answered(stuck, c.rest, aff.Turn, call.Usage.CacheRead)
+ }
} else {
try.Fail = failure(call.Status, []byte(call.Error))
}
diff --git a/internal/gateway/group_test.go b/internal/gateway/group_test.go
new file mode 100644
index 00000000..e1d345e9
--- /dev/null
+++ b/internal/gateway/group_test.go
@@ -0,0 +1,206 @@
+package gateway
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// keyed is a vendor that says which key each request came with, and
+// answers as told for each.
+type keyed struct {
+ tried []string
+ fail map[string]int // key → status to fail with
+}
+
+func (k *keyed) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ io.ReadAll(r.Body)
+ key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ k.tried = append(k.tried, key)
+ w.Header().Set("Content-Type", "application/json")
+ if c := k.fail[key]; c != 0 {
+ w.WriteHeader(c)
+ io.WriteString(w, `{"error":{"message":"slow down"}}`)
+ return
+ }
+ io.WriteString(w, `{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"from `+key+`"},"finish_reason":"stop"}],`+
+ `"usage":{"prompt_tokens":3000,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":2500}}}`)
+}
+
+func fresh(t *testing.T) {
+ t.Helper()
+ t.Setenv("HOME", t.TempDir()) // no agent signed in
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ restingUntil.Lock()
+ restingUntil.m = map[string]time.Time{}
+ restingUntil.Unlock()
+ sticks.Lock()
+ sticks.m = map[string]stick{}
+ sticks.Unlock()
+}
+
+func serveOn(t *testing.T, id, key string, models []string, v http.Handler, keys ...string) {
+ t.Helper()
+ up := httptest.NewServer(v)
+ t.Cleanup(up.Close)
+ p := provider.Provider{ID: id, Name: strings.ToUpper(id), Key: key, Models: models, Chat: up.URL + "/v1"}
+ for _, k := range keys {
+ p.Keys = append(p.Keys, provider.KeyAccount{Key: k})
+ }
+ if err := provider.Save(p); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func postAs(t *testing.T, s *Server, session, body string) (int, string) {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
+ if session != "" {
+ req.Header.Set("x-session-id", session)
+ }
+ s.Handler().ServeHTTP(rec, req)
+ return rec.Code, rec.Body.String()
+}
+
+// A model two providers serve is a group magpie finds; a group the user
+// makes routes over its members in the order it names them, the next one
+// taking a request the one before can't.
+func TestRoutingGroups(t *testing.T) {
+ fresh(t)
+ a, b := &keyed{}, &keyed{fail: map[string]int{"kb": 429}}
+ serveOn(t, "a", "ka", []string{"m", "only-a"}, a)
+ serveOn(t, "b", "kb", []string{"vendor/m"}, b)
+
+ var ids []string
+ for _, e := range provider.Catalog() {
+ if e.Provider.Account == nil { // signed-in agents the machine may have aside
+ ids = append(ids, e.ID)
+ }
+ }
+ if got := strings.Join(ids, " "); got != "a/m a/only-a b/vendor/m group/auto-m" {
+ t.Fatalf("catalog: %s", got)
+ }
+ if err := provider.SaveGroup(provider.Group{Name: "Mine", Members: []string{"b/vendor/m", "a/only-a"}, Routing: provider.Ordered}); err != nil {
+ t.Fatal(err)
+ }
+ if p, m, ok := provider.Resolve("group/mine"); !ok || p.ID != "b" || m != "vendor/m" {
+ t.Fatalf("resolve: %v %s %s", ok, p.ID, m)
+ }
+
+ s := New()
+ code, body := postAs(t, s, "", `{"model":"group/mine","messages":[{"role":"user","content":"hi"}]}`)
+ if code != 200 || !strings.Contains(body, "from ka") || strings.Join(b.tried, ",") != "kb" {
+ t.Fatalf("group: %d %s, b tried %v", code, body, b.tried)
+ }
+ r := s.trace.routes[len(s.trace.routes)-1]
+ if r.Group == nil || r.Group.ID != "mine" || len(r.Order) != 2 || r.Order[0].Provider != "b" || r.Order[1].Model != "only-a" {
+ t.Fatalf("trace: %+v", r)
+ }
+
+ // removing one magpie found hides it; showing it brings it back
+ if err := provider.DeleteGroup("auto-m"); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, ok := provider.Resolve("group/auto-m"); ok {
+ t.Fatal("hidden group still served")
+ }
+ provider.ShowGroup("auto-m")
+ if _, _, ok := provider.Resolve("group/auto-m"); !ok {
+ t.Fatal("shown group not served")
+ }
+}
+
+// A conversation stays with the key that answered it: within a turn
+// always, across turns while the vendor read enough of it from its cache,
+// or not at all when its affinity is off — rotating otherwise.
+func TestAffinity(t *testing.T) {
+ const (
+ first = `{"model":"aff/m","messages":[{"role":"user","content":"hi"}]}`
+ again = `{"model":"aff/m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"yo"},{"role":"user","content":"more"}]}`
+ tool = `{"model":"aff/m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ls","arguments":"{}"}}]},{"role":"tool","tool_call_id":"c1","content":"a.go"}]}`
+ )
+ for _, c := range []struct {
+ routing string
+ affinity string
+ body string
+ want string // the key the second request goes to
+ why string
+ }{
+ {"", "", again, "k1", "cache"},
+ {provider.Rotate, "", again, "k2", "new-turn"}, // in turn spreads the turns
+ {provider.Rotate, "", tool, "k1", "turn"},
+ {provider.Rotate, provider.AffinityTurn, again, "k2", "new-turn"},
+ {provider.Rotate, provider.AffinityTurn, tool, "k1", "turn"},
+ {provider.Rotate, provider.AffinitySession, again, "k1", "session"},
+ {provider.Rotate, provider.AffinityOff, again, "k2", "off"},
+ } {
+ fresh(t)
+ v := &keyed{}
+ serveOn(t, "aff", "k1", []string{"m"}, v, "k2")
+ provider.SetRouting("aff", c.routing)
+ provider.SetAffinity("aff", c.affinity)
+ routed.Lock()
+ delete(routed.turn, "aff")
+ routed.Unlock()
+ s := New()
+ postAs(t, s, "s1", first)
+ postAs(t, s, "s1", c.body)
+ r := s.trace.routes[len(s.trace.routes)-1]
+ if got := v.tried[len(v.tried)-1]; got != c.want || r.Affinity == nil || r.Affinity.Why != c.why {
+ t.Errorf("%s %q: went to %s (%+v), want %s by %s", c.routing, c.affinity, got, r.Affinity, c.want, c.why)
+ }
+ }
+
+ // in turn, each turn goes to the one after the last turn's, whatever
+ // came between: the tool results within it, another model's requests
+ fresh(t)
+ v0 := &keyed{}
+ serveOn(t, "aff", "k1", []string{"m", "small"}, v0, "k2", "k3")
+ provider.SetRouting("aff", provider.Rotate)
+ s0 := New()
+ var turns []string
+ msgs := `{"role":"user","content":"hi"}`
+ for i := range 4 {
+ if i > 0 {
+ msgs += `,{"role":"assistant","content":"yo"},{"role":"user","content":"more"}`
+ }
+ postAs(t, s0, "s1", `{"model":"aff/m","messages":[`+msgs+`]}`)
+ turns = append(turns, v0.tried[len(v0.tried)-1])
+ postAs(t, s0, "s1", `{"model":"aff/small","messages":[{"role":"user","content":"title?"}]}`)
+ postAs(t, s0, "s1", `{"model":"aff/m","messages":[`+msgs+`,{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ls","arguments":"{}"}}]},{"role":"tool","tool_call_id":"c1","content":"a.go"}]}`)
+ if got := v0.tried[len(v0.tried)-1]; got != turns[i] {
+ t.Fatalf("turn %d's tool result went to %s, not %s", i, got, turns[i])
+ }
+ }
+ for i := 1; i < len(turns); i++ {
+ if want := map[string]string{"k1": "k2", "k2": "k3", "k3": "k1"}[turns[i-1]]; turns[i] != want {
+ t.Fatalf("turns went %v", turns)
+ }
+ }
+
+ // another session starts fresh, and one resting is left
+ fresh(t)
+ v := &keyed{}
+ serveOn(t, "aff", "k1", []string{"m"}, v, "k2")
+ provider.SetAffinity("aff", provider.AffinitySession)
+ s := New()
+ postAs(t, s, "s1", first)
+ restingUntil.Lock()
+ restingUntil.m["aff#"+provider.KeyID("k1")] = time.Now().Add(time.Minute)
+ restingUntil.Unlock()
+ postAs(t, s, "s1", again)
+ if r := s.trace.routes[len(s.trace.routes)-1]; v.tried[1] != "k2" || r.Affinity.Why != "resting" {
+ t.Fatalf("resting: %v %+v", v.tried, r.Affinity)
+ }
+ postAs(t, s, "s2", first)
+ if r := s.trace.routes[len(s.trace.routes)-1]; r.Affinity.Why != "first" {
+ t.Fatalf("new session: %+v", r.Affinity)
+ }
+}
diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go
index 883565bc..cad0aefa 100644
--- a/internal/gateway/routing.go
+++ b/internal/gateway/routing.go
@@ -241,17 +241,19 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
if len(cs) < 2 {
return cs, wg
}
- var known map[string]provider.Allowance
- if p.Account != nil {
- known = allowances(p.Account.Agent)
- }
+ // each account's own agent's: a group weighs accounts of several
+ known := map[string]map[string]provider.Allowance{}
now := time.Now()
wg.lefts = map[string]left{}
for _, c := range cs {
if c.p.Account == nil {
continue
}
- if a, ok := known[c.p.Account.User]; ok {
+ ag := c.p.Account.Agent
+ if _, ok := known[ag]; !ok {
+ known[ag] = allowances(ag)
+ }
+ if a, ok := known[ag][c.p.Account.User]; ok {
u, r := a.For(c.model, now)
wg.lefts[c.rest] = left{u, r} // one not known counts as unused
}
@@ -345,7 +347,7 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
return cs, wg
}
if p.Account == nil {
- sort.SliceStable(cs, func(i, j int) bool { return keyFit(cs[i].p, model, from) < keyFit(cs[j].p, model, from) })
+ sort.SliceStable(cs, func(i, j int) bool { return keyFit(cs[i].p, cs[i].model, from) < keyFit(cs[j].p, cs[j].model, from) })
}
return cs, wg
}
diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go
index eae9a828..13d0ea81 100644
--- a/internal/gateway/trace.go
+++ b/internal/gateway/trace.go
@@ -25,9 +25,11 @@ type Route struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Agent string `json:"agent"`
- Model string `json:"model"` // as the agent asked
- Provider string `json:"provider"` // the provider the model resolved to
- Order []Weighed `json:"order"` // who was to try it, first first
+ Model string `json:"model"` // as the agent asked
+ Provider string `json:"provider"` // the provider the model resolved to
+ Group *GroupRef `json:"group,omitempty"` // the routing group the agent asked for
+ Affinity *Affinity `json:"affinity,omitempty"` // its conversation, and whether it stayed put
+ Order []Weighed `json:"order"` // who was to try it, first first
Left []Weighed `json:"left,omitempty"`
Tries []Try `json:"tries"`
Done bool `json:"done"`
@@ -37,6 +39,16 @@ type Route struct {
Tokens int `json:"tokens,omitempty"`
}
+// GroupRef is the routing group a request asked for.
+type GroupRef struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Routing string `json:"routing"`
+ Affinity string `json:"affinity"`
+ Auto bool `json:"auto,omitempty"`
+ Members []string `json:"members"` // those ready, as provider/model
+}
+
// Weighed is one account or key as routing weighed it.
type Weighed struct {
ID string `json:"id"` // what rests after a failure
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index e8e65eba..ce3b3fb4 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -2223,7 +2223,7 @@ function renderModels(p) {
const ROUTINGS = [
["", "Smart", "The first takes requests while it has quota to spare; when it runs low, the one with the most left takes over. One out of credit sits out half an hour, one out of quota until it resets, one rate limited as long as the vendor asks, and one that fails a minute, longer each time it fails again."],
["order", "In order", "Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails."],
- ["rotate", "In turn", "Each request goes to the next one, spreading the load evenly; one that fails is passed over for a minute."],
+ ["rotate", "In turn", "Each turn of a conversation goes to the next one, spreading the load evenly; the requests within a turn stay where it began, so the prompt cache holds, and one that fails is passed over while it rests."],
["usage", "Least used first", "Each request goes to the one used least: a subscription by the share of its allowance used, a key by the tokens it served in the last hours."],
];
function renderRouting(p) {
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 58406532..0351cb14 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -144,7 +144,7 @@ const I18N = {
"In turn": "轮流",
"Least used first": "用量少的优先",
"Requests go to the first; the next takes over when the one before runs out of quota, hits a rate limit or fails.": "请求先发给第一个;它额度用完、被限流或出错时,由下一个接手。",
- "Each request goes to the next one, spreading the load evenly; one that fails is passed over for a minute.": "每个请求轮流发给下一个,均摊负载;出错的会被跳过一分钟。",
+ "Each turn of a conversation goes to the next one, spreading the load evenly; the requests within a turn stay where it began, so the prompt cache holds, and one that fails is passed over while it rests.": "会话的每一轮轮流交给下一个,均摊负载;一轮之内的请求留在这一轮开始的地方,好让提示词缓存生效;出错的在休息期间被跳过。",
"Each request goes to the one used least: a subscription by the share of its allowance used, a key by the tokens it served in the last hours.": "每个请求发给用量最少的:订阅看已用额度比例,密钥看最近几小时经它的 token 数。",
"{name}: {routing}": "{name}:{routing}",
"The gateway now uses {user} first": "网关现在优先使用 {user}",
@@ -452,6 +452,8 @@ const I18N = {
"Saved": "已保存",
// Gateway: routing, live
"live, as the gateway decides": "实时 · 网关正在做的决定",
+ "Hide emails": "邮箱打码",
+ "Mask the email addresses on this page, for a screenshot to share": "给本页的邮箱地址打码,方便截图分享",
"Least used": "最少使用",
"requests": "请求",
"rerouted": "改道",
@@ -475,7 +477,7 @@ const I18N = {
"Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. One at 90% or more waits until the others can't answer; one resting after a failure goes last.": "智能:在还有余量的账号里,额度最先重置的排第一——它剩下的额度到重置时就作废了。用到 90% 以上的账号等其他账号都答不了时才用;失败后正在休息的排最后。",
"Smart: keys that suit the request go first — one made for the model's own API — then in their order. One resting after a failure goes last.": "智能:适合这个请求的 Key 排前面——专为该模型原生 API 的 Key 优先——其余按顺序。失败后正在休息的排最后。",
"In order: the first answers everything until it can't; then the next.": "按顺序:第一个处理所有请求,直到它不行了,再换下一个。",
- "In turn: each request starts one account further along.": "轮流:每个请求从下一个账号开始。",
+ "In turn: each conversation's next turn goes to the account after the one that answered its last, and a new conversation starts one further along; the requests within a turn stay put, keeping the prompt cache.": "轮流:会话的下一轮交给上一轮回答者之后的那个账号,新会话从再下一个开始;一轮之内的请求不换账号,保住提示词缓存。",
"Least used first: the account with the most of its allowance left goes first; a key by the tokens magpie sent it lately.": "最少使用优先:剩余额度最多的账号排第一;Key 按 magpie 最近发给它的 token 数排。",
"Nothing could take {model}.": "没有谁能处理 {model}。",
"{who} is the only one, so it's tried though it is resting.": "只有 {who} 一个,所以虽然它在休息,也还是会试。",
@@ -542,6 +544,23 @@ const I18N = {
"its plan doesn't list {model}": "套餐不包含 {model}",
"answering…": "回答中…",
"answered this request": "回答了这个请求",
+ "answered {agent}": "回答了 {agent}",
+ "routing group": "路由组",
+ "Routing groups": "路由组",
+ "{n} agents at once": "{n} 个 Agent 同时在发",
+ "In order: member by member, the first model the group names until it can't answer, each over its own accounts or keys as its provider routes them.": "按顺序:逐个成员——先用组里排第一的模型,它答不了再换下一个;每个成员按其供应商的路由在自己的账号或 Key 间分配。",
+ "{agent} asked for the routing group {name}: {members}": "{agent} 请求了路由组 {name}:{members}",
+ "Kept on {who}: it answered this conversation before, and affinity keeps a session with one account.": "留在 {who}:这个会话之前由它回答,亲和性让一个会话始终用同一个账号。",
+ "Kept on {who}: {agent} is handing back tool results within turn {n}, and moving now would lose what the vendor cached of it.": "留在 {who}:{agent} 正在第 {n} 轮内回传工具结果,此时换账号会丢掉厂商为它缓存的内容。",
+ "Kept on {who}: the vendor read {n} tokens of this conversation from its cache {d} ago; anyone else would be sent them afresh and paid in full.": "留在 {who}:{d}前厂商从缓存里读了这个会话的 {n} tokens;换给别人就得重新发送、全价计费。",
+ "Turn {n} begins: in turn, it goes to the one after {who}, which answered the last turn — {next}.": "第 {n} 轮开始:轮流模式下,交给上一轮回答者 {who} 之后的那个——{next}。",
+ "Turn {n} begins: affinity keeps a conversation only within a turn, so routing decides afresh.": "第 {n} 轮开始:亲和性只在一轮之内保持,所以路由重新决定。",
+ "{who} answered this conversation last, but it is resting, so the conversation moves.": "这个会话上次由 {who} 回答,但它在休息,所以会话换了账号。",
+ "{who} answered this conversation last, but its allowance is all but used up, so the conversation moves.": "这个会话上次由 {who} 回答,但它的额度快用完了,所以会话换了账号。",
+ "{who} answered this conversation last, but it is no longer one to route to.": "这个会话上次由 {who} 回答,但它已不在可路由的范围内。",
+ "{who} answered this conversation last, but the vendor read only {n} tokens of it from its cache then — not worth staying for.": "这个会话上次由 {who} 回答,但当时厂商只从缓存读了 {n} tokens——不值得为此留下。",
+ "{who} answered this conversation {d} ago, longer than the 5 minutes a vendor keeps a prompt cached — so routing decides afresh.": "这个会话 {d}前由 {who} 回答,已超过厂商缓存提示词的 5 分钟——所以路由重新决定。",
+ "Affinity is off: each request is routed afresh, whoever answered its conversation before.": "亲和性已关闭:每个请求都重新路由,不管之前是谁回答的这个会话。",
"{status} · {fail} · passed to {agent}": "{status} · {fail} · 已交给 {agent}",
"{n} used · all but used up": "已用 {n} · 几乎用尽",
"{n} used · kept for last": "已用 {n} · 留到最后",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index 1ceccae6..05e4cb9f 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -68,7 +68,8 @@
-
Routinglive, as the gateway decides
+
Routinglive, as the gateway decides
+
diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css
index 13700fe4..c77850d1 100644
--- a/internal/gui/assets/routing.css
+++ b/internal/gui/assets/routing.css
@@ -9,12 +9,36 @@
.rt-stage { position: relative; display: grid; grid-template-columns: 124px 124px minmax(0, 1fr); column-gap: 56px; align-items: center; padding: 22px 2px; }
.rt-wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.rt-wires path { fill: none; stroke: var(--line); stroke-width: 1.5; transition: opacity .3s; }
-.rt-wires path.live { stroke: var(--muted); stroke-dasharray: 3 5; animation: rtflow .7s linear infinite; }
+.rt-wires path.live { stroke: var(--agent, var(--muted)); stroke-dasharray: 3 5; animation: rtflow .7s linear infinite; }
.rt-wires path.rest { stroke-dasharray: 2 6; opacity: .45; }
@keyframes rtflow { to { stroke-dashoffset: -8; } }
-.rt-wires .pkt { fill: var(--fg); filter: drop-shadow(0 0 3px var(--muted)); }
-.rt-wires .pkt.back { fill: var(--card); stroke: var(--fg); stroke-width: 2; }
-.rt-wires .pkt.err { stroke: var(--red); }
+/* the magpies, over the nodes: one carries each request out, another
+ brings its answer back — the dot at the tip of its beak */
+.rt-sky { position: absolute; inset: 0; z-index: 2; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
+/* each agent's requests and answers fly in its colour: a request filled
+ with it, an answer ringed with it, a failure ringed red */
+.rt-sky .pkt { fill: var(--agent, var(--fg)); filter: drop-shadow(0 0 3px var(--agent, var(--muted))); }
+.rt-sky .pkt.back { fill: var(--card); stroke: var(--agent, var(--fg)); stroke-width: 2.25; }
+.rt-sky .pkt.back.err { fill: var(--agent, var(--card)); stroke: var(--red); }
+.rt-sky path.via { fill: none; stroke: none; }
+.rt-sky .pkt.held { animation: rtheld 1.2s ease-in-out infinite; }
+@keyframes rtheld { 50% { opacity: .45; } }
+.rt-flier { color: var(--fg); }
+.rt-flier .body, .rt-flier .tail, .rt-flier .wing path:first-child, .rt-flier .wing.far { fill: currentColor; }
+.rt-flier .wing.far { opacity: .45; }
+.rt-flier .belly { fill: var(--card); }
+.rt-flier .wing .bar { fill: none; stroke: var(--card); stroke-width: 1.3; stroke-linecap: round; }
+/* the wings beat from the shoulder: down hard, up easy */
+.rt-flier .wing { transform-box: fill-box; transform-origin: 50% 100%; animation: rtflap .42s cubic-bezier(.45, 0, .3, 1) infinite; }
+.rt-flier .wing.far { animation-delay: -.03s; }
+@keyframes rtflap { 0%, 100% { transform: scaleY(1); } 55% { transform: scaleY(-.62) skewX(-8deg); } }
+.rt-flier .rt-lift { animation: rtcome .3s ease-out; transition: transform .45s cubic-bezier(.3, 0, .2, 1), opacity .45s; }
+.rt-flier.away .rt-lift { transform: translate(14px, -16px) scale(.8); opacity: 0; }
+@keyframes rtcome { from { transform: translate(-6px, -10px) scale(.85); opacity: 0; } }
+.rt-srcs { position: relative; z-index: 1; min-width: 0; display: grid; gap: 10px; }
+.rt-srcs.many .rt-node { padding: 8px 8px 7px; }
+.rt-src b::before { content: ""; display: inline-block; width: 7px; height: 7px; margin: 0 6px 1px 0; border-radius: 50%; background: var(--agent); vertical-align: middle; }
+.rt-srcs.many .rt-node .rt-ic .ic, .rt-srcs.many .rt-node .rt-ic .ic img, .rt-srcs.many .rt-node .rt-ic .ic svg { width: 20px; height: 20px; }
.rt-node { position: relative; z-index: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 8px 10px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; text-align: center; }
.rt-node .rt-ic .ic, .rt-node .rt-bird { width: 26px; height: 26px; margin-bottom: 3px; }
.rt-node .rt-ic .ic img, .rt-node .rt-ic .ic svg { width: 26px; height: 26px; }
@@ -52,6 +76,7 @@
.rt-accts li.left { border-style: dotted; }
.rt-accts li.left > :not(.tag) { opacity: .45; }
.rt-accts li.hit { animation: rthit .45s; }
+.rt-accts li.new { animation: rtin .4s ease-out; }
@keyframes rthit { 20% { translate: -5px; } 40% { translate: 4px; } 60% { translate: -2px; } 80% { translate: 1px; } }
.rt-accts .tag { position: absolute; right: 10px; top: -10px; padding: 1px 7px; border-radius: 6px; background: var(--red); color: #fff; font: 600 10.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; opacity: 0; transform: translateY(4px) scale(.9); transition: opacity .25s, transform .25s; pointer-events: none; }
.rt-accts li.tagged .tag { opacity: 1; transform: none; }
@@ -77,7 +102,7 @@
.rt-steps li.wait::before { animation: rtlive 1.8s infinite; }
.rt-off { padding: 18px 4px; text-align: center; color: var(--muted); font-size: 13px; }
@media (prefers-reduced-motion: reduce) {
- .rt-accts li.on .dot, .rt-wires path.live, .rt-accts li.hit, .rt-hub.tick, .rt-src.tick, .rt-cap.in, .rt-steps li.wait::before { animation: none; }
+ .rt-accts li.on .dot, .rt-wires path.live, .rt-sky .pkt.held, .rt-accts li.new, .rt-accts li.hit, .rt-hub.tick, .rt-src.tick, .rt-cap.in, .rt-steps li.wait::before, .masking .pii { animation: none; }
.rt-accts li { transition: none; }
}
/* a narrow list: what a row says goes under its name */
@@ -100,6 +125,14 @@
#view-routing .row-head { margin: 14px 0 6px; padding: 0 2px; gap: 0; }
#view-routing .row-head .note { font-size: 11px; color: var(--faint); margin-left: 8px; }
#view-routing .row-head .rt-livedot + .note { margin-left: 8px; }
+.rt-mask { display: inline-flex; align-items: center; gap: 5px; }
+.rt-mask svg { width: 13px; height: 13px; flex: none; }
+.rt-mask[aria-pressed="true"] { color: var(--fg); background: var(--pill); box-shadow: inset 0 0 0 1px var(--line); }
+/* an email, hidden: stand-in letters of its shape, softly blurred —
+ coming in out of focus when asked for */
+.pii { filter: blur(2.2px); opacity: .5; user-select: none; }
+.masking .pii { animation: rtpii .35s ease-out both; }
+@keyframes rtpii { from { filter: blur(0); opacity: 1; } }
#view-routing .rt { margin: 0; }
.rt-more { container-type: inline-size; padding-bottom: 12px; }
.rt-cols { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 16px; }
@@ -111,6 +144,7 @@
.rt-req[aria-pressed="true"] { background: var(--card-2); border-color: var(--line); color: var(--fg); }
.rt-req .at { font-variant-numeric: tabular-nums; color: var(--muted); }
.rt-req .asked { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.rt-req .asked i.ag { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--agent); }
.rt-req .asked .ic, .rt-req .asked .ic img, .rt-req .asked .ic svg { width: 14px; height: 14px; flex: none; }
.rt-req .m, .rt-req .to span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rt-req .to { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--fg); }
diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js
index 5fd63cf1..9c070125 100644
--- a/internal/gui/assets/routing.js
+++ b/internal/gui/assets/routing.js
@@ -1,7 +1,9 @@
// Routing, live: the gateway's own trace of each request, played as it
-// happens. A request travels from the agent to magpie and on to the account
-// routing put first; one that can't answer sends it back, and the next
-// takes it. Every row, number and sentence comes from what the gateway
+// happens. A magpie carries each request from its agent through magpie to
+// the account routing put first; another brings the answer back — or,
+// from one that can't answer, the failure back to magpie, and the first
+// takes the request on to the next. Every agent sending at once plays at
+// once, each from its own place on the left. Every row, number and sentence comes from what the gateway
// recorded while deciding (see internal/gateway/trace.go) — the order it
// weighed the accounts in, what it weighed them by, what each answered,
// how long a failed one rests. Nothing here is worked out again or made up.
@@ -22,18 +24,20 @@
const wires = document.createElementNS(NS, "svg");
wires.setAttribute("class", "rt-wires");
wires.setAttribute("aria-hidden", "true");
- const src = el("div", "rt-node rt-src");
- const srcIc = el("span", "rt-ic"), srcName = el("b"), srcSub = el("small");
- src.append(srcIc, srcName, srcSub);
+ const srcs = el("div", "rt-srcs"); // an agent's node each
const hub = el("div", "rt-node rt-hub");
- const bird = document.createElementNS(NS, "svg");
- bird.setAttribute("viewBox", "0 0 44 44");
- bird.setAttribute("class", "rt-bird");
- bird.innerHTML = '';
+ const logo = document.createElementNS(NS, "svg");
+ logo.setAttribute("viewBox", "0 0 44 44");
+ logo.setAttribute("class", "rt-bird");
+ logo.innerHTML = '';
const hubSub = el("small"), chip = el("i");
- hub.append(bird, el("b", "", "magpie"), hubSub, chip);
+ hub.append(logo, el("b", "", "magpie"), hubSub, chip);
const list = el("ol", "rt-accts");
- stage.append(wires, src, hub, list);
+ // the magpies fly over the nodes, the wires run under them
+ const sky = document.createElementNS(NS, "svg");
+ sky.setAttribute("class", "rt-sky");
+ sky.setAttribute("aria-hidden", "true");
+ stage.append(wires, srcs, hub, list, sky);
const foot = el("div", "rt-foot");
const cap = el("p", "rt-cap");
cap.setAttribute("aria-live", "polite");
@@ -69,7 +73,6 @@
more.append(hist);
const path = () => { const p = document.createElementNS(NS, "path"); wires.appendChild(p); return p; };
- const wSrc = path();
const tick = (e) => { e.classList.remove("tick"); void e.offsetWidth; e.classList.add("tick"); };
// ---------- words ----------
@@ -102,9 +105,10 @@
const MODES = {
"": ["Smart", "Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. One at 90% or more waits until the others can't answer; one resting after a failure goes last."],
order: ["In order", "In order: the first answers everything until it can't; then the next."],
- rotate: ["In turn", "In turn: each request starts one account further along."],
+ rotate: ["In turn", "In turn: each conversation's next turn goes to the account after the one that answered its last, and a new conversation starts one further along; the requests within a turn stay put, keeping the prompt cache."],
usage: ["Least used", "Least used first: the account with the most of its allowance left goes first; a key by the tokens magpie sent it lately."],
};
+ const GROUP_ORDER = "In order: member by member, the first model the group names until it can't answer, each over its own accounts or keys as its provider routes them.";
const KEYS_SMART = "Smart: keys that suit the request go first — one made for the model's own API — then in their order. One resting after a failure goes last.";
const agentOf = (id) => state.agents.find((a) => a.id === id);
@@ -201,6 +205,8 @@
// asides: the others' places, where they say something
function asides(r) {
const out = [];
+ const aff = affWhy(r, true) ? null : affWhy(r, false);
+ if (aff) out.push(aff);
const smart = (x) => !x.routing && x.kind === "account";
const someKnown = r.order.some((x) => x.known);
for (const x of r.order.slice(1)) {
@@ -213,6 +219,35 @@
return out;
}
+ // affWhy tells whether a request stayed with who answered its
+ // conversation last, and why — as the gateway decided it. With lead, only
+ // when that is what put the first where it is.
+ function affWhy(r, lead) {
+ const a = r.affinity;
+ if (!a) return null;
+ const lw = r.order.find((x) => x.id === a.last), last = lw ? who(lw) : a.last;
+ const routing = r.group ? r.group.routing : r.order[0]?.routing;
+ const ago = known0(a.at) ? dur(at(r.time) - at(a.at)) : "";
+ switch (a.why) {
+ case "session": return t("Kept on {who}: it answered this conversation before, and affinity keeps a session with one account.", { who: last });
+ case "turn": return t("Kept on {who}: {agent} is handing back tool results within turn {n}, and moving now would lose what the vendor cached of it.", { who: last, agent: agentName(r.agent), n: a.turn });
+ case "cache": return t("Kept on {who}: the vendor read {n} tokens of this conversation from its cache {d} ago; anyone else would be sent them afresh and paid in full.", { who: last, n: tokens(a.cacheRead), d: ago });
+ case "new-turn": return routing === "rotate"
+ ? t("Turn {n} begins: in turn, it goes to the one after {who}, which answered the last turn — {next}.", { n: a.turn, who: last, next: who(r.order[0]) })
+ : lead ? null : t("Turn {n} begins: affinity keeps a conversation only within a turn, so routing decides afresh.", { n: a.turn });
+ }
+ if (lead) return null;
+ switch (a.why) {
+ case "resting": return t("{who} answered this conversation last, but it is resting, so the conversation moves.", { who: last });
+ case "spent": return t("{who} answered this conversation last, but its allowance is all but used up, so the conversation moves.", { who: last });
+ case "gone": return t("{who} answered this conversation last, but it is no longer one to route to.", { who: last });
+ case "no-cache": return t("{who} answered this conversation last, but the vendor read only {n} tokens of it from its cache then — not worth staying for.", { who: last, n: tokens(a.cacheRead || 0) });
+ case "cold": return t("{who} answered this conversation {d} ago, longer than the 5 minutes a vendor keeps a prompt cached — so routing decides afresh.", { who: last, d: ago });
+ case "off": return t("Affinity is off: each request is routed afresh, whoever answered its conversation before.");
+ }
+ return null;
+ }
+
function tryWhy(r, i) {
const tr = r.tries[i], w = r.order.find((x) => x.id === tr.id), agent = agentName(r.agent);
const name = w ? `${who(w)} (${w.model})` : tr.id;
@@ -238,14 +273,63 @@
const routes = new Map(); // id → the latest of each route
let seq = 0, mine = true, loaded = false;
- let cur = null; // the route the stage shows
+ let cur = null; // the route the header and the log tell of: the newest played
let pinned = null; // a past route picked from the strip
- let rows = new Map(); // id → { li, wire, st, bi, tg, w }
- let key = ""; // the ids the stage has rows for
- let gen = 0, trips = [], waiters = [], active = 0, queue = [];
+ let rows = new Map(); // id → { li, wire, st, bi, tg, w, rid }
+ const agents = new Map(); // agent → { node, ic, name, sub, wire }
+ let sets = []; // the account sets on the stage, in the order they came
+ const playing = new Set(); // the routes being played
+ const LINGER = 12e3; // how long an agent's last request stays on the stage
+ let gen = 0, trips = [], waiters = [];
let capQ = [], capAt = -1e9, capLo = false, flipUntil = 0;
- const travel = (dot, p, rev, ms) => new Promise((res) => trips.push({ dot, p, rev, t0: performance.now(), ms: still() || !shown() ? 0 : ms, res, g: gen }));
+ // fly carries a dot along paths one after another, as one flight — and
+ // the magpie holding it in its beak, if there is one
+ const fly = (dot, bird, legs, ms) => new Promise((res) => {
+ const tr = { dot, bird, legs, t0: performance.now(), ms: still() || !shown() ? 0 : ms, res, g: gen };
+ pose(tr, 0);
+ trips.push(tr);
+ }).finally(() => { for (const l of legs) if (l.j) l.p.remove(); });
+
+ // tip is where a flight meets a wire's end or start, and which way it
+ // heads there: along the wire, or back along it
+ const tip = (p, atEnd, back) => () => {
+ const L = p.isConnected && p.getTotalLength?.() || 0;
+ if (!L) return null; // not laid out: nowhere to meet it yet
+ const a = p.getPointAtLength(atEnd ? L : 0), b = p.getPointAtLength(atEnd ? Math.max(0, L - 2) : Math.min(L, 2));
+ let tx = atEnd ? a.x - b.x : b.x - a.x, ty = atEnd ? a.y - b.y : b.y - a.y;
+ if (back) { tx = -tx; ty = -ty; }
+ const n = Math.hypot(tx, ty) || 1;
+ return { x: a.x, y: a.y, tx: tx / n, ty: ty / n };
+ };
+ // via is the way through magpie from one wire to the next: a gentle arc
+ // across it, carrying on the way the flight came in and leaving the way
+ // the next wire goes — or, out the side it came in, a loop round inside.
+ // It follows the wires as they move.
+ function via(from, to) {
+ const p = document.createElementNS(NS, "path");
+ p.setAttribute("class", "via");
+ sky.appendChild(p);
+ const j = () => {
+ const a = from(), b = to();
+ if (!a || !b) { p.removeAttribute("d"); return; }
+ const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
+ let c1, c2;
+ if (dist < 4) {
+ const h = hub.getBoundingClientRect(), k = Math.min(h.width, h.height) * .42, m = k * .6;
+ const nx = a.ty, ny = -a.tx;
+ c1 = [a.x + a.tx * k - nx * m, a.y + a.ty * k - ny * m];
+ c2 = [b.x - b.tx * k + nx * m, b.y - b.ty * k + ny * m];
+ } else {
+ const k = dist * .38, l = Math.min(16, dist * .12), nx = dy / dist, ny = -dx / dist;
+ c1 = [a.x + a.tx * k + nx * l, a.y + a.ty * k + ny * l];
+ c2 = [b.x - b.tx * k + nx * l, b.y - b.ty * k + ny * l];
+ }
+ p.setAttribute("d", `M${a.x} ${a.y} C${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${b.x} ${b.y}`);
+ };
+ j();
+ return { p, j };
+ }
const until = (f) => f() ? Promise.resolve() : new Promise((res) => waiters.push({ f, res }));
const wake = () => { const w = waiters; waiters = []; for (const x of w) if (x.f()) x.res(); else waiters.push(x); };
function say(s, lo) {
@@ -263,101 +347,199 @@
if (!r.width) return;
const b = (e) => { const x = e.getBoundingClientRect(); return { l: x.left - r.left, r: x.right - r.left, t: x.top - r.top, b: x.bottom - r.top, cx: (x.left + x.right) / 2 - r.left, cy: (x.top + x.bottom) / 2 - r.top }; };
wires.setAttribute("viewBox", `0 0 ${r.width} ${r.height}`);
- const s = b(src), h = b(hub);
- wSrc.setAttribute("d", h.l > s.r ? `M${s.r} ${s.cy} L${h.l} ${h.cy}` : `M${s.cx} ${s.b} L${h.cx} ${h.t}`);
+ sky.setAttribute("viewBox", `0 0 ${r.width} ${r.height}`);
+ const h = b(hub), low = b(srcs).b;
+ for (const a of agents.values()) {
+ const s = b(a.node), mx = (s.r + h.l) / 2;
+ a.wire.setAttribute("d", h.l > s.r ? `M${s.r} ${s.cy} C${mx} ${s.cy} ${mx} ${h.cy} ${h.l} ${h.cy}` : `M${s.cx} ${s.b} L${h.cx} ${h.t}`);
+ }
for (const row of rows.values()) {
const a = b(row.li);
if (a.l > h.r) {
const mx = (h.r + a.l) / 2;
row.wire.setAttribute("d", `M${h.r} ${h.cy} C${mx} ${h.cy} ${mx} ${a.cy} ${a.l} ${a.cy}`);
- } else { // the accounts sit under magpie: a lane down their left
- const x = a.l - 12;
- row.wire.setAttribute("d", `M${h.cx} ${h.b} C${h.cx} ${h.b + 22} ${x} ${h.b + 2} ${x} ${h.b + 24} L${x} ${a.cy - 10} Q${x} ${a.cy} ${a.l} ${a.cy}`);
+ } else { // the accounts sit under magpie: a lane down their left, from below the agents too
+ const x = a.l - 12, y = Math.max(h.b, low - 8);
+ row.wire.setAttribute("d", `M${h.cx} ${h.b} L${h.cx} ${y} C${h.cx} ${y + 22} ${x} ${y + 2} ${x} ${y + 24} L${x} ${a.cy - 10} Q${x} ${a.cy} ${a.l} ${a.cy}`);
}
}
}
- // stageFor puts a route's accounts on the stage: in the order it weighed
- // them, those left out after. The same accounts in another order move
- // to their places, so a new order shows.
- function stageFor(r) {
- const all = [...r.order, ...(r.left || [])];
- const k = all.map((w) => w.id).sort().join("\n");
- if (k !== key) {
- key = k;
+ const setOf = (r) => [...r.order, ...(r.left || [])].map((w) => w.id).sort().join("\n");
+
+ // staged: the routes the stage shows — a picked one alone; else those
+ // playing, and each agent's latest while it lingers, a few agents at most
+ function staged() {
+ if (pinned) return [pinned];
+ const n = now(), last = new Map();
+ const rs = [...routes.values()].sort((a, b) => a.id - b.id);
+ for (const r of rs) last.set(r.agent, r);
+ const out = rs.filter((r) => playing.has(r.id) || (last.get(r.agent) === r && (!r.done || at(r.time) + (r.ms || 0) > n - LINGER)));
+ const c = cur && routes.get(cur.id);
+ if (c && !out.includes(c)) out.push(c);
+ const ags = [...new Set(out.map((r) => r.agent))].slice(-4);
+ return out.filter((r) => ags.includes(r.agent)).sort((a, b) => a.id - b.id);
+ }
+
+ // hueOf is the colour an agent's requests and answers fly in, and it is
+ // marked with: its maker's own where it has one, else one of the rest
+ // picked by its id, so it stays the same from one request to the next.
+ const HUES = {
+ claude: "#d97757", codex: "#6366f1", gemini: "#0ea5e9", copilot: "#a855f7", cursor: "#14b8a6", opencode: "#eab308",
+ crush: "#ec4899", goose: "#84cc16", pi: "#10b981", omp: "#f43f5e", dsh: "#06b6d4", commandcode: "#f97316",
+ };
+ const SPARE = ["#8b5cf6", "#22c55e", "#e11d48", "#0891b2", "#ca8a04", "#db2777", "#2563eb", "#65a30d"];
+ function hueOf(id) {
+ if (HUES[id]) return HUES[id];
+ let h = 0;
+ for (const c of String(id || "")) h = (h * 31 + c.charCodeAt(0)) >>> 0;
+ return SPARE[h % SPARE.length];
+ }
+
+ function agentNode(id) {
+ let a = agents.get(id);
+ if (a) return a;
+ const node = el("div", "rt-node rt-src"), ic = el("span", "rt-ic"), name = el("b"), sub = el("small");
+ node.append(ic, name, sub);
+ a = { node, ic, name, sub, wire: path() };
+ node.style.setProperty("--agent", hueOf(id));
+ a.wire.style.setProperty("--agent", hueOf(id));
+ agents.set(id, a);
+ return a;
+ }
+
+ function makeRow(w) {
+ const li = el("li"), b = el("b");
+ b.append(icon(w.icon || (w.preset ? w.preset : "generic")));
+ const name = el("span", "who", who(w));
+ // the provider's name heads the card; a row names what differs
+ const sub = el("span", "", w.fallback ? w.name : w.kind === "provider" ? "" : w.plan || "");
+ b.append(name, " ", sub, el("code", "mdl", w.model));
+ if (w.fallback) b.append(el("small", "fb", t("fallback")));
+ const st = el("em"), bar = el("div", "bar"), bi = el("i"), tg = el("span", "tag");
+ bar.append(bi);
+ li.append(el("i", "dot"), b, st, bar, tg);
+ li.title = w.id;
+ return { li, wire: path(), st, bi, tg, w };
+ }
+
+ // sync puts the staged routes on the stage: each agent on the left, and
+ // the accounts they weighed, each route's in the order it weighed them —
+ // a new order of the same accounts moves them to their places, so it
+ // shows. Accounts and agents stay where they are while they stay.
+ function sync(force) {
+ const rs = staged();
+ if (!rs.length) return;
+ cur = routes.get((pinned || rs[rs.length - 1]).id) || pinned || rs[rs.length - 1];
+ if (force) {
for (const row of rows.values()) row.wire.remove();
rows = new Map();
list.replaceChildren();
- for (const w of all) {
- const li = el("li"), b = el("b");
- b.append(icon(w.icon || (w.preset ? w.preset : "generic")));
- const name = el("span", "who", who(w));
- // the provider's name heads the card; a row names what differs
- const sub = el("span", "", w.fallback ? w.name : w.kind === "provider" ? "" : w.plan || "");
- b.append(name, " ", sub, el("code", "mdl", w.model));
- if (w.fallback) b.append(el("small", "fb", t("fallback")));
- const st = el("em"), bar = el("div", "bar"), bi = el("i"), tg = el("span", "tag");
- bar.append(bi);
- li.append(el("i", "dot"), b, st, bar, tg);
- li.title = w.id;
- list.append(li);
- rows.set(w.id, { li, wire: path(), st, bi, tg, w });
- }
- } else {
- // FLIP: from where each was to its new place
- const before = new Map([...rows].map(([id, row]) => [id, row.li.getBoundingClientRect().top]));
- for (const w of all) list.append(rows.get(w.id).li);
- let moved = false;
- for (const [id, row] of rows) {
- const dy = before.get(id) - row.li.getBoundingClientRect().top;
- if (!dy || still()) continue;
- moved = true;
- row.li.style.transition = "none";
- row.li.style.transform = `translateY(${dy}px)`;
- }
- if (moved) {
- void list.offsetWidth;
- for (const row of rows.values()) { row.li.style.transition = ""; row.li.style.transform = ""; }
- flipUntil = performance.now() + 520;
- }
+ for (const a of agents.values()) a.wire.remove();
+ agents.clear();
+ srcs.replaceChildren();
}
- for (const w of all) rows.get(w.id).w = w;
- cur = r;
- const ag = agentOf(r.agent);
- srcIc.replaceChildren(icon(ag?.icon || "generic"));
- srcName.textContent = agentName(r.agent);
- srcSub.textContent = r.model;
+ // agents
+ const ags = [...new Set(rs.map((r) => r.agent))];
+ for (const [id, a] of agents) if (!ags.includes(id)) { a.node.remove(); a.wire.remove(); agents.delete(id); }
+ const keep = [...agents.keys()];
+ const nodes = [...keep, ...ags.filter((a) => !keep.includes(a))].map((id) => agentNode(id).node);
+ if (nodes.some((x, i) => srcs.children[i] !== x) || srcs.children.length !== nodes.length) srcs.replaceChildren(...nodes);
+ srcs.classList.toggle("many", ags.length > 1);
+ for (const id of ags) {
+ const a = agents.get(id), r = rs.filter((x) => x.agent === id).pop(), ag = agentOf(id);
+ if (a.icon !== (ag?.icon || "generic")) { a.icon = ag?.icon || "generic"; a.ic.replaceChildren(icon(a.icon)); }
+ a.name.textContent = agentName(id);
+ a.sub.textContent = r.model;
+ }
+ // the account sets, the latest of each
+ const latest = new Map();
+ for (const r of rs) latest.set(setOf(r), r);
+ sets = [...sets.filter((k) => latest.has(k)), ...[...latest.keys()].filter((k) => !sets.includes(k))];
+ const ids = [], wOf = new Map(), rOf = new Map();
+ for (const k of sets) {
+ const r = latest.get(k);
+ for (const w of [...r.order, ...(r.left || [])]) if (!ids.includes(w.id)) ids.push(w.id);
+ }
+ for (const r of rs) for (const w of [...r.order, ...(r.left || [])]) { wOf.set(w.id, w); rOf.set(w.id, r.id); }
+ const before = new Map([...rows].map(([id, row]) => [id, row.li.getBoundingClientRect().top]));
+ for (const [id, row] of rows) if (!ids.includes(id)) { row.li.remove(); row.wire.remove(); rows.delete(id); }
+ if (list.querySelector(".idle")) list.replaceChildren();
+ for (const id of ids) {
+ let row = rows.get(id);
+ if (!row) {
+ row = makeRow(wOf.get(id));
+ rows.set(id, row);
+ if (before.size && !still()) row.li.classList.add("new");
+ }
+ row.w = wOf.get(id);
+ row.rid = rOf.get(id);
+ }
+ const mine = new Set([...rows.values()].map((row) => row.li));
+ for (const li of [...list.children]) if (!mine.has(li)) li.remove();
+ // moved only when the order changed: moving a node restarts what it plays
+ if (ids.some((id, i) => list.children[i] !== rows.get(id).li) || list.children.length !== ids.length) {
+ for (const id of ids) list.append(rows.get(id).li);
+ }
+ // FLIP: from where each was to its new place
+ let moved = false;
+ for (const [id, row] of rows) {
+ const dy = before.has(id) ? before.get(id) - row.li.getBoundingClientRect().top : 0;
+ if (!dy || still()) continue;
+ moved = true;
+ row.li.style.transition = "none";
+ row.li.style.transform = `translateY(${dy}px)`;
+ }
+ if (moved) {
+ void list.offsetWidth;
+ for (const row of rows.values()) { row.li.style.transition = ""; row.li.style.transform = ""; }
+ flipUntil = performance.now() + 520;
+ }
+ header(cur, ags.length);
+ layout();
+ }
+
+ // header tells of the route the log tells of: its provider or group,
+ // and how it routes
+ function header(r, many) {
const f = r.order.find((x) => !x.fallback) || r.order[0];
- const m = MODES[f?.routing || ""] || MODES[""];
+ const g = r.group;
+ const routing = g ? g.routing || "" : f?.routing || "";
+ const m = MODES[routing] || MODES[""];
chip.textContent = t(m[0]);
chip.hidden = false;
hubText();
- what.replaceChildren(el("b", "", f?.name || r.provider), el("span", "", " · " + t(r.order.filter((x) => !x.fallback).length + (r.left || []).length === 1 ? "one on" : "{n} on", { n: r.order.filter((x) => !x.fallback).length + (r.left || []).length })));
- mode.textContent = t(!f?.routing && f?.kind === "key" ? KEYS_SMART : m[1]);
- layout();
+ const on = r.order.filter((x) => !x.fallback).length + (r.left || []).length;
+ what.replaceChildren(el("b", "", g ? g.name : f?.name || r.provider),
+ el("span", "", (g ? " · " + t("routing group") : "") + " · " + t(on === 1 ? "one on" : "{n} on", { n: on })
+ + (many > 1 ? " · " + t("{n} agents at once", { n: many }) : "")));
+ mode.textContent = g && routing === "order" ? t(GROUP_ORDER) : t(!routing && f?.kind === "key" && !g ? KEYS_SMART : m[1]);
}
// what a row says now: resting, answering, or what routing weighed it by
function render() {
if (!cur) return;
hubText();
- const r = pinned || cur, n = now();
- const trying = new Set(), answered = new Set(), rests = new Map(), gave = new Map();
- for (const w of r.order) if (w.rest) rests.set(w.id, w.rest);
- for (const tr of r.tries) {
- if (!tr.done) trying.add(tr.id);
- else if (tr.status < 400) answered.add(tr.id);
- else if (!tr.rest) gave.set(tr.id, tr); // the error the agent got
- if (tr.rest) rests.set(tr.id, tr.rest);
- }
- const onWire = new Set(flying.values());
+ const n = now(), rs = staged();
+ const trying = new Set(), busy = new Set();
+ for (const r of rs) for (const tr of r.tries) if (!tr.done) { trying.add(tr.id); busy.add(r.agent); }
+ const onWire = new Set();
+ for (const f of flying.values()) { onWire.add(f.id); busy.add(f.agent); }
for (const [id, row] of rows) {
+ // each row as the latest staged request that weighed it found it
+ const r = routes.get(row.rid) || pinned || cur, answered = new Set(), rests = new Map(), gave = new Map();
+ for (const w of r.order) if (w.rest) rests.set(w.id, w.rest);
+ for (const tr of r.tries) {
+ if (tr.done && tr.status < 400) answered.add(tr.id);
+ else if (tr.done && !tr.rest) gave.set(tr.id, tr); // the error the agent got
+ if (tr.rest) rests.set(tr.id, tr.rest);
+ }
const w = row.w, rest = rests.get(id), resting = rest && at(rest.until) > n;
let s;
if (w.unlisted) s = t("its plan doesn't list {model}", { model: w.model });
else if (resting) s = `${failWord(rest.why)} · ${restWhen(rest)}`;
else if (trying.has(id)) s = t("answering…");
- else if (answered.has(id)) s = t("answered this request");
+ else if (answered.has(id)) s = agents.size > 1 ? t("answered {agent}", { agent: agentName(r.agent) }) : t("answered this request");
else if (gave.has(id)) s = t("{status} · {fail} · passed to {agent}", { status: gave.get(id).status, fail: failWord(gave.get(id).fail), agent: agentName(r.agent) });
else if (w.kind === "account" && w.known) {
const soon = renews(w)[0];
@@ -378,9 +560,10 @@
row.li.classList.toggle("rest", !!resting || gave.has(id));
row.li.classList.toggle("left", !!w.unlisted);
row.wire.classList.toggle("live", on);
+ if (on) row.wire.style.setProperty("--agent", hueOf(r.agent));
row.wire.classList.toggle("rest", !!resting || !!w.unlisted);
}
- wSrc.classList.toggle("live", trying.size > 0 || flying.size > 0);
+ for (const [id, a] of agents) a.wire.classList.toggle("live", busy.has(id));
}
// ---------- the log: how one request was routed ----------
@@ -394,15 +577,17 @@
el("span", "grow"));
if (pinned) {
const live = el("button", "text", t("Back to live"));
- live.onclick = () => { pinned = null; stageFor(newest()); renderAll(); };
+ live.onclick = () => { pinned = null; cur = newest(); sync(true); renderAll(); };
logHead.append(live);
}
const items = [];
const main = r.order.find((x) => !x.fallback);
- items.push([main && main.model !== r.model
+ items.push([r.group
+ ? t("{agent} asked for the routing group {name}: {members}", { agent: agentName(r.agent), name: r.group.name, members: (r.group.members || []).join(", ") })
+ : main && main.model !== r.model
? t("{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}", { agent: agentName(r.agent), model: r.model, name: main.name, sent: main.model })
: t("{agent} asked for {model}", { agent: agentName(r.agent), model: r.model }) + " → " + (main?.name || r.provider), ""]);
- items.push([firstWhy(r), "why"]);
+ items.push([affWhy(r, true) || firstWhy(r), "why"]);
for (const a of asides(r)) items.push([a, "aside"]);
r.tries.forEach((_, i) => items.push([tryWhy(r, i), r.tries[i].done ? (r.tries[i].status < 400 ? "ok" : "bad") : "wait"]));
if (r.done && !r.tries.length) items.push([t("Nothing was tried: {error}", { error: r.error || r.status }), "bad"]);
@@ -412,9 +597,11 @@
// pick sets the stage to a past request, or back to live with the newest
function pick(r) {
pinned = r.id === newest()?.id ? null : r;
- gen++; trips = []; flying.clear(); for (const p of wires.querySelectorAll(".pkt")) p.remove();
- stageFor(pinned || r); renderAll();
- say(firstWhy(r));
+ gen++; trips = []; flying.clear(); playing.clear();
+ for (const p of sky.querySelectorAll(".pkt, .rt-flier")) p.remove();
+ cur = r;
+ sync(true); renderAll();
+ say(affWhy(r, true) || firstWhy(r));
if (pinned) box.scrollIntoView({ block: "nearest", behavior: still() ? "auto" : "smooth" });
}
@@ -444,7 +631,9 @@
const ag = agentOf(r.agent);
const when = el("span", "at", new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
const asked = el("span", "asked");
- asked.append(icon(ag?.icon || "generic"), el("span", "m", r.model));
+ const sw = el("i", "ag");
+ sw.style.setProperty("--agent", hueOf(r.agent));
+ asked.append(sw, icon(ag?.icon || "generic"), el("span", "m", r.model));
const to = el("span", "to");
to.append(el("i"), el("span", "", said));
const meta = [];
@@ -537,96 +726,193 @@
// ---------- playing a request ----------
- const flying = new Map(); // packet → the id it is at
+ const flying = new Map(); // packet → { id: the row it is at, agent }
- async function play(id) {
- let r = routes.get(id);
- const k = [...r.order, ...(r.left || [])].map((w) => w.id).sort().join("\n");
- if (k !== key && active) {
- // another set of accounts: wait for the stage to be free
- queue.push(id);
- if (queue.length > 2) { queue.shift(); }
- return;
- }
- active++;
- const g = gen;
- stageFor(r);
- say(firstWhy(r));
- const aside = asides(r).find((s) => s);
- if (aside) say(aside, true);
- renderAll();
+ // A magpie, drawn to fly: facing right, the dot it carries at the tip
+ // of its beak where the flight puts it; its wings beat from the shoulder.
+ const FLIER = ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + "";
+
+ function bird(kind) {
+ if (still() || !shown()) return null;
+ const g = document.createElementNS(NS, "g");
+ g.setAttribute("class", "rt-flier " + kind);
+ g.innerHTML = FLIER;
+ sky.appendChild(g);
+ return g;
+ }
+ // off it goes, up and away, once it has let go of the dot
+ function away(b) {
+ if (!b) return;
+ b.classList.add("away");
+ setTimeout(() => b.remove(), 450);
+ }
+ function packet() {
const dot = document.createElementNS(NS, "circle");
dot.setAttribute("r", 4.5);
dot.setAttribute("class", "pkt");
- dot.setAttribute("cx", -20);
- wires.appendChild(dot);
- tick(src);
- await travel(dot, wSrc, false, 380);
- for (let i = 0; g === gen; ) {
- r = routes.get(id);
- if (i >= r.tries.length) {
- if (r.done) break;
- await until(() => g !== gen || routes.get(id).tries.length > i || routes.get(id).done);
- continue;
- }
- const row = rows.get(r.tries[i].id);
- if (!row) { i++; continue; }
- tick(hub);
- flying.set(dot, r.tries[i].id);
- await travel(dot, row.wire, false, 420);
- await until(() => g !== gen || routes.get(id).tries[i].done);
- if (g !== gen) break;
- r = routes.get(id);
- const tr = r.tries[i];
- if (cur.id === id) cur = r;
- if (tr.status < 400) {
- // the reply's tokens are counted once the route is done
- await until(() => g !== gen || routes.get(id).done);
- r = routes.get(id);
- say(tryWhy(r, i), true);
- renderAll();
- dot.classList.add("back");
- dot.setAttribute("r", 4);
- await travel(dot, row.wire, true, 360);
- flying.delete(dot);
- await travel(dot, wSrc, true, 320);
- break;
- }
- row.li.classList.remove("hit"); void row.li.offsetWidth; row.li.classList.add("hit", "tagged");
- row.tg.textContent = `${tr.status} · ${failWord(tr.fail)}`;
- setTimeout(() => row.li.classList.remove("tagged"), 1800);
- say(tryWhy(r, i));
- renderAll();
- await travel(dot, row.wire, true, 300);
- flying.delete(dot);
- if (!tr.rest) { // that was the answer: the agent gets the error
- dot.classList.add("err");
- await travel(dot, wSrc, true, 320);
- break;
- }
- i++;
- }
- flying.delete(dot);
- dot.remove();
- active--;
- renderAll();
- if (!active && queue.length) play(queue.shift());
+ dot.setAttribute("visibility", "hidden"); // until a flight puts it somewhere
+ sky.appendChild(dot);
+ return dot;
}
+ // play: one magpie carries the request from the agent through magpie to
+ // who routing put first, and lets it go there while it answers; another
+ // picks up the answer and brings it back — a failure only as far as
+ // magpie, where the first takes the request on to the next.
+ async function play(id) {
+ let r = routes.get(id);
+ const g = gen;
+ playing.add(id);
+ cur = r;
+ sync();
+ say(affWhy(r, true) || firstWhy(r));
+ const aside = asides(r).find((s) => s);
+ if (aside) say(aside, true);
+ renderAll();
+ const A = agents.get(r.agent);
+ const dot = packet();
+ dot.style.setProperty("--agent", hueOf(r.agent));
+ // from: where in magpie the dot is, once it is — the way it came in
+ let carrier = bird("req"), from = null, back = null;
+ // rt: the route as the gateway has it now — or, dropped from what it
+ // keeps, as it was last, over
+ const rt = () => routes.get(id) || { ...r, done: true, tries: r.tries.map((x) => ({ ...x, done: true })) };
+ try {
+ tick(A.node);
+ if (!r.tries.length) { // routing hasn't picked yet: to magpie, to wait there
+ await fly(dot, carrier, [{ p: A.wire }], 620);
+ from = tip(A.wire, true);
+ tick(hub);
+ }
+ for (let i = 0; g === gen; ) {
+ r = rt();
+ if (!routes.has(id)) break;
+ if (i >= r.tries.length) {
+ if (r.done) break;
+ await until(() => g !== gen || rt().tries.length > i || rt().done);
+ continue;
+ }
+ const row = rows.get(r.tries[i].id);
+ if (!row) { i++; continue; }
+ flying.set(dot, { id: r.tries[i].id, agent: r.agent });
+ if (!carrier) carrier = bird("req");
+ if (from) tick(hub);
+ const out = [via(from || tip(A.wire, true), tip(row.wire, false)), { p: row.wire }];
+ await fly(dot, carrier, from ? out : [{ p: A.wire }, ...out], from ? 900 : 1400);
+ if (g !== gen) break;
+ // let go at the account: it waits there while it answers
+ away(carrier);
+ carrier = null;
+ dot.classList.add("held");
+ await until(() => g !== gen || rt().tries[i].done);
+ if (g !== gen) break;
+ r = rt();
+ if (!routes.has(id)) break;
+ const tr = r.tries[i];
+ dot.classList.remove("held");
+ if (tr.status < 400) {
+ // the reply's tokens are counted once the route is done
+ await until(() => g !== gen || rt().done);
+ if (g !== gen) break;
+ r = rt();
+ say(tryWhy(r, i), true);
+ renderAll();
+ dot.classList.add("back");
+ dot.setAttribute("r", 4);
+ back = bird("res");
+ await fly(dot, back, home(row, A), 1400);
+ flying.delete(dot);
+ away(back);
+ tick(A.node);
+ break;
+ }
+ row.li.classList.remove("hit"); void row.li.offsetWidth; row.li.classList.add("hit", "tagged");
+ row.tg.textContent = `${tr.status} · ${failWord(tr.fail)}`;
+ setTimeout(() => row.li.classList.remove("tagged"), 1800);
+ say(tryWhy(r, i));
+ renderAll();
+ dot.classList.add("back", "err");
+ back = bird("res");
+ if (!tr.rest) { // that was the answer: the agent gets the error
+ await fly(dot, back, home(row, A), 1350);
+ flying.delete(dot);
+ away(back);
+ tick(A.node);
+ break;
+ }
+ // back to magpie, which hands the request on to the next
+ await fly(dot, back, [{ p: row.wire, rev: true }], 620);
+ flying.delete(dot);
+ away(back);
+ dot.classList.remove("back", "err");
+ from = tip(row.wire, false, true);
+ i++;
+ }
+ } finally { // however it ended, nothing of it stays behind
+ away(carrier);
+ away(back);
+ flying.delete(dot);
+ dot.remove();
+ playing.delete(id);
+ renderAll();
+ }
+ }
+
+ // home: from an account back through magpie to the agent
+ const home = (row, A) => [{ p: row.wire, rev: true }, via(tip(row.wire, false, true), tip(A.wire, true, true)), { p: A.wire, rev: true }];
+
// ---------- the loop ----------
+ // pose puts a flight's dot e of the way along, and its magpie with it,
+ // heading the way it flies — turned about to fly left, tilted no more
+ // than a bird banks
+ function pose(tr, e) {
+ for (const l of tr.legs) if (l.j) l.j();
+ const lens = tr.legs.map((l) => l.p.isConnected && l.p.getAttribute("d") && l.p.getTotalLength?.() || 0);
+ const total = lens.reduce((a, b) => a + b, 0);
+ const point = (d) => {
+ d = Math.max(0, Math.min(total, d));
+ let i = 0;
+ // a leg not laid out has nowhere to be on: passed over
+ while (i < lens.length - 1 && (d > lens[i] || !lens[i])) { d -= lens[i]; i++; }
+ const l = tr.legs[i];
+ return l.p.getPointAtLength(l.rev ? lens[i] - d : d);
+ };
+ if (!total) return;
+ const d = e * total, pt = point(d);
+ tr.dot.setAttribute("cx", pt.x);
+ tr.dot.setAttribute("cy", pt.y);
+ tr.dot.removeAttribute("visibility");
+ const b = tr.bird;
+ if (!b) return;
+ const p0 = point(d - 3), p1 = point(d + 3);
+ let vx = p1.x - p0.x, vy = p1.y - p0.y;
+ if (Math.abs(vx) + Math.abs(vy) < .01) { vx = b._vx ?? (tr.legs[0].rev ? -1 : 1); vy = 0; }
+ b._vx = vx;
+ const aim = Math.max(-24, Math.min(24, Math.atan2(vy, Math.abs(vx)) * 180 / Math.PI));
+ b._a = b._a === undefined || b._flip !== (vx < 0) ? aim : b._a + (aim - b._a) * .12;
+ b._flip = vx < 0;
+ b.setAttribute("transform", `translate(${pt.x.toFixed(1)} ${pt.y.toFixed(1)}) scale(${b._flip ? -1 : 1} 1) rotate(${b._a.toFixed(1)})`);
+ }
+
function frame(ts) {
if (shown()) {
const now_ = trips; trips = [];
for (const tr of now_) {
if (tr.g !== gen) { tr.res(); continue; }
- const k = tr.ms ? Math.min(1, (ts - tr.t0) / tr.ms) : 1;
- if (tr.dot && tr.p.getTotalLength) {
- const e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
- const len = tr.p.getTotalLength(), pt = tr.p.getPointAtLength((tr.rev ? 1 - e : e) * len);
- tr.dot.setAttribute("cx", pt.x);
- tr.dot.setAttribute("cy", pt.y);
- }
+ const k = tr.ms ? Math.min(1, Math.max(0, (ts - tr.t0) / tr.ms)) : 1;
+ try {
+ pose(tr, (1 - Math.cos(Math.PI * k)) / 2); // eased in and out, as a bird glides to land
+ } catch { tr.res(); continue; } // one that can't be flown ends, and the rest fly on
if (k >= 1) tr.res(); else trips.push(tr);
}
if (ts < flipUntil) layout();
@@ -639,7 +925,7 @@
requestAnimationFrame(frame);
}
// countdowns tick once a second
- setInterval(() => { if (shown()) { render(); renderActs([...routes.values()].sort((a, b) => b.id - a.id)); } }, 1000);
+ setInterval(() => { if (shown()) { if (!pinned && loaded && cur) sync(); render(); renderActs([...routes.values()].sort((a, b) => b.id - a.id)); } }, 1000);
function offline(msg) {
off.textContent = msg;
@@ -652,9 +938,15 @@
offline("");
what.replaceChildren(el("b", "", t("Waiting for a request")));
mode.textContent = t("Send one from any agent routed through magpie and it plays here as it happens: who routing put first and why, each try, and what each answered.");
- srcIc.replaceChildren(icon("generic"));
- srcName.textContent = t("your agent");
- srcSub.textContent = "";
+ for (const a of agents.values()) a.wire.remove();
+ agents.clear();
+ const a = agentNode("");
+ a.ic.replaceChildren(icon("generic"));
+ a.name.textContent = t("your agent");
+ a.sub.textContent = "";
+ srcs.replaceChildren(a.node);
+ for (const row of rows.values()) row.wire.remove();
+ rows = new Map();
chip.hidden = true;
hubText();
list.replaceChildren(el("li", "idle", t("No request yet")));
@@ -693,9 +985,9 @@
if (first) {
offline("");
const r = newest();
- if (r) { stageFor(r); say(firstWhy(r)); renderAll(); } else empty();
+ if (r) { cur = r; sync(true); say(affWhy(r, true) || firstWhy(r)); renderAll(); } else empty();
} else {
- if (cur && routes.has(cur.id) && !active) cur = routes.get(cur.id);
+ if (cur && routes.has(cur.id)) cur = routes.get(cur.id);
if (pinned && routes.has(pinned.id)) pinned = routes.get(pinned.id);
for (const id of fresh) if (!pinned) play(id);
wake();
@@ -713,9 +1005,78 @@
function words() {
for (const s of stats.children) s.lastChild.textContent = t(s.dataset.label);
hubText();
- if (loaded) { if (cur) { stageFor(cur); renderAll(); } else empty(); }
+ if (loaded) { if (cur) { sync(true); renderAll(); } else empty(); }
}
new MutationObserver(words).observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
+
+ // ---------- hiding emails, for a screenshot to share ----------
+ // Each email address on the page — an account's, in a row, a sentence,
+ // a tooltip — is swapped for blurred stand-in letters while it's on, as the
+ // page redraws too; the address itself is kept aside to put back.
+ const view = $("#view-routing"), maskBtn = $("#rtMask");
+ const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, IS_EMAIL = new RegExp(EMAIL.source);
+ // stand-in letters of the address's shape, the same each time it's drawn:
+ // blurred, they read as a name without being one
+ const dots = (s) => { let h = 7; return s.replace(/[^@.]/g, (c) => (h = (h * 31 + c.charCodeAt(0)) >>> 0, "aeiounrstlcmdh"[h % 14])); };
+ let masked = false;
+ try { masked = localStorage.getItem("magpie.maskEmails") === "1"; } catch {}
+ function mask() {
+ const walk = document.createTreeWalker(view, NodeFilter.SHOW_TEXT), found = [];
+ for (let n; (n = walk.nextNode());) if (n.data.includes("@") && IS_EMAIL.test(n.data) && !n.parentElement?.closest(".pii")) found.push(n);
+ for (const n of found) {
+ const bits = [];
+ let last = 0;
+ for (const m of n.data.matchAll(EMAIL)) {
+ if (m.index > last) bits.push(n.data.slice(last, m.index));
+ const s = el("span", "pii", dots(m[0]));
+ s.dataset.raw = m[0];
+ bits.push(s);
+ last = m.index + m[0].length;
+ }
+ if (!last) continue;
+ if (last < n.data.length) bits.push(n.data.slice(last));
+ // one piece still, where the text was: in a flex row each would
+ // otherwise stand as an item of its own
+ if (bits.length > 1) { const run = el("span", "pii-run"); run.append(...bits); n.replaceWith(run); }
+ else n.replaceWith(...bits);
+ }
+ for (const e of view.querySelectorAll("[title]")) {
+ if (!e.title.includes("@") || !IS_EMAIL.test(e.title)) continue;
+ e.dataset.piiTitle = e.title;
+ e.title = e.title.replace(EMAIL, (m) => m.replace(/[^@.]/g, "•")); // a tooltip can't blur
+ }
+ }
+ function unmask() {
+ for (const s of view.querySelectorAll(".pii")) s.replaceWith(s.dataset.raw);
+ for (const r of view.querySelectorAll(".pii-run")) r.replaceWith(r.textContent);
+ view.normalize();
+ for (const e of view.querySelectorAll("[data-pii-title]")) { e.title = e.dataset.piiTitle; delete e.dataset.piiTitle; }
+ }
+ // what the page redraws is masked before it's painted; masking isn't
+ // itself watched, so it can't set itself off again
+ const OBS = { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["title"] };
+ const watch = new MutationObserver(() => {
+ if (!masked) return;
+ watch.disconnect();
+ mask();
+ watch.observe(view, OBS);
+ });
+ function setMasked(on) {
+ masked = on;
+ try { localStorage.setItem("magpie.maskEmails", on ? "1" : "0"); } catch {}
+ maskBtn.setAttribute("aria-pressed", String(on));
+ view.classList.toggle("masked", on);
+ if (on) { mask(); watch.observe(view, OBS); }
+ else { watch.disconnect(); unmask(); }
+ }
+ maskBtn.onclick = () => {
+ setMasked(!masked);
+ // pixelated in when asked for, not again each time the page redraws
+ view.classList.add("masking");
+ clearTimeout(maskBtn._t);
+ maskBtn._t = setTimeout(() => view.classList.remove("masking"), 450);
+ };
+ setMasked(masked);
new ResizeObserver(() => layout()).observe(stage);
words();
requestAnimationFrame(frame);
diff --git a/internal/gui/groups.go b/internal/gui/groups.go
new file mode 100644
index 00000000..a3753a6b
--- /dev/null
+++ b/internal/gui/groups.go
@@ -0,0 +1,131 @@
+package gui
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// groupsJSON is what the Routing view manages: the routing groups, the
+// models they can be made of, and each provider with several keys or
+// accounts on — which any of its models routes over already.
+type groupsJSON struct {
+ Groups []groupJSON `json:"groups"`
+ Models []modelRef `json:"models"`
+ Pools []poolJSON `json:"pools"`
+}
+
+type groupJSON struct {
+ provider.Group
+ Ready bool `json:"ready"` // a member is: agents can pick it
+ Info []memberJSON `json:"memberInfo"`
+}
+
+type memberJSON struct {
+ ID string `json:"id"`
+ Ready bool `json:"ready"`
+ Provider string `json:"provider,omitempty"`
+ Name string `json:"name,omitempty"` // the provider's
+ Icon string `json:"icon,omitempty"`
+ Model string `json:"model,omitempty"` // what the vendor is asked for
+ On int `json:"on"` // its keys or accounts on
+}
+
+type modelRef struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ PName string `json:"providerName"`
+ Icon string `json:"icon,omitempty"`
+}
+
+type poolJSON struct {
+ Provider string `json:"provider"`
+ Name string `json:"name"`
+ Icon string `json:"icon,omitempty"`
+ Kind string `json:"kind"` // "account" or "key"
+ Who []string `json:"who"`
+ Routing string `json:"routing"`
+ Affinity string `json:"affinity"`
+}
+
+// onOf is who a provider's requests spread over: its accounts, or keys.
+func onOf(p provider.Provider) (kind string, who []string) {
+ if p.Account != nil {
+ who = append(who, p.Account.User)
+ for _, q := range p.AlsoOn() {
+ who = append(who, q.Account.User)
+ }
+ return "account", who
+ }
+ for _, k := range p.KeysOn() {
+ n := k.Name
+ if n == "" {
+ n = provider.Mask(k.Key)
+ }
+ who = append(who, n)
+ }
+ return "key", who
+}
+
+func groupsState() groupsJSON {
+ out := groupsJSON{Groups: []groupJSON{}, Models: []modelRef{}, Pools: []poolJSON{}}
+ for _, e := range provider.Catalog() {
+ if e.Group == "" {
+ out.Models = append(out.Models, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon})
+ }
+ }
+ for _, g := range provider.Groups() {
+ gj := groupJSON{Group: g, Info: []memberJSON{}}
+ for _, id := range g.Members {
+ m := memberJSON{ID: id}
+ if p, model, ok := provider.Resolve(id); ok {
+ _, who := onOf(p)
+ m.Ready, m.Provider, m.Name, m.Icon, m.Model, m.On = true, p.ID, p.Name, p.Icon, model, max(len(who), 1)
+ gj.Ready = true
+ }
+ gj.Info = append(gj.Info, m)
+ }
+ out.Groups = append(out.Groups, gj)
+ }
+ for _, p := range provider.All() {
+ if !p.Ready() {
+ continue
+ }
+ if kind, who := onOf(p); len(who) > 1 {
+ out.Pools = append(out.Pools, poolJSON{Provider: p.ID, Name: p.Name, Icon: p.Icon, Kind: kind, Who: who, Routing: p.Routing, Affinity: p.Affinity})
+ }
+ }
+ return out
+}
+
+func groupRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/groups", func(rw http.ResponseWriter, r *http.Request) {
+ writeJSON(rw, groupsState())
+ })
+ mux.HandleFunc("POST /api/groups/{action}", func(rw http.ResponseWriter, r *http.Request) {
+ var in provider.Group
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(rw, err)
+ return
+ }
+ var err error
+ switch r.PathValue("action") {
+ case "save":
+ err = provider.SaveGroup(in)
+ case "delete":
+ err = provider.DeleteGroup(in.ID)
+ case "show":
+ err = provider.ShowGroup(in.ID)
+ default:
+ http.NotFound(rw, r)
+ return
+ }
+ if err != nil {
+ fail(rw, err)
+ return
+ }
+ writeJSON(rw, groupsState())
+ })
+}
diff --git a/internal/gui/providers.go b/internal/gui/providers.go
index 5830d233..60c948da 100644
--- a/internal/gui/providers.go
+++ b/internal/gui/providers.go
@@ -50,6 +50,7 @@ type providerJSON struct {
Chosen []string `json:"chosen"` // the user's explicit picks, if any
Fallback []string `json:"fallback"` // where requests go when this one can't take them
Routing string `json:"routing"` // how requests spread over its keys or accounts
+ Affinity string `json:"affinity"` // how long a conversation stays with who answered it
Models []modelJSON `json:"models"` // everything the vendor lists, exposed ones flagged
Exposed int `json:"exposed"` // how many reach the agents
Fetched string `json:"fetched"` // "3h ago" when the list came from the vendor
@@ -108,6 +109,9 @@ func currentProvider(a *agent.Agent) (string, string) {
return "", ""
}
v := strings.TrimPrefix(a.Fields[0].Get(), "magpie/")
+ if strings.HasPrefix(v, provider.GroupPrefix) {
+ return "", "" // a routing group: no one provider
+ }
if pid, model, ok := strings.Cut(v, "/"); ok {
if _, err := provider.Find(pid); err == nil {
return pid, model
@@ -123,7 +127,7 @@ func providerInfo(p provider.Provider, agents []*agent.Agent) providerJSON {
Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL,
Headers: p.Headers, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath,
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
- Fallback: p.Fallback, Routing: p.Routing,
+ Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity,
}
if out.Fallback == nil {
out.Fallback = []string{}
@@ -227,6 +231,7 @@ func ago(t time.Time) string {
func providerRoutes(mux *http.ServeMux, w Windows, gw *gateway.Server) {
importAppsRoutes(mux, gw)
traceRoutes(mux, gw)
+ groupRoutes(mux)
mux.HandleFunc("GET /api/providers", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, providersState(gw))
})
@@ -316,6 +321,11 @@ func providerRoutes(mux *http.ServeMux, w Windows, gw *gateway.Server) {
fail(rw, err)
return
}
+ case "affinity":
+ if err := provider.SetAffinity(in.ID, in.Affinity); err != nil {
+ fail(rw, err)
+ return
+ }
case "delete":
if err := provider.Delete(in.ID); err != nil {
fail(rw, err)
diff --git a/internal/provider/group.go b/internal/provider/group.go
new file mode 100644
index 00000000..afa46872
--- /dev/null
+++ b/internal/provider/group.go
@@ -0,0 +1,248 @@
+package provider
+
+// Routing groups: several models, from one provider or many, that an agent
+// picks as one. The gateway serves a group's id like any model's and
+// routes each request over every member's keys or accounts together — a
+// subscription whose allowance renews soonest first across providers, one
+// resting after a failure last — rather than over one provider's.
+//
+// The user makes groups, in the Routing view. magpie also finds some on
+// its own: a model more than one provider serves under the same name is a
+// group of those, derived each time and never stored until the user
+// changes one. Models of different names are only ever grouped by the
+// user: nothing here guesses which models are alike.
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+)
+
+// GroupPrefix starts a group's id in the catalog: "group/".
+const GroupPrefix = "group/"
+
+// Affinities are how long a conversation stays with the key or account that
+// answered it: "" auto, as long as what the vendor cached of it is worth
+// keeping; for the whole session; within a turn only, free to move when
+// the user speaks again; or never.
+var Affinities = []string{"", AffinitySession, AffinityTurn, AffinityOff}
+
+const (
+ AffinitySession = "session"
+ AffinityTurn = "turn"
+ AffinityOff = "off"
+)
+
+// Group is a routing group.
+type Group struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Members []string `json:"members"` // "provider/model", in order
+ Routing string `json:"routing,omitempty"` // as Provider.Routing, over all the members' keys and accounts
+ Affinity string `json:"affinity,omitempty"` // as Provider.Affinity
+ // Auto is set on a group magpie found: one model served by several
+ // providers. It is derived, never stored.
+ Auto bool `json:"auto,omitempty"`
+ // Hidden is stored for a found group the user removed.
+ Hidden bool `json:"hidden,omitempty"`
+}
+
+// Member is a group's member as it resolves now.
+type Member struct {
+ ID string // as the group names it
+ Provider Provider
+ Model string // what the vendor is asked for
+}
+
+// Groups lists the user's groups, then those magpie found, hidden ones
+// too (marked so).
+func Groups() []Group {
+ return groupsIn(providerEntries())
+}
+
+func groupsIn(entries []Entry) []Group {
+ f := load()
+ var out []Group
+ hidden := map[string]bool{}
+ for _, g := range f.Groups {
+ if g.Hidden {
+ hidden[g.ID] = true
+ continue
+ }
+ out = append(out, g)
+ }
+ for _, g := range autoGroups(entries) {
+ if slices.ContainsFunc(out, func(o Group) bool { return o.ID == g.ID }) {
+ continue // the user changed it: theirs now
+ }
+ g.Hidden = hidden[g.ID]
+ out = append(out, g)
+ }
+ return out
+}
+
+// autoGroups are the models more than one ready provider serves under the
+// same name — the vendor's own prefix aside ("anthropic/claude-sonnet-5"
+// is claude-sonnet-5) — in the order the providers were added.
+func autoGroups(entries []Entry) []Group {
+ var order []string
+ by := map[string][]Entry{}
+ for _, e := range entries {
+ k := strings.ToLower(e.Model)
+ if i := strings.LastIndex(k, "/"); i >= 0 {
+ k = k[i+1:]
+ }
+ if !slices.ContainsFunc(by[k], func(o Entry) bool { return o.Provider.ID == e.Provider.ID }) {
+ if by[k] == nil {
+ order = append(order, k)
+ }
+ by[k] = append(by[k], e)
+ }
+ }
+ var out []Group
+ for _, k := range order {
+ es := by[k]
+ if len(es) < 2 {
+ continue
+ }
+ g := Group{ID: "auto-" + Slug(k), Name: es[0].Name, Auto: true}
+ for _, e := range es {
+ g.Members = append(g.Members, e.ID)
+ }
+ out = append(out, g)
+ }
+ return out
+}
+
+// FindGroup looks a group up by its catalog id ("group/") and resolves
+// its members; one not ready now is left out.
+func FindGroup(id string) (Group, []Member, bool) {
+ gid, ok := strings.CutPrefix(strings.TrimSpace(id), GroupPrefix)
+ if !ok {
+ return Group{}, nil, false
+ }
+ entries := providerEntries()
+ for _, g := range groupsIn(entries) {
+ if g.ID == gid && !g.Hidden {
+ return g, membersIn(entries, g), true
+ }
+ }
+ return Group{}, nil, false
+}
+
+func membersIn(entries []Entry, g Group) []Member {
+ var out []Member
+ seen := map[string]bool{}
+ for _, id := range g.Members {
+ if strings.HasPrefix(id, GroupPrefix) {
+ continue // a group in a group is not one
+ }
+ p, m, ok := resolveIn(entries, id)
+ if !ok || seen[p.ID+"/"+m] {
+ continue
+ }
+ seen[p.ID+"/"+m] = true
+ out = append(out, Member{ID: id, Provider: p, Model: m})
+ }
+ return out
+}
+
+// groupEntries are the catalog's groups: each with a member ready, named
+// as the user named it, answering for its first member when an agent asks
+// what the model can do, and offering only the reasoning levels every
+// member has.
+func groupEntries(entries []Entry) []Entry {
+ var out []Entry
+ for _, g := range groupsIn(entries) {
+ if g.Hidden {
+ continue
+ }
+ ms := membersIn(entries, g)
+ if len(ms) == 0 {
+ continue
+ }
+ e := Entry{ID: GroupPrefix + g.ID, Model: ms[0].Model, Name: g.Name, Provider: ms[0].Provider, Group: g.ID}
+ for i, m := range ms {
+ var efforts []string
+ for _, x := range entries {
+ if x.Provider.ID == m.Provider.ID && x.Model == m.Model {
+ efforts = x.Efforts
+ }
+ }
+ if i == 0 {
+ e.Efforts = efforts
+ continue
+ }
+ e.Efforts = slices.DeleteFunc(slices.Clone(e.Efforts), func(v string) bool { return !slices.Contains(efforts, v) })
+ }
+ out = append(out, e)
+ }
+ return out
+}
+
+// SaveGroup adds or replaces a group of the user's. Changing one magpie
+// found makes it the user's.
+func SaveGroup(g Group) error {
+ g.ID = strings.ToLower(strings.TrimSpace(g.ID))
+ g.Name = strings.TrimSpace(g.Name)
+ if g.ID == "" {
+ g.ID = Slug(g.Name)
+ }
+ if g.ID == "" || g.ID != Slug(g.ID) {
+ return fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", g.ID)
+ }
+ if g.Name == "" {
+ g.Name = g.ID
+ }
+ g.Members = cleanList(g.Members)
+ g.Members = slices.DeleteFunc(g.Members, func(id string) bool { return strings.HasPrefix(id, GroupPrefix) })
+ if len(g.Members) == 0 {
+ return errors.New("a group needs a model in it")
+ }
+ if g.Routing != Ordered && g.Routing != Rotate && g.Routing != LeastUsed {
+ g.Routing = ""
+ }
+ if !slices.Contains(Affinities, g.Affinity) {
+ g.Affinity = ""
+ }
+ g.Auto, g.Hidden = false, false
+ f := load()
+ for i := range f.Groups {
+ if f.Groups[i].ID == g.ID {
+ f.Groups[i] = g
+ return store(f)
+ }
+ }
+ f.Groups = append(f.Groups, g)
+ return store(f)
+}
+
+// DeleteGroup removes a group of the user's; one magpie found is hidden,
+// to come back with ShowGroup.
+func DeleteGroup(id string) error {
+ f := load()
+ found := false
+ f.Groups = slices.DeleteFunc(f.Groups, func(g Group) bool {
+ if g.ID == id {
+ found = true
+ return true
+ }
+ return false
+ })
+ if slices.ContainsFunc(autoGroups(providerEntries()), func(g Group) bool { return g.ID == id }) {
+ f.Groups = append(f.Groups, Group{ID: id, Hidden: true})
+ found = true
+ }
+ if !found {
+ return fmt.Errorf("no group %q", id)
+ }
+ return store(f)
+}
+
+// ShowGroup brings back a group magpie found that the user had removed.
+func ShowGroup(id string) error {
+ f := load()
+ f.Groups = slices.DeleteFunc(f.Groups, func(g Group) bool { return g.ID == id && g.Hidden })
+ return store(f)
+}
diff --git a/internal/provider/models.go b/internal/provider/models.go
index abb8105e..c2031164 100644
--- a/internal/provider/models.go
+++ b/internal/provider/models.go
@@ -236,11 +236,19 @@ type Entry struct {
Model string `json:"model"` // what magpie sends the vendor
Name string `json:"name"`
Efforts []string `json:"efforts,omitempty"`
- Provider Provider `json:"-"`
+ Provider Provider `json:"-"` // a group's: its first member's
+ Group string `json:"group,omitempty"` // set on a routing group (group.go)
}
-// Catalog lists every exposed model of every ready provider.
+// Catalog lists every exposed model of every ready provider, then the
+// routing groups.
func Catalog() []Entry {
+ entries := providerEntries()
+ return append(entries, groupEntries(entries)...)
+}
+
+// providerEntries is the catalog without its groups.
+func providerEntries() []Entry {
var out []Entry
for _, p := range All() {
if !p.Ready() {
@@ -256,9 +264,21 @@ func Catalog() []Entry {
// Resolve maps an id an agent sent to a provider and the vendor's model id.
// It accepts catalog ids, "provider/model" for any model (exposed or not),
// and the bare model id when exactly one provider serves it.
+// A group's id resolves to its first member.
func Resolve(id string) (Provider, string, bool) {
id = strings.TrimSpace(id)
- entries := Catalog()
+ if strings.HasPrefix(id, GroupPrefix) {
+ for _, e := range Catalog() {
+ if e.ID == id {
+ return e.Provider, e.Model, true
+ }
+ }
+ return Provider{}, "", false
+ }
+ return resolveIn(providerEntries(), id)
+}
+
+func resolveIn(entries []Entry, id string) (Provider, string, bool) {
for _, e := range entries {
if e.ID == id {
return e.Provider, e.Model, true
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 69f757b3..8a6b4b14 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -68,6 +68,12 @@ type Provider struct {
// failing is passed over for as long as that lasts.
Routing string `json:"routing,omitempty"`
+ // Affinity is how long a conversation stays with the key or account
+ // that answered it, so the vendor's prompt cache it filled is read
+ // again rather than lost (see Affinities): "" auto, "session",
+ // "turn", "off".
+ Affinity string `json:"affinity,omitempty"`
+
// Headers are extra HTTP request headers sent to the vendor, exactly as
// the user typed them. They ride on every request magpie makes to a plain
// key+URL provider — forwarded calls, connectivity tests, and model-list
@@ -107,6 +113,7 @@ type Provider struct {
type file struct {
Providers []Provider `json:"providers"`
+ Groups []Group `json:"groups,omitempty"`
}
// Path is the file the user's providers live in.
@@ -161,7 +168,7 @@ func All() []Provider {
if _, taken := find(out, a.ID); taken || picks[a.ID].Hidden {
continue
}
- a.Models, a.Fallback, a.Routing = picks[a.ID].Models, picks[a.ID].Fallback, picks[a.ID].Routing
+ a.Models, a.Fallback, a.Routing, a.Affinity = picks[a.ID].Models, picks[a.ID].Fallback, picks[a.ID].Routing, picks[a.ID].Affinity
out = append(out, a)
}
return out
@@ -220,13 +227,16 @@ func Save(p Provider) error {
if p.ID == "magpie" {
return errors.New(`"magpie" is what agents call the gateway itself; pick another id`)
}
+ if p.ID == strings.TrimSuffix(GroupPrefix, "/") {
+ return errors.New(`"group" starts the ids of routing groups; pick another id`)
+ }
if p.Name == "" {
p.Name = p.ID
}
if a, ok := find(Accounts(), p.ID); ok {
// an account keeps only the user's model picks; the rest is the
// agent's own sign-in. Saving it again brings a removed one back.
- p = Provider{ID: a.ID, Models: p.Models, Fallback: p.Fallback, Routing: p.Routing}
+ p = Provider{ID: a.ID, Models: p.Models, Fallback: p.Fallback, Routing: p.Routing, Affinity: p.Affinity}
} else {
if slices.Contains(accountIDs, p.ID) && !stored(p.ID) {
// taken, it would hide that subscription once signed in
@@ -330,6 +340,9 @@ func normalize(p Provider) Provider {
if p.Routing != Ordered && p.Routing != Rotate && p.Routing != LeastUsed {
p.Routing = ""
}
+ if !slices.Contains(Affinities, p.Affinity) {
+ p.Affinity = ""
+ }
p.Catalog = strings.Join(p.Catalogs(), ", ")
p.Headers = cleanHeaders(p.Headers)
if p.Preset != "" {
diff --git a/internal/provider/routing.go b/internal/provider/routing.go
index 2d3e2c7b..fc2dbdbf 100644
--- a/internal/provider/routing.go
+++ b/internal/provider/routing.go
@@ -32,6 +32,17 @@ func SetRouting(id, routing string) error {
return Save(*p)
}
+// SetAffinity changes how long a provider's conversations stay with the key
+// or account that answered them.
+func SetAffinity(id, affinity string) error {
+ p, err := Find(id)
+ if err != nil {
+ return err
+ }
+ p.Affinity = affinity
+ return Save(*p)
+}
+
var usedCache struct {
sync.Mutex
m map[string]map[string]Allowance // agent → user → allowance
diff --git a/providers_cli.go b/providers_cli.go
index 503f0bc8..f677e83d 100644
--- a/providers_cli.go
+++ b/providers_cli.go
@@ -107,6 +107,9 @@ func usesByProvider() map[string][]string {
}
v := a.Fields[0].Get()
v = strings.TrimPrefix(v, "magpie/")
+ if strings.HasPrefix(v, provider.GroupPrefix) {
+ continue // a routing group, of no one provider
+ }
if pid, _, ok := strings.Cut(v, "/"); ok {
out[pid] = append(out[pid], a.Name)
}
@@ -155,9 +158,13 @@ func models() error {
}
last := ""
for _, e := range entries {
- if e.Provider.ID != last {
- last = e.Provider.ID
- fmt.Println(faint.Render(" " + e.Provider.Name))
+ head, name := e.Provider.ID, e.Provider.Name
+ if e.Group != "" {
+ head, name = provider.GroupPrefix, "Routing groups"
+ }
+ if head != last {
+ last = head
+ fmt.Println(faint.Render(" " + name))
}
line := " " + pad(e.ID, w)
if e.Name != "" && e.Name != e.Model {