diff --git a/internal/agent/workbuddy.go b/internal/agent/workbuddy.go index ab9c0f9a..5671448e 100644 --- a/internal/agent/workbuddy.go +++ b/internal/agent/workbuddy.go @@ -94,6 +94,8 @@ type workbuddyEntry struct { ID string `json:"id"` Vendor string `json:"vendor"` Disabled *bool `json:"disabled"` + URL string `json:"url"` + APIKey string `json:"apiKey"` } func workbuddyMine(raw json.RawMessage) (workbuddyEntry, bool) { @@ -109,7 +111,9 @@ func workbuddyWired(path string) bool { // workbuddyWrite puts magpie's models into models.json (on) or takes them // out, leaving every other entry and key as WorkBuddy wrote it. A model -// turned off in WorkBuddy stays off. +// turned off in WorkBuddy stays off, and models the user pointed at a magpie +// on another machine (a NAS's) stay there with its key, as ZCode's do +// (zcodeAddress): a sync brings the models up to date, not the address. func workbuddyWrite(path string, on bool) error { d, err := workbuddyRead(path) if err != nil { @@ -118,12 +122,16 @@ func workbuddyWrite(path string, on bool) error { var kept []json.RawMessage var ours []string off := map[string]bool{} + var remote, remoteKey string at := -1 // where magpie's were, which they keep for _, raw := range d.models { if e, ok := workbuddyMine(raw); ok { if at < 0 { at = len(kept) } + if remote == "" && onAnotherMachine(e.URL) { + remote, remoteKey = e.URL, e.APIKey + } ours = append(ours, e.ID) if e.Disabled != nil && *e.Disabled { off[e.ID] = true @@ -143,6 +151,12 @@ func workbuddyWrite(path string, on bool) error { if off[m.ID] { e["disabled"] = true } + if remote != "" { + e["url"] = remote + if remoteKey != "" { + e["apiKey"] = remoteKey + } + } b, err := json.Marshal(e) if err != nil { return err diff --git a/internal/agent/workbuddy_test.go b/internal/agent/workbuddy_test.go index f44f1c5e..1751f0e4 100644 --- a/internal/agent/workbuddy_test.go +++ b/internal/agent/workbuddy_test.go @@ -123,3 +123,69 @@ func TestWorkBuddyEfforts(t *testing.T) { t.Fatalf("%v", e) } } + +// WorkBuddy pointed at a magpie on another machine stays there through a +// sync, with that magpie's key (悠悠哥 on Discord: models moved to the +// magpie on a NAS were put back on this machine's), while one on this +// machine's loopback follows the gateway. +func TestWorkBuddyKeepsRemoteAddress(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("WORKBUDDY_CONFIG_DIR", "") + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".workbuddy", "models.json") + os.MkdirAll(filepath.Dir(path), 0o755) + a := workbuddy(home) + if err := a.Field("provider").Set(magpieID); err != nil { + t.Fatal(err) + } + read := func() []map[string]any { + t.Helper() + var ms []map[string]any + b, _ := os.ReadFile(path) + if err := json.Unmarshal(b, &ms); err != nil { + t.Fatalf("%v\n%s", err, b) + } + return ms + } + point := func(url, key string) { + t.Helper() + ms := read() + for _, m := range ms { + m["url"], m["apiKey"] = url, key + } + b, _ := json.Marshal(ms) + os.WriteFile(path, b, 0o600) + } + + const nas = "http://192.168.1.20:3425/v1/chat/completions" + point(nas, "nas-key") + if err := a.Sync(); err != nil { + t.Fatal(err) + } + ms := read() + if len(ms) != 2 { + t.Fatalf("models: %v", ms) + } + for _, m := range ms { + if m["url"] != nas || m["apiKey"] != "nas-key" { + t.Fatalf("NAS address not kept: %v", m) + } + } + + // an address on this machine is magpie's own and follows the gateway + point("http://localhost:9999/v1/chat/completions", "old") + if err := a.Sync(); err != nil { + t.Fatal(err) + } + for _, m := range read() { + if m["url"] != gatewayV1()+"/chat/completions" || m["apiKey"] != "magpie-workbuddy" { + t.Fatalf("local address not followed: %v", m) + } + } +}