Files
yetone a8290d2772 agent: a catalog sync or a model pick keeps the keys a user or another tool added to magpie's provider block (Pi's providers.magpie.compat.sendSessionAffinityHeaders from pi-cache-optimizer) instead of writing the block whole (#1103)
syncJSON and Pi's writeMagpie set providers.magpie to a freshly built
object, so a key magpie doesn't write there was dropped on every sync.
theirsKept now merges magpie's block into what the file holds: keys magpie
writes are its own, objects in both are merged key by key, and anything
else (compat, an options key, Crush's extra_headers) stays. The model list
is still magpie's whole, so a model gone from the catalog leaves it, and a
sync with nothing new writes nothing.

The same goes for every agent that writes the block whole: Pi and OmO,
Aside, Pencil, OpenCode (and the agents built on it), OpenChamber, Crush
and Command Code.

Verified: TestPiKeepsTheirKeysInMagpiesProvider (the reporter's
compat.sendSessionAffinityHeaders: true in Pi's models.json, through
SyncCatalog and a pick) and TestOpenCodeAndCrushKeepTheirKeysInMagpiesProvider
fail without the change ("compat.sendSessionAffinityHeaders dropped by the
sync", "opencode sync") and pass with it. go test -race ./internal/agent
and go vet ./... pass in a sandbox HOME.
2026-10-08 02:15:01 +08:00

208 lines
5.5 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"io"
"reflect"
"slices"
"strings"
"sync"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
"gopkg.in/yaml.v3"
)
// Most agents can't ask the gateway which models it has: magpie writes the
// catalog into a file of the agent's own (Pi's models.json, OpenCode's
// provider block, Codex's model catalog …) when a model is picked through
// magpie. That list was the catalog as it was then, so a provider added
// later never reached the agent, and one removed stayed listed. SyncCatalog
// rewrites each such list whenever the catalog may have changed.
var syncing struct {
sync.Mutex
running, again bool
}
// SyncCatalog brings every model list magpie wrote into an agent's files up
// to the catalog as it is now. It is what catalog.Changed is set to, so a
// provider saved or removed, or a vendor's list fetched anew, reaches the
// agents; one already running asks for another round rather than waiting.
// Errors are left for the picker: a file magpie can't write now is one the
// next change, or a pick, writes.
func SyncCatalog() {
syncing.Lock()
if syncing.running {
syncing.again = true
syncing.Unlock()
return
}
syncing.running = true
syncing.Unlock()
for {
// the catalog built once for every agent's lists, not for each
// look-up of each (thousands at magpie's start, with 30 providers)
release := provider.Hold()
for _, a := range All() {
if a.Sync != nil {
_ = a.Sync()
}
}
release()
syncing.Lock()
if !syncing.again {
syncing.running = false
syncing.Unlock()
return
}
syncing.again = false
syncing.Unlock()
}
}
// syncJSON rewrites the value at key of a JSON file, if the file has one
// there and it differs: an agent that watches its files isn't told of a
// change that isn't one.
func syncJSON(path, key string, value func() any) error {
cur, ok := edit.GetJSON(path, key)
if !ok {
return nil
}
v := value()
if sameJSON(cur, v) {
return nil
}
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
}
// theirsKept is value, magpie's provider block for the agent, with what
// else the block at key of the JSON file holds kept: a key magpie doesn't
// write there, at any depth, is the user's or another tool's (Pi's
// providers.magpie.compat.sendSessionAffinityHeaders, which
// pi-cache-optimizer adds, #1103) and stays. The keys named in whole (the
// model list) are magpie's alone, so a model gone from the catalog leaves
// the agent's list.
func theirsKept(path, key string, value func() any, whole ...string) func() any {
return func() any {
v := value()
cur, ok := edit.GetJSON(path, key)
if !ok {
return v
}
var have, want map[string]json.RawMessage
b, err := json.Marshal(v)
if err != nil || json.Unmarshal([]byte(cur), &have) != nil || have == nil || json.Unmarshal(b, &want) != nil || want == nil {
return v
}
return mergeTheirs(have, want, whole)
}
}
// mergeTheirs is want with each key of have that want lacks, objects in
// both merged alike; a key named in whole is want's as it is.
func mergeTheirs(have, want map[string]json.RawMessage, whole []string) map[string]json.RawMessage {
out := make(map[string]json.RawMessage, len(have)+len(want))
for k, w := range want {
out[k] = w
}
for k, h := range have {
w, ok := want[k]
if !ok {
out[k] = h
continue
}
if slices.Contains(whole, k) {
continue
}
var hm, wm map[string]json.RawMessage
if json.Unmarshal(h, &hm) != nil || hm == nil || json.Unmarshal(w, &wm) != nil || wm == nil {
continue
}
if b, err := json.Marshal(mergeTheirs(hm, wm, nil)); err == nil {
out[k] = b
}
}
return out
}
// syncJSONInOrder is syncJSON for a block whose keys' order the agent reads
// (OpenCode lists a model's variants in theirs): one that says the same in
// another order is rewritten too, as an older magpie wrote the variants
// alphabetically (#713).
func syncJSONInOrder(path, key string, value func() any) error {
cur, ok := edit.GetJSON(path, key)
if !ok {
return nil
}
v := value()
if sameJSON(cur, v) && sameOrder(cur, v) {
return nil
}
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
}
// sameOrder reports whether raw JSON and what v marshals to read alike token
// by token, keys in the same order; whitespace and escapes aside.
func sameOrder(raw string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return false
}
x, y := json.NewDecoder(strings.NewReader(raw)), json.NewDecoder(bytes.NewReader(b))
x.UseNumber()
y.UseNumber()
for {
a, errA := x.Token()
c, errC := y.Token()
if errA != nil || errC != nil {
return errA == io.EOF && errC == io.EOF
}
if a != c {
return false
}
}
}
// sameJSON reports whether raw JSON says what v marshals to.
func sameJSON(raw string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return false
}
var x, y any
if json.Unmarshal([]byte(raw), &x) != nil || json.Unmarshal(b, &y) != nil {
return false
}
return reflect.DeepEqual(x, y)
}
// syncYAML is syncJSON for a YAML file.
func syncYAML(path, key string, value func() any) error {
raw, err := edit.Read(path)
if err != nil || raw == nil {
return nil
}
var doc any
if yaml.Unmarshal(raw, &doc) != nil {
return nil
}
for _, k := range strings.Split(key, ".") {
m, _ := doc.(map[string]any)
if doc = m[k]; doc == nil {
return nil
}
}
v := value()
b, err := yaml.Marshal(v)
if err != nil {
return err
}
var want any
if yaml.Unmarshal(b, &want) == nil && reflect.DeepEqual(doc, want) {
return nil
}
return edit.SetYAML(path, edit.KV{Path: key, Value: v})
}