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:
4 files changed
+341
No files matched your search
@@ -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) |
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ func All() []*Agent {
|
||||
qoderCN(home),
|
||||
grok(home),
|
||||
zcode(home),
|
||||
workbuddy(home),
|
||||
hanako(home),
|
||||
alma(),
|
||||
cindy(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user