Files
magpie/internal/gateway/affinity.go
T
yetone 555f570660 gateway, provider: the Routing page says what a lone key or account has left, and a key bought as a plan (Kimi Code, GLM Coding Plan, MiniMax, OpenCode Go, Command Code) is weighed by its plan's windows (#1016, gphysign: Kimi Code (China) with one key on, Smart routing; the card said only "Kimi Code (China) 只启用了一个 Key,无需选择。" while /v1/magpie/quotas had its 5 hours 49 used, 51 left and its 7 days 45 used) — two gaps. weighRouted returned before reading anyone's allowance when there was one candidate, so a lone key or a lone subscription account was never known in the trace, and the Routing page, which shows a bar and "{n} used · renews in {d}" for a known row, had nothing to show; every single-account subscription said "what's left not known yet" the same way. And KeyAllowance read only a sub2api key's limits, so a key whose plan the Usage page reads (planQuotaSourceOf: Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code) was never known to routing, with one key or several. weighRouted now reads what each candidate has left before returning a lone one (one alone is still weighed against nobody). readsKeyWindows takes a plan key too; keyWindows reads it through planKeyWindows (planWindows, with Zhipu's team quota behind a key that has none of its own, as PlanQuotas does), and PlanQuotas' own read of a card is noted for routing at once (noteKeyAllowance), so the Usage page and routing never ask twice; the card kept on disk seeds it after a restart (keyCardTag keys a plan card as plan:, a sub2api one as balance:). A key at such a vendor that turns out to be pay-as-you-go, with no windows, is asked again every 30 minutes (keyNoWindowsAge), not every minute it is used. So, beyond the issue: several plan keys on one provider are now weighed by their windows under Smart, usage and pace, and a plan key the vendor refuses as out of quota rests until its window renews and comes back once read short of full, as a sub2api key does. affine no longer says a lone key or account all but used up "moved" its conversation: there is nobody to move to (at > 0). The GUI needed no change. TestLoneOneTellsWhatItHasLeft (a lone Kimi key known at 49% with both renews, the week first; a lone Codex account known; a plain DeepSeek key not), TestLoneSpentKeyKeepsItsConversation (fails with the at > 0 guard taken out), TestPlanKeyAllowance (the windows read behind the request, asked once; DeepSeek and Moonshot keys never asked), TestPlanKeyAllowanceFromItsCard (PlanQuotas' read known at once without a second ask; after a restart with the vendor down, the card on disk stands in and a failed read keeps it) and TestPayAsYouGoKeyAskedSeldom (a GLM pay-as-you-go key asked once, not two minutes on, again 31 minutes on) all fail without the change and pass -race -count=20 (provider) / -count=10 (gateway). routing-key-windows.test.cjs gains a lone Kimi Code key at 1100px and 440px (the one-key story, the row's bar at 49%, its provider row "49% used · renews in …" fitting, no sideways scroll) and passes in full on Chromium and WebKit in en, zh, ja and de; routing-quota-left and panel-routing pass. go test -tags nogui ./internal/..., go test -race on ./internal/provider and ./internal/gateway in full, go vet, gofmt -l, darwin, linux and windows (nogui) builds pass. Not tried with a real Kimi Code key. StepFun's Step Plan is read through a sign-in, not the key, so it isn't one of these. docs/subsystems/gateway-routing.md, providers-accounts.md and docs/reference.md say it.
2026-10-06 20:25:04 +08:00

601 lines
19 KiB
Go

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.
//
// Who answered is kept on disk as well (affinity.json, next to the
// providers), so a restart — an update's included — doesn't hand a
// conversation to whoever routing puts first while the vendor still has
// it cached at the one before (vincentzhang on Discord).
import (
"crypto/sha256"
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/steady"
)
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
// sticksKept is how many conversations, the latest, are kept on disk.
sticksKept = 512
)
// 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
who string // the key or account, however many were on
model string // the model it answered as: a group may have several on one account
effort string // the effort the group's member it answered as is fixed at
turn int
at time.Time
cacheRead int
}
var sticks = struct {
sync.Mutex
m map[string]stick // scope|conversation → who answered it last
// the file as last read or written: another magpie's writes (the one
// handing over to this one) are read again when a conversation is missed
from string
mod time.Time
}{m: map[string]stick{}}
// sticksSaving keeps one write of the file at a time, the latest last.
var sticksSaving sync.Mutex
// savedStick is a stick as the file has it.
type savedStick struct {
Rest string `json:"rest"`
Who string `json:"who"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
Turn int `json:"turn,omitempty"`
At time.Time `json:"at"`
CacheRead int `json:"cacheRead,omitempty"`
}
func sticksPath() string { return filepath.Join(filepath.Dir(provider.Path()), "affinity.json") }
// stickOf is who answered key last, read from the file again when it isn't
// known here and the file changed since: after a restart, or written by
// the magpie that handed over. Called with sticks held.
func stickOf(key string) (stick, bool) {
if st, ok := sticks.m[key]; ok {
return st, true
}
path := sticksPath()
fi, err := os.Stat(path)
if err != nil || path == sticks.from && fi.ModTime().Equal(sticks.mod) {
return stick{}, false
}
sticks.from, sticks.mod = path, fi.ModTime()
b, err := os.ReadFile(path)
if err != nil {
return stick{}, false
}
var saved map[string]savedStick
if json.Unmarshal(b, &saved) != nil {
return stick{}, false
}
for k, v := range saved {
if time.Since(v.At) > stickKeep || v.Who == "" {
continue
}
if st, ok := sticks.m[k]; ok && !st.at.Before(v.At) {
continue
}
sticks.m[k] = stick{rest: v.Rest, who: v.Who, model: v.Model, effort: v.Effort, turn: v.Turn, at: v.At, cacheRead: v.CacheRead}
}
st, ok := sticks.m[key]
return st, ok
}
// saveSticks writes the latest conversations' answerers to the file, only
// for this user to read.
func saveSticks() {
sticksSaving.Lock()
defer sticksSaving.Unlock()
now := time.Now()
type kept struct {
k string
v savedStick
}
var ks []kept
sticks.Lock()
for k, st := range sticks.m {
if now.Sub(st.at) <= stickKeep {
ks = append(ks, kept{k, savedStick{st.rest, st.who, st.model, st.effort, st.turn, st.at, st.cacheRead}})
}
}
sticks.Unlock()
slices.SortFunc(ks, func(a, b kept) int { return b.v.At.Compare(a.v.At) })
ks = ks[:min(len(ks), sticksKept)]
out := make(map[string]savedStick, len(ks))
for _, k := range ks {
out[k.k] = k.v
}
b, err := json.Marshal(out)
if err != nil {
return
}
path := sticksPath()
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
return
}
path, err = edit.Target(path) // a symlink stays, its target written
if err != nil {
return
}
tmp := path + ".magpie-tmp"
if os.WriteFile(tmp, b, 0o600) != nil {
return
}
if steady.Rename(tmp, path) != nil {
os.Remove(tmp)
return
}
if fi, err := os.Stat(path); err == nil {
sticks.Lock()
// what this magpie wrote isn't read back as another's
sticks.from, sticks.mod = sticksPath(), fi.ModTime()
sticks.Unlock()
}
}
// 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
}
return turnIn(req)
}
// turnIn is turnOf for a request already parsed. The last of the user's
// messages tells whether the turn goes on: an agent may put its own notes
// after it (Claude Code a system message after the tool results).
func turnIn(req *Request) (turn int, within bool) {
for _, m := range req.Messages {
if m.Role != "user" {
continue
}
text, result := false, false
for _, p := range m.Parts {
switch p.Kind {
case Text, Image, File:
text = true
case ToolResult:
result = true
}
}
if text && !result {
turn++
}
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 := stickOf(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
}
// the account and the model that answered: a group with Opus and
// Sonnet both on one Claude account, a rule sending the turn to Opus,
// had the turn's next request kept to the account and so to Sonnet,
// its first member there; the account alone when the model is gone
at := -1
for _, same := range []func(candidate) bool{
func(c candidate) bool { return c.who() == st.who && c.model == st.model && c.effort == st.effort },
func(c candidate) bool { return c.who() == st.who && c.model == st.model },
func(c candidate) bool { return c.who() == st.who },
} {
for i, c := range cs {
if had && at < 0 && same(c) {
at, a.Last = i, c.rest // as it goes by now
}
}
}
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 at > 0 && pl.order[at].Known && pl.order[at].Used >= provider.SpentShareOf(cs[at].p.Routing):
// all but used up, and put behind another: first still — the
// one key or account it has (#1016), or every one used up — it
// doesn't move
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 {
weightedKept(cs[0], cs[at])
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)
}
case "first":
if rotate {
cs, pl = leastHeld(scope, cs, pl)
}
}
return cs, pl, a, key
}
// heldFor is how long after its last answer a conversation still counts as
// holding the one that answered it: a session whose user is reading or
// typing is still going.
const heldFor = 30 * time.Minute
// leastHeld puts first, for a conversation nobody has answered yet, the
// one in turn that the fewest other conversations in scope are held on
// (#946): in turn counts requests, not conversations, so a second session
// started after the first had sent an even number of them went to the
// model the first was kept on. Of those held on as few, routing's order
// stands.
func leastHeld(scope string, cs []candidate, pl planned) ([]candidate, planned) {
if len(cs) < 2 || len(pl.order) < len(cs) {
return cs, pl
}
if strings.HasPrefix(scope, provider.GroupPrefix) {
if _, ms, ok := provider.FindGroup(scope); ok {
for _, m := range ms {
if len(m.Path) > 1 {
return cs, pl // a group in the group: its own routing orders its models (#576)
}
}
}
}
now := time.Now()
held := map[string]int{}
sticks.Lock()
for k, st := range sticks.m {
if strings.HasPrefix(k, scope+"|") && now.Sub(st.at) <= heldFor {
held[st.who+"/"+provider.WithMemberEffort(st.model, st.effort)]++
}
}
sticks.Unlock()
if len(held) == 0 {
return cs, pl
}
seat := func(c candidate) string { return c.who() + "/" + provider.WithMemberEffort(c.model, c.effort) }
best := -1
for i, c := range cs {
if pl.order[i].Rest != nil || pl.order[i].Aside {
continue
}
if best < 0 || held[seat(c)] < held[seat(cs[best])] {
best = i
}
}
if best <= 0 {
return cs, pl
}
cs = append(append([]candidate{cs[best]}, cs[:best]...), cs[best+1:]...)
pl.order = append(append([]Weighed{pl.order[best]}, pl.order[:best]...), pl.order[best+1:]...)
for j := range pl.order {
pl.order[j].Turn = j == 0
}
return cs, pl
}
// 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 || pl.order[i].Aside {
continue // a turn goes round the keys routed over only
}
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 string, c candidate, turn, cacheRead int) {
now := time.Now()
sticks.Lock()
sticks.m[key] = stick{rest: c.rest, who: c.who(), model: c.model, effort: c.effort, 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)
}
}
}
sticks.Unlock()
saveSticks()
}
// unanswered forgets that c answered a conversation, when its reply to it
// broke off (#733): the agent's retry goes by routing again, not back to
// the one that just failed it. The account alone is enough: affine's
// widest match keeps a conversation on an account whose answerer's model
// has since left the group, so a stick that only matched by its model
// would leave the next request kept on the account that just broke off.
func unanswered(key string, c candidate) {
sticks.Lock()
st, ok := stickOf(key)
ok = ok && st.who == c.who()
if ok {
delete(sticks.m, key)
}
sticks.Unlock()
if ok {
saveSticks()
}
}
// foreignReasoning is how a vendor refuses reasoning another account (or
// organization, or vendor) sealed: OpenAI's "The encrypted content for
// item rs_… could not be verified", invalid_encrypted_content; xAI's
// (Grok's API and a SuperGrok account alike) "Could not decrypt the
// provided encrypted_content. Ensure the value is the unmodified
// encrypted_content from a previous response."
var foreignReasoning = regexp.MustCompile(`(?i)invalid_encrypted_content|encrypted[ _]content.{0,80}could not be (verified|decrypted)|could not (decrypt|verify).{0,40}encrypted[ _]content`)
// refusedSeals remembers, per conversation and the key or account that
// refused it, the sealed reasoning another one wrote. Codex hands every
// earlier turn's reasoning back on each request, so once a conversation
// has moved, what was sealed before it moved is left out of its later
// requests there up front, rather than refused and asked again each turn.
// Reasoning the one it moved to sealed itself still goes along, for its
// cache and its train of thought.
var refusedSeals = struct {
sync.Mutex
m map[string]sealsRefused // scope|conversation|who → what it refused
}{m: map[string]sealsRefused{}}
type sealsRefused struct {
at time.Time
seals map[[sha256.Size]byte]bool
}
// sealedItem is what's read of an input item to tell sealed reasoning.
type sealedItem struct {
Type string `json:"type"`
Enc string `json:"encrypted_content"`
}
// sealedKinds are the input items a vendor seals for itself: its
// reasoning, and the compaction a conversation's earlier turns were
// folded into (OpenAI's, when Codex compacts on its own models; magpie's
// own is text by the time a request is sent).
var sealedKinds = map[string]bool{"reasoning": true, "compaction": true, "compaction_summary": true}
var compactionKinds = []string{"compaction", "compaction_summary"}
// seals are the sealed reasoning and compactions (encrypted_content) in a
// Responses request's input, of these types.
func seals(body []byte, kinds ...string) [][sha256.Size]byte {
var q struct {
Input []sealedItem `json:"input"`
}
if json.Unmarshal(body, &q) != nil {
return nil
}
var out [][sha256.Size]byte
for _, it := range q.Input {
if slices.Contains(kinds, it.Type) && it.Enc != "" {
out = append(out, sha256.Sum256([]byte(it.Enc)))
}
}
return out
}
// refused notes that who turned away the sealed items of these types in
// body, in the conversation key names.
func refused(key, who string, body []byte, kinds ...string) {
ss := seals(body, kinds...)
if len(ss) == 0 {
return
}
now := time.Now()
refusedSeals.Lock()
defer refusedSeals.Unlock()
k := key + "|" + who
r := refusedSeals.m[k]
if r.seals == nil {
r.seals = map[[sha256.Size]byte]bool{}
}
for _, s := range ss {
r.seals[s] = true
}
r.at = now
refusedSeals.m[k] = r
if len(refusedSeals.m) > 4096 {
for k, r := range refusedSeals.m {
if now.Sub(r.at) > stickKeep {
delete(refusedSeals.m, k)
}
}
}
}
// withoutRefused takes out of a Responses request the sealed reasoning
// and compactions who already refused in this conversation; the rest
// stays as it is.
func withoutRefused(key, who string, body []byte) ([]byte, bool) {
k := key + "|" + who
refusedSeals.Lock()
r, ok := refusedSeals.m[k]
if ok && time.Since(r.at) > stickKeep {
delete(refusedSeals.m, k)
ok = false
}
refusedSeals.Unlock()
if !ok {
return nil, false
}
var q map[string]json.RawMessage
if json.Unmarshal(body, &q) != nil {
return nil, false
}
var items []json.RawMessage
if json.Unmarshal(q["input"], &items) != nil {
return nil, false
}
kept := items[:0:0]
for _, it := range items {
var t sealedItem
if json.Unmarshal(it, &t) == nil && sealedKinds[t.Type] && t.Enc != "" && r.seals[sha256.Sum256([]byte(t.Enc))] {
continue
}
kept = append(kept, it)
}
if len(kept) == len(items) {
return nil, false
}
q["input"], _ = json.Marshal(kept)
b, err := json.Marshal(q)
return b, err == nil
}
// withoutReasoning takes the sealed reasoning out of a Responses request's
// input — what another account wrote and this one can't read. What was
// said and done stays; only the model's private notes to itself go.
func withoutReasoning(body []byte) ([]byte, bool) {
return withoutKinds(body, "reasoning")
}
// withoutCompaction takes out of a Responses request's input the sealed
// compaction of its earlier turns — OpenAI's, which no other vendor or
// account can read (waroy: Grok's "Could not decrypt the provided
// encrypted_content" with the reasoning already gone). What the turns
// since said and did stays; the summary of those before goes.
func withoutCompaction(body []byte) ([]byte, bool) {
var q struct {
Input []sealedItem `json:"input"`
}
if json.Unmarshal(body, &q) != nil {
return nil, false
}
for _, it := range q.Input {
if slices.Contains(compactionKinds, it.Type) && it.Enc != "" {
return withoutKinds(body, compactionKinds...)
}
}
return nil, false
}
// withoutKinds is a Responses request without its input items of these
// types.
func withoutKinds(body []byte, kinds ...string) ([]byte, bool) {
var q map[string]json.RawMessage
if json.Unmarshal(body, &q) != nil {
return nil, false
}
var items []json.RawMessage
if json.Unmarshal(q["input"], &items) != nil {
return nil, false
}
kept := items[:0:0]
for _, it := range items {
var t struct {
Type string `json:"type"`
}
if json.Unmarshal(it, &t) == nil && slices.Contains(kinds, t.Type) {
continue
}
kept = append(kept, it)
}
if len(kept) == len(items) {
return nil, false
}
q["input"], _ = json.Marshal(kept)
b, err := json.Marshal(q)
return b, err == nil
}