agent: WorkBuddy takes magpie's models (能否作为Agent的支持WorkBuddy) — a WorkBuddy agent whose provider field puts every magpie model into WorkBuddy's picker: entries in ~/.workbuddy/models.json ($WORKBUDDY_CONFIG_DIR), the custom-model list WorkBuddy reads and watches (no restart), as OpenAI chat-completions models at the gateway's /v1/chat/completions under magpie-workbuddy, with each model's context, output, images and reasoning levels (reasoning.supportedEfforts, canDisableThinking where it can think off). WorkBuddy's own entries (its local models) and the CLI's {models, availableModels} form are kept; a magpie model turned off in WorkBuddy stays off; off takes only magpie's out. Its requests say CLI/<ver> WorkBuddy/<ver>, so they're known by the key. Tested with WorkBuddy 5.5.6's own CLI against a sandbox gateway: deepseek/deepseek-v4-pro answered, reasoning_effort sent, usage under WorkBuddy

This commit is contained in:
yetone committed 2026-09-28 19:24:40 +08:00
1 parent 3ad65b03ed
commit 0de3094d67
4 files changed
+341

No files matched your search

+1
View File
@@ -87,6 +87,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI.
| Qoder CN (CLI) | `~/.qoder-cn/settings.json` (`$QODERCN_CONFIG_DIR`) | model, effort (as Qoder; its own accounts, a Qoder CN plan with BYOK) |
| Grok Build | `~/.grok/config.toml` (`$GROK_HOME`) | model, effort |
| ZCode | `~/.zcode/v2/config.json` | provider (magpie's models in ZCode's picker) |
| WorkBuddy | `~/.workbuddy/models.json` (`$WORKBUDDY_CONFIG_DIR`) | provider (magpie's models in WorkBuddy's picker) |
| OpenHanako | `~/.hanako/provider-catalog.json` + `agents/<id>/config.yaml` (`$HANA_HOME`; its local API while it runs) | model (the primary agent's; magpie's models as a provider) |
| Alma | Alma's local API (`localhost:23001`, while Alma runs) | model (Alma's default; magpie's models as a provider) |
+1
View File
@@ -77,6 +77,7 @@ func All() []*Agent {
qoderCN(home),
grok(home),
zcode(home),
workbuddy(home),
hanako(home),
alma(),
cindy(),
+215
View File
@@ -0,0 +1,215 @@
package agent
// WorkBuddy (Tencent's CodeBuddy desktop app) takes models of one's own
// from ~/.workbuddy/models.json ($WORKBUDDY_CONFIG_DIR if set): a list of
// OpenAI chat-completions models, or {"models":[…],"availableModels":[…]}
// as CodeBuddy Code's CLI writes it. Its own "local models" are entries of
// the same list (local: true).
//
// [{"id":"deepseek/pro","name":…,"vendor":"magpie","apiKey":"magpie-workbuddy",
// "url":"http://127.0.0.1:3425/v1/chat/completions",
// "maxInputTokens":…,"maxOutputTokens":…,
// "supportsToolCall":true,"supportsImages":…,"supportsReasoning":true,
// "reasoning":{"supportedEfforts":[…],"canDisableThinking":…,"defaultEffort":…}}]
//
// WorkBuddy shows them in its picker as "custom-local:<id>", and asks url
// for model <id>, with reasoning_effort, under Bearer apiKey. It watches the
// file and picks a change up without a restart. The model is picked per
// task in WorkBuddy's window, not in a file, so what magpie sets is whether
// its models are in that picker. Its requests say "CLI/<ver> WorkBuddy/<ver>",
// so they are told apart by their key.
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"slices"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
func workbuddy(home string) *Agent {
dir := os.Getenv("WORKBUDDY_CONFIG_DIR")
if dir == "" {
dir = filepath.Join(home, ".workbuddy")
}
path := filepath.Join(dir, "models.json")
return &Agent{
ID: "workbuddy", Name: "WorkBuddy", Icon: "workbuddy-color", Aliases: []string{"work-buddy"},
UA: []string{"workbuddy"},
Dir: dir, Path: path,
Sync: func() error {
if !workbuddyWired(path) {
return nil
}
return workbuddyWrite(path, true)
},
Fields: []Field{{
Key: "provider", Label: "provider",
Get: func() string {
if workbuddyWired(path) {
return magpieID
}
return ""
},
Set: func(v string) error { return workbuddyWrite(path, v != "") },
Options: func(map[string]string) []Option {
return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in WorkBuddy's picker"}}
},
}},
}
}
// workbuddyDoc is models.json as read: its entries, and the rest of the
// object when it is one (nil for a bare list).
type workbuddyDoc struct {
models []json.RawMessage
rest map[string]json.RawMessage
}
func workbuddyRead(path string) (workbuddyDoc, error) {
var d workbuddyDoc
b, err := edit.Read(path)
if err != nil || len(bytes.TrimSpace(b)) == 0 {
return d, err
}
if bytes.TrimSpace(b)[0] == '[' {
return d, json.Unmarshal(b, &d.models)
}
if err := json.Unmarshal(b, &d.rest); err != nil {
return d, err
}
if raw, ok := d.rest["models"]; ok {
if err := json.Unmarshal(raw, &d.models); err != nil {
return d, err
}
}
return d, nil
}
// workbuddyEntry is what magpie reads of an entry.
type workbuddyEntry struct {
ID string `json:"id"`
Vendor string `json:"vendor"`
Disabled *bool `json:"disabled"`
}
func workbuddyMine(raw json.RawMessage) (workbuddyEntry, bool) {
var e workbuddyEntry
return e, json.Unmarshal(raw, &e) == nil && e.Vendor == magpieID
}
// workbuddyWired reports whether models.json has magpie's models.
func workbuddyWired(path string) bool {
d, _ := workbuddyRead(path)
return slices.ContainsFunc(d.models, func(raw json.RawMessage) bool { _, ok := workbuddyMine(raw); return ok })
}
// 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.
func workbuddyWrite(path string, on bool) error {
d, err := workbuddyRead(path)
if err != nil {
return err
}
var kept []json.RawMessage
var ours []string
off := map[string]bool{}
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)
}
ours = append(ours, e.ID)
if e.Disabled != nil && *e.Disabled {
off[e.ID] = true
}
continue
}
kept = append(kept, raw)
}
if !on && len(ours) == 0 {
return nil
}
var add []json.RawMessage
var ids []string
if on {
for _, m := range magpieModels("workbuddy") {
e := workbuddyModel(m.ID, m.Name, m.Context, m.Output, m.Images, m.Efforts)
if off[m.ID] {
e["disabled"] = true
}
b, err := json.Marshal(e)
if err != nil {
return err
}
add = append(add, b)
ids = append(ids, m.ID)
}
}
if at < 0 {
at = len(kept)
}
models := slices.Concat(kept[:at], add, kept[at:])
if models == nil {
models = []json.RawMessage{}
}
var out any = models
if d.rest != nil {
// a list of the models to show keeps showing magpie's
if raw, ok := d.rest["availableModels"]; ok {
var avail []string
if json.Unmarshal(raw, &avail) == nil && len(avail) > 0 {
avail = slices.DeleteFunc(avail, func(id string) bool { return slices.Contains(ours, id) })
avail = append(avail, ids...)
b, _ := json.Marshal(avail)
d.rest["availableModels"] = b
}
}
b, err := json.Marshal(models)
if err != nil {
return err
}
d.rest["models"] = b
out = d.rest
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
return edit.WriteAtomic(path, append(b, '\n'))
}
// workbuddyModel is one of magpie's models as a models.json entry.
func workbuddyModel(id, name string, context, output int, images bool, efforts []string) map[string]any {
if context == 0 {
context = 200000
}
e := map[string]any{
"id": id, "name": name, "vendor": magpieID,
"apiKey": gateway.TokenFor("workbuddy"), "url": gatewayV1() + "/chat/completions",
"maxInputTokens": context,
"supportsToolCall": true, "supportsImages": images, "supportsReasoning": false,
}
// without it WorkBuddy caps every reply at its own default
if out := zcodeOutput(output); out > 0 {
e["maxOutputTokens"] = out
}
var levels []string
for _, l := range efforts {
if l != "none" && !slices.Contains(levels, l) {
levels = append(levels, l)
}
}
if len(levels) > 0 {
e["supportsReasoning"] = true
e["reasoning"] = map[string]any{"supportedEfforts": levels,
"canDisableThinking": slices.Contains(efforts, "none"), "defaultEffort": zcodeDefaultLevel(levels)}
}
return e
}
+124
View File
@@ -0,0 +1,124 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/yetone/magpie/internal/provider"
)
func TestWorkBuddy(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", 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"}}); err != nil {
t.Fatal(err)
}
path := filepath.Join(home, ".workbuddy", "models.json")
os.MkdirAll(filepath.Dir(path), 0o755)
// WorkBuddy's own local model, which stays
os.WriteFile(path, []byte(`[{"id":"qwen-local","local":true,"url":"http://127.0.0.1:8080/v1/chat/completions"}]`), 0o600)
read := func() []map[string]any {
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
}
a := workbuddy(home)
if !a.Detected() {
t.Fatal("not detected")
}
f := a.Field("provider")
if f.Get() != "" {
t.Fatalf("get: %q", f.Get())
}
if err := f.Set("magpie"); err != nil {
t.Fatal(err)
}
ms := read()
if len(ms) != 2 || ms[0]["id"] != "qwen-local" || ms[0]["local"] != true {
t.Fatalf("models: %v", ms)
}
pro := ms[1]
if pro["id"] != "deepseek/pro" || pro["vendor"] != "magpie" || pro["apiKey"] != "magpie-workbuddy" ||
pro["url"] != gatewayV1()+"/chat/completions" || pro["supportsToolCall"] != true || pro["maxInputTokens"] == nil {
t.Fatalf("magpie model: %v", pro)
}
if f.Get() != "magpie" {
t.Fatalf("get: %q", f.Get())
}
// turned off in WorkBuddy, it stays off through a sync
ms[1]["disabled"] = true
b, _ := json.Marshal(ms)
os.WriteFile(path, b, 0o600)
if err := a.Sync(); err != nil {
t.Fatal(err)
}
if ms = read(); len(ms) != 2 || ms[1]["disabled"] != true {
t.Fatalf("after sync: %v", ms)
}
if err := f.Set(""); err != nil {
t.Fatal(err)
}
if ms = read(); len(ms) != 1 || ms[0]["id"] != "qwen-local" {
t.Fatalf("after off: %v", ms)
}
if f.Get() != "" {
t.Fatalf("get: %q", f.Get())
}
// off, a sync leaves it off
if err := a.Sync(); err != nil {
t.Fatal(err)
}
if len(read()) != 1 {
t.Fatal("sync put magpie back")
}
// the CLI's object form, with a list of the models to show
os.WriteFile(path, []byte(`{"models":[{"id":"mine","url":"https://x/v1/chat/completions"}],"availableModels":["mine"],"other":1}`), 0o600)
if err := f.Set("magpie"); err != nil {
t.Fatal(err)
}
var doc struct {
Models []map[string]any `json:"models"`
Available []string `json:"availableModels"`
Other int `json:"other"`
}
b, _ = os.ReadFile(path)
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatal(err)
}
if len(doc.Models) != 2 || doc.Other != 1 || len(doc.Available) != 2 || doc.Available[1] != "deepseek/pro" {
t.Fatalf("object form: %s", b)
}
if err := f.Set(""); err != nil {
t.Fatal(err)
}
b, _ = os.ReadFile(path)
doc.Available = nil
json.Unmarshal(b, &doc)
if len(doc.Models) != 1 || len(doc.Available) != 1 {
t.Fatalf("object form off: %s", b)
}
}
func TestWorkBuddyEfforts(t *testing.T) {
e := workbuddyModel("x/y", "Y", 0, 500000, true, []string{"none", "low", "high"})
r, _ := e["reasoning"].(map[string]any)
if e["maxInputTokens"] != 200000 || e["maxOutputTokens"] != zcodeMaxOutput || e["supportsReasoning"] != true ||
r["canDisableThinking"] != true || r["defaultEffort"] != "high" || len(r["supportedEfforts"].([]string)) != 2 {
t.Fatalf("%v", e)
}
if e := workbuddyModel("x/z", "Z", 1000, 0, false, nil); e["reasoning"] != nil || e["supportsReasoning"] != false || e["maxOutputTokens"] != nil {
t.Fatalf("%v", e)
}
}