diff --git a/README.md b/README.md index e3ed7e76..ea308a7f 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
-Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy +Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
diff --git a/docs/reference.md b/docs/reference.md index 3f28deb3..3e7d41db 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -108,6 +108,7 @@ line; agents connected to magpie lose it when it quits. | Hermes Agent | `~/.hermes/config.yaml` (`$HERMES_HOME`) | model | | Mister Morph | `~/.morph/config.yaml` (`$MISTER_MORPH_CONFIG`) | model, effort (`llm` on the gateway as `openai_response_compatible`, the Responses API; what it had comes back when you switch away) | | Kimi Code | `~/.kimi/config.toml` (`$KIMI_SHARE_DIR`) | model (a `magpie` provider; magpie's models in Kimi's /model) | +| Qwen Code | `~/.qwen/settings.json` (`$QWEN_HOME`) | model (magpie's models as `modelProviders.openai` entries on a `MAGPIE_QWEN_API_KEY` env var; settings.model; `security.auth.selectedType` openai while wired) | | Muse Code | `~/.config/muse/settings.json` (`$XDG_CONFIG_HOME`) | model (endpoint_transport to the gateway, auth none; magpie's models in Muse's list) | | Empryo | `~/.empryo/config.json` | defaultModel (a `magpie` provider at the gateway in `providers`) | | Ante | `~/.ante/catalog.json` and `settings.json` (`$ANTE_HOME`) | provider, model (a `magpie` provider on OpenAiCompatible, with no auth of magpie's: the gateway lets this machine in with any token and Ante counts a provider with none as authenticated; magpie's models in Ante's model picker, and Ante started on magpie, which is what its settings' `provider` decides) | diff --git a/internal/agent/agents.go b/internal/agent/agents.go index a6dff62f..6e397be5 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -97,6 +97,7 @@ func All() []*Agent { hermes(home), morph(home), kimi(home), + qwen(home), muse(cfg), empryo(home), ante(home), diff --git a/internal/agent/qwen.go b/internal/agent/qwen.go new file mode 100644 index 00000000..7896b6ae --- /dev/null +++ b/internal/agent/qwen.go @@ -0,0 +1,431 @@ +package agent + +// Qwen Code (`qwen`, QwenLM/qwen-code, a Gemini CLI fork) keeps its +// settings in ~/.qwen/settings.json ($QWEN_HOME when set). Models of the +// user's own are the modelProviders. arrays, each +// {baseUrl, envKey, id, name}: id is the model asked for, name what its +// /model picker shows, and envKey names a variable in the same file's env +// object (or ~/.qwen/.env) with the key. The model sessions start on is +// settings.model {name, baseUrl}, baseUrl telling two entries of one id +// apart, and security.auth.selectedType "openai" skips the /auth prompt +// on start. +// +// magpie appends one entry per catalog model to modelProviders.openai, +// the wire id the gateway takes ("/") and the name +// "magpie//", every one on the same env key of its own, +// which is how an entry of magpie's is told from the user's own, and +// points settings.model and security.auth.selectedType at a pick. The key +// itself lives in the settings' env object — a shell-exported or +// ~/.qwen/.env variable of the same name would win over it, which the +// qwen-specific name makes unlikely. The user's entries stay as they were +// and in front, so their own ids don't move, and the default, auth type +// and any key of theirs they had are stashed and put back when magpie +// steps out. + +import ( + "encoding/json" + "path/filepath" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/jsonc" + + "github.com/yetone/magpie/internal/edit" +) + +const ( + qwenModels = "modelProviders.openai" + qwenModel = "model.name" + qwenModelAt = "model.baseUrl" + qwenAuth = "security.auth.selectedType" + qwenEnv = "env." + qwenEnvKey + // qwenEnvKey names the variable magpie's provider entries take their + // key from: qwen-specific so a shell or ~/.qwen/.env value of the + // name is unlikely to shadow the settings' (their priority over it is + // why "MAGPIE_API_KEY" is not used), and set in the settings' env + // object while magpie is wired in, which names one of magpie's + // entries wherever it appears. + qwenEnvKey = "MAGPIE_QWEN_API_KEY" +) + +// qwenEntry is one modelProviders.openai member magpie writes, fields +// ordered as Qwen Code orders its own (alphabetically). +type qwenEntry struct { + BaseURL string `json:"baseUrl"` + EnvKey string `json:"envKey"` + Generation *qwenGen `json:"generationConfig,omitempty"` + ID string `json:"id"` + Name string `json:"name"` +} + +// qwenGen is the generationConfig a model's entry may carry. +type qwenGen struct { + ContextWindowSize int `json:"contextWindowSize,omitempty"` +} + +// qwenEntriesAt are magpie's entries for a qwen reaching the gateway at +// v1. +func qwenEntriesAt(v1 string) []qwenEntry { + var out []qwenEntry + for _, m := range magpieModels("qwen") { + e := qwenEntry{ID: m.ID, Name: magpieID + "/" + m.ID, BaseURL: v1, EnvKey: qwenEnvKey} + if m.Context > 0 { + e.Generation = &qwenGen{ContextWindowSize: m.Context} + } + out = append(out, e) + } + return out +} + +// qwenCustom is one modelProviders.openai member as it is in the file: +// its raw JSON, and what of it the picker needs. +type qwenCustom struct { + raw json.RawMessage + id, name string + base, envKey string + ours bool +} + +// qwenCustoms reads modelProviders.openai of a settings.json. +func qwenCustoms(path string) []qwenCustom { + raw, err := edit.Read(path) + if err != nil || len(raw) == 0 { + return nil + } + var out []qwenCustom + all := gjson.GetBytes(jsonc.ToJSONInPlace(raw), qwenModels) + if !all.IsArray() { + // no modelProviders (or none of openai): none of magpie's and + // none of the user's, rather than an error ForEach gives for null + return nil + } + all.ForEach(func(_, v gjson.Result) bool { + c := qwenCustom{ + raw: json.RawMessage(v.Raw), + id: v.Get("id").String(), + name: v.Get("name").String(), + base: v.Get("baseUrl").String(), + envKey: v.Get("envKey").String(), + } + c.ours = c.envKey == qwenEnvKey + out = append(out, c) + return true + }) + return out +} + +// qwenModelOf reads settings.model: the model's id, and the baseUrl +// telling one of its two entries apart ("" when unset). +func qwenModelOf(path string) (name, base string) { + name, _ = edit.GetJSON(path, qwenModel) + base, _ = edit.GetJSON(path, qwenModelAt) + return name, base +} + +// qwenPrune takes a parent key out once the last thing in it is gone: the +// "env": {} or "modelProviders": {} that emptying magpie's writes would +// leave was never the user's. +func qwenPrune(path, parent string) error { + if v, ok := edit.GetJSON(path, parent); ok && strings.TrimSpace(v) == "{}" { + return edit.DelJSON(path, parent) + } + return nil +} + +// qwenFind finds the entry settings.model points at: by id and baseUrl, +// or the first of the id when no baseUrl is set. +func qwenFind(cs []qwenCustom, name, base string) *qwenCustom { + for i := range cs { + if cs[i].id == name && (base == "" || cs[i].base == base) { + return &cs[i] + } + } + return nil +} + +func qwen(home string) *Agent { return qwenIn(here(home)) } + +// qwenIn is Qwen Code at a place: this machine's home, or a WSL distro's +// (see wsl.go), whose entries name the gateway as the distro reaches it, +// with the key it takes from there. +func qwenIn(at place) *Agent { + dir := filepath.Join(at.home, ".qwen") + if d := at.getenv("QWEN_HOME"); d != "" { + dir = d + } + path := filepath.Join(dir, "settings.json") + entries := func() []qwenEntry { return qwenEntriesAt(at.v1()) } + keyName := "qwen:" + path + ":" + qwenModel + keyBase := "qwen:" + path + ":" + qwenModelAt + keyAuth := "qwen:" + path + ":" + qwenAuth + // keyAuthSeen marks the user's own auth type already stashed (or + // known absent), so a later wire doesn't take magpie's own "openai" + // for it + keyAuthSeen := keyAuth + ".seen" + keyEnv := "qwen:" + path + ":" + qwenEnv + + // get spells an entry of magpie's as the catalog does + get := func() string { + name, base := qwenModelOf(path) + if name == "" { + return "" + } + if c := qwenFind(qwenCustoms(path), name, base); c != nil && c.ours { + return magpieID + "/" + c.id + } + return name + } + + // setEnvKey writes env.MAGPIE_API_KEY as the gateway's key; with=false + // puts back what was stashed, or takes the key out where it still is + // magpie's. A value of the user's own under that name is stashed before + // it is replaced and never removed: magpie only removes what it wrote. + setEnvKey := func(with bool) error { + if with { + if v, ok := edit.GetJSON(path, qwenEnv); ok && v != at.gwKey() { + stash(map[string]string{keyEnv: v}) + } + return edit.SetJSON(path, edit.KV{Path: qwenEnv, Value: at.gwKey()}) + } + if was := unstash(keyEnv); was != "" { + return edit.SetJSON(path, edit.KV{Path: qwenEnv, Value: was}) + } + if v, ok := edit.GetJSON(path, qwenEnv); ok && v == at.gwKey() { + if err := edit.DelJSON(path, qwenEnv); err != nil { + return err + } + return qwenPrune(path, "env") + } + return nil + } + + // setAuth sets security.auth.selectedType to openai while magpie's + // models are the pick (skipping the /auth prompt), stashing the user's + // own; with=false puts it back, or takes the key out where it never + // was, pruning the empty security.auth and security it would leave. + setAuth := func(with bool) error { + if with { + // the user's own value is stashed once, whatever it is — + // "openai" of their own too, or it can't be told from + // magpie's on a later wire or Sync and comes off with it + if _, seen := stashLoad()[keyAuthSeen]; !seen { + kv := map[string]string{keyAuthSeen: "1"} + if v, ok := edit.GetJSON(path, qwenAuth); ok { + kv[keyAuth] = v + } + stash(kv) + } + return edit.SetJSON(path, edit.KV{Path: qwenAuth, Value: "openai"}) + } + forget(keyAuthSeen) + if was := unstash(keyAuth); was != "" { + return edit.SetJSON(path, edit.KV{Path: qwenAuth, Value: was}) + } + if err := edit.DelJSON(path, qwenAuth); err != nil { + return err + } + if err := qwenPrune(path, "security.auth"); err != nil { + return err + } + return qwenPrune(path, "security") + } + + // setProviders writes modelProviders.openai with the user's own and, + // when with, magpie's after them; none left takes the array out. A + // with=false when none were magpie's changes nothing. + setProviders := func(with bool) error { + var ms []any + var had bool + for _, c := range qwenCustoms(path) { + if c.ours { + had = true + continue + } + ms = append(ms, c.raw) + } + if with { + if err := setEnvKey(true); err != nil { + return err + } + if err := setAuth(true); err != nil { + return err + } + for _, e := range entries() { + ms = append(ms, e) + } + } else if !had { + // nothing of magpie's left: a key it left behind, and the + // auth type it set, still go + if err := setEnvKey(false); err != nil { + return err + } + return setAuth(false) + } + if len(ms) > 0 { + if err := edit.SetJSON(path, edit.KV{Path: qwenModels, Value: ms}); err != nil { + return err + } + } else if err := edit.DelJSON(path, qwenModels); err != nil { + return err + } + if !with { + if err := setEnvKey(false); err != nil { + return err + } + if err := setAuth(false); err != nil { + return err + } + return qwenPrune(path, "modelProviders") + } + return nil + } + + // put sets settings.model to (name, base), or takes it out for "": + // the "model": {} that Qwen Code never wrote is removed with its last + // member. + put := func(name, base string) error { + if name != "" { + if base != "" { + if err := edit.SetJSON(path, edit.KV{Path: qwenModelAt, Value: base}); err != nil { + return err + } + } else if err := edit.DelJSON(path, qwenModelAt); err != nil { + // baseUrl unset stays unset: qwen treats "" as a real + // endpoint of its own in the composite match + return err + } + return edit.SetJSON(path, edit.KV{Path: qwenModel, Value: name}) + } + if err := edit.DelJSON(path, qwenModelAt); err != nil { + return err + } + if err := edit.DelJSON(path, qwenModel); err != nil { + return err + } + if v, ok := edit.GetJSON(path, "model"); ok && strings.TrimSpace(v) == "{}" { + return edit.DelJSON(path, "model") + } + return nil + } + + return atomic(&Agent{ + ID: "qwen", Name: "Qwen Code", Icon: "qwen-color", Aliases: []string{"qwen-code", "qwen-cli"}, Spelled: prefixed, + // qwen's requests carry QwenCode/ (packages/core/src/core/ + // openaiContentGenerator/provider/default.ts buildHeaders) + UA: []string{"qwencode"}, + Bin: "qwen", Dir: dir, Path: path, + Sync: func() error { + for _, c := range qwenCustoms(path) { + if c.ours { + return setProviders(true) + } + } + return nil + }, + Notice: func() string { + if Running(`(^|/)qwen( |$)`) { + return "Qwen Code reads a session's model at start-up — start a new session, or /model anew, to use this." + } + return "" + }, + Check: func() string { + // settings.model is magpie's by the entry it names, wherever + // that entry points: another address is drift to report, not + // a model of the user's + name, _ := qwenModelOf(path) + if name == "" { + return "" + } + var c *qwenCustom + cs := qwenCustoms(path) + for i := range cs { + if cs[i].ours && cs[i].id == name { + c = &cs[i] + break + } + } + if c == nil { + return "" + } + if v, ok := edit.GetJSON(path, qwenEnv); !ok || v != at.gwKey() { + return "Qwen Code's " + qwenEnv + " (settings.json) no longer is magpie's key, so magpie's models have none" + } + if v, ok := edit.GetJSON(path, qwenAuth); !ok || v != "openai" { + return "Qwen Code's security.auth.selectedType (settings.json) no longer is openai, so it asks /auth instead of using magpie" + } + return wiringOff("Qwen Code", path, func(k string) (string, bool) { + g := gjson.GetBytes(c.raw, k) + return g.String(), g.Exists() + }, "baseUrl", at.v1()) + }, + Fields: []Field{{ + Key: "model", Label: "model", + Get: get, + Set: func(v string) error { + curName, curBase := qwenModelOf(path) + ours := qwenFind(qwenCustoms(path), curName, curBase) + onOurs := ours != nil && ours.ours + if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) { + if !onOurs { + kv := map[string]string{keyBase: curBase} + if curName != "" { + kv[keyName] = curName + } else { + forget(keyName) + } + stash(kv) + } + if err := setProviders(true); err != nil { + return err + } + return put(ref, at.v1()) + } + base := "" + fromStash := false + if onOurs { + // back to what the user had, name and its baseUrl + if v == "" { + v, base, fromStash = unstash(keyName), unstash(keyBase), true + } else { + forget(keyName, keyBase) + } + } + if v != "" && !fromStash { + if c := qwenFind(qwenCustoms(path), v, ""); c != nil { + base = c.base + } + } + if err := put(v, base); err != nil { + return err + } + return setProviders(false) + }, + Options: func(cur map[string]string) []Option { + return append(qwenOwnOptions(path, cur["model"]), viaMagpie("qwen", magpieID+"/")...) + }, + }}, + }, path) +} + +// qwenOwnOptions are the models of the user's own the settings file has: +// every modelProviders.openai entry that isn't magpie's, and the current +// pick. +func qwenOwnOptions(path, cur string) []Option { + seen := map[string]bool{} + var out []Option + for _, c := range qwenCustoms(path) { + if c.ours || seen[c.id] { + continue + } + seen[c.id] = true + label := c.name + if label == "" || label == c.id { + label = "" + } + out = append(out, Option{Value: c.id, Label: label, Icon: modelIcon("", c.id)}) + } + if cur != "" && !seen[cur] && !strings.HasPrefix(cur, magpieID+"/") { + out = append([]Option{{Value: cur, Icon: modelIcon("", cur)}}, out...) + } + return group("Qwen Code", out) +} diff --git a/internal/agent/qwen_test.go b/internal/agent/qwen_test.go new file mode 100644 index 00000000..810fcc25 --- /dev/null +++ b/internal/agent/qwen_test.go @@ -0,0 +1,347 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/gateway" + "github.com/yetone/magpie/internal/provider" +) + +// qwenUser is a settings.json as Qwen Code leaves it for someone with a +// custom model provider of their own, and settings magpie has nothing to +// do with. +const qwenUser = `{ + "env": { + "IDEALAB_API_KEY": "team-key" + }, + "modelProviders": { + "openai": [ + { + "id": "glm-5", + "name": "[IdeaLab] glm-5", + "baseUrl": "https://idealab.example.com/api/openai/v1", + "envKey": "IDEALAB_API_KEY" + } + ] + }, + "model": { + "name": "glm-5", + "baseUrl": "https://idealab.example.com/api/openai/v1" + }, + "outputLanguage": "zh-CN" +} +` + +func qwenHome(t *testing.T) (home, path string) { + t.Helper() + home = syncHome(t) + for _, p := range []provider.Provider{ + {ID: "deepseek", Name: "DeepSeek", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"pro", "flash"}}, + {ID: "anth", Name: "Anth", Key: "k", Anthropic: "http://127.0.0.1:1", Models: []string{"claude-sonnet-5"}}, + } { + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + } + path = filepath.Join(home, ".qwen", "settings.json") + os.MkdirAll(filepath.Dir(path), 0o755) + return home, path +} + +// qwenFile is settings.json read back: the modelProviders.openai entries +// by id, their order, and the whole file. +func qwenFile(t *testing.T, path string) (map[string]map[string]any, []string, map[string]any) { + t.Helper() + var f map[string]any + if err := json.Unmarshal([]byte(readFile(path)), &f); err != nil { + t.Fatalf("%v\n%s", err, readFile(path)) + } + byID := map[string]map[string]any{} + var order []string + mp, _ := f["modelProviders"].(map[string]any) + ms, _ := mp["openai"].([]any) + for _, m := range ms { + e := m.(map[string]any) + id, _ := e["id"].(string) + byID[id] = e + order = append(order, id) + } + return byID, order, f +} + +func TestQwen(t *testing.T) { + home, path := qwenHome(t) + os.WriteFile(path, []byte(qwenUser), 0o644) + a, err := Find("qwen") + if err != nil || a.Path != path { + t.Fatalf("find: %v %v", a, err) + } + if !a.Detected() { + t.Fatal("not detected with ~/.qwen") + } + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("model: %q", got) + } + // the picker: the user's own entries, then magpie's catalog + opts := a.Field("model").Options(a.Values()) + if opts[0].Value != "glm-5" || opts[0].Label != "[IdeaLab] glm-5" || opts[0].Group != "Qwen Code" { + t.Fatalf("own options: %+v", opts[:1]) + } + var via bool + for _, o := range opts { + via = via || o.Value == "magpie/deepseek/pro" + } + if !via { + t.Fatalf("no magpie/deepseek/pro in %+v", opts) + } + + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + ms, order, f := qwenFile(t, path) + if order[0] != "glm-5" { + t.Fatalf("the user's entry moved: %v", order) + } + // the user's entry is as it was + if !strings.Contains(readFile(path), qwenUser[strings.Index(qwenUser, " {"):strings.Index(qwenUser, " }\n")+7]) { + t.Fatalf("user's entry rewritten:\n%s", readFile(path)) + } + m := f["model"].(map[string]any) + if m["name"] != "deepseek/pro" || m["baseUrl"] != gatewayV1() { + t.Fatalf("model: %v", m) + } + env := f["env"].(map[string]any) + if env["IDEALAB_API_KEY"] != "team-key" || env["MAGPIE_QWEN_API_KEY"] != gateway.Token { + t.Fatalf("env: %v", env) + } + if auth := f["security"].(map[string]any)["auth"].(map[string]any)["selectedType"]; auth != "openai" { + t.Fatalf("selectedType: %v", auth) + } + if f["outputLanguage"] != "zh-CN" { + t.Fatalf("settings: %v", f) + } + for _, id := range []string{"deepseek/pro", "deepseek/flash", "anth/claude-sonnet-5"} { + e := ms[id] + if e == nil { + t.Errorf("%s missing: %v", id, order) + continue + } + if e["name"] != "magpie/"+id || e["baseUrl"] != gatewayV1() || e["envKey"] != "MAGPIE_QWEN_API_KEY" { + t.Errorf("%s: %v", id, e) + } + } + if got := a.Values()["model"]; got != "magpie/deepseek/pro" { + t.Fatalf("model read back: %q", got) + } + if d := a.Check(); d != "" { + t.Fatalf("check: %s", d) + } + // another of magpie's: the stash keeps the user's own + if err := a.Apply("model", "magpie/anth/claude-sonnet-5"); err != nil { + t.Fatal(err) + } + // a provider added since joins the picker on Sync + provider.Save(provider.Provider{ID: "later", Name: "Later", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1"}}) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if ms, _, _ = qwenFile(t, path); ms["later/m1"] == nil { + t.Fatalf("sync: %v", ms) + } + // something else rewrote magpie's entry: its baseUrl is the one + // before its name, as the fields are ordered + cur := readFile(path) + at := strings.Index(cur, `"magpie/anth/claude-sonnet-5"`) + base := strings.LastIndex(cur[:at], gatewayV1()) + if base < 0 { + t.Fatal("no baseUrl before the entry's name:\n" + cur) + } + edited := cur[:base] + "http://127.0.0.1:9/v1" + cur[base+len(gatewayV1()):] + os.WriteFile(path, []byte(edited), 0o644) + if d := a.Check(); !strings.Contains(d, "baseUrl") { + t.Fatalf("check after a moved baseUrl: %q, model %q", d, a.Values()["model"]) + } + a.Sync() + if d := a.Check(); d != "" { + t.Fatalf("check after sync: %s", d) + } + + // reset: the user's model back, magpie's entries and key gone, the + // rest as it was + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + if got := readFile(path); got != qwenUser { + t.Fatalf("not restored:\n%s", got) + } + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("reset model: %q", got) + } + _ = home +} + +// Picking one of the user's own models while on magpie's takes magpie out. +func TestQwenOwnModel(t *testing.T) { + _, path := qwenHome(t) + os.WriteFile(path, []byte(qwenUser), 0o644) + a := qwen(os.Getenv("HOME")) + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + if err := a.Apply("model", "glm-5"); err != nil { + t.Fatal(err) + } + ms, order, f := qwenFile(t, path) + if len(order) != 1 || ms["glm-5"] == nil { + t.Fatalf("models: %v", order) + } + m := f["model"].(map[string]any) + if m["name"] != "glm-5" || m["baseUrl"] != "https://idealab.example.com/api/openai/v1" { + t.Fatalf("model: %v", m) + } + env := f["env"].(map[string]any) + if _, ok := env["MAGPIE_QWEN_API_KEY"]; ok { + t.Fatalf("key left: %v", env) + } + // and a reset after that leaves the user's choice, not the stash + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + a.Apply("model", "") + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("reset: %q", got) + } +} + +// No settings.json at all: magpie adds only its own, and a reset leaves +// the file as empty as it began. +func TestQwenFresh(t *testing.T) { + home, path := qwenHome(t) + a := qwen(home) + if a.Values()["model"] != "" { + t.Fatal("model without a file") + } + if err := a.Apply("model", "magpie/deepseek/flash"); err != nil { + t.Fatal(err) + } + if _, order, f := qwenFile(t, path); len(order) != 4 { + t.Fatalf("models: %v", order) + } else if env := f["env"].(map[string]any); env["MAGPIE_QWEN_API_KEY"] != gateway.Token { + t.Fatalf("env: %v", env) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(readFile(path)); got != "{}" { + t.Fatalf("left: %s", got) + } + // a file Qwen Code never put a modelProviders in reads as no models + // of anyone at all, and magpie touches none of it + os.WriteFile(path, []byte("{\n \"fastModel\": \"qwen3.8-flash\"\n}\n"), 0o644) + if got := a.Values()["model"]; got != "" { + t.Fatalf("model without modelProviders: %q", got) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + if got := readFile(path); got != "{\n \"fastModel\": \"qwen3.8-flash\"\n}\n" { + t.Fatalf("nothing of magpie's to take out:\n%s", got) + } +} + +// A model the user picked without a matching entry keeps no baseUrl; +// one of the user's under magpie's own env key name, and their auth +// type, are stashed and put back when magpie steps out. +func TestQwenBareModelAndOwnEnvKey(t *testing.T) { + _, path := qwenHome(t) + bare := `{ + "env": { + "MAGPIE_QWEN_API_KEY": "the-user's-own" + }, + "security": { + "auth": { + "selectedType": "qwen-oauth" + } + }, + "model": { + "name": "qwen3.8-max" + } +} +` + os.WriteFile(path, []byte(bare), 0o644) + a := qwen(os.Getenv("HOME")) + if got := a.Values()["model"]; got != "qwen3.8-max" { + t.Fatalf("bare model: %q", got) + } + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + if _, _, f := qwenFile(t, path); f["env"].(map[string]any)["MAGPIE_QWEN_API_KEY"] != gateway.Token { + t.Fatalf("key not magpie's: %v", f["env"]) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + var f map[string]any + json.Unmarshal([]byte(readFile(path)), &f) + if f["env"].(map[string]any)["MAGPIE_QWEN_API_KEY"] != "the-user's-own" { + t.Fatalf("key not restored: %v", f["env"]) + } + if got := f["security"].(map[string]any)["auth"].(map[string]any)["selectedType"]; got != "qwen-oauth" { + t.Fatalf("auth not restored: %v", got) + } + m, ok := f["model"].(map[string]any) + if !ok || m["name"] != "qwen3.8-max" { + t.Fatalf("model not restored: %v", m) + } + if _, ok = m["baseUrl"]; ok { + t.Fatalf("baseUrl it never had: %v", m) + } +} + +// An auth type of "openai" the user set themselves is told from +// magpie's, and stays when magpie steps out. +func TestQwenAuthOpenAIOfTheUsersOwn(t *testing.T) { + _, path := qwenHome(t) + os.WriteFile(path, []byte(`{ + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3.8-max" + } +} +`), 0o644) + a := qwen(os.Getenv("HOME")) + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + var f map[string]any + json.Unmarshal([]byte(readFile(path)), &f) + if got := f["security"].(map[string]any)["auth"].(map[string]any)["selectedType"]; got != "openai" { + t.Fatalf("the user's own openai came off: %v", f) + } +} + +// $QWEN_HOME moves the config folder Qwen Code looks at. +func TestQwenHomeOverride(t *testing.T) { + home, _ := qwenHome(t) + other := filepath.Join(home, "elsewhere") + t.Setenv("QWEN_HOME", other) + a := qwen(home) + if a.Dir != other || a.Path != filepath.Join(other, "settings.json") { + t.Fatalf("override: %s %s", a.Dir, a.Path) + } +} diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index 73b70803..3c89da80 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -335,6 +335,18 @@ var wslKinds = []wslKind{ return append(kimiOwnOptions("", cur["model"]), viaMagpie("kimi", magpieID+"/")...) } }}, + // Qwen Code's ~/.qwen, one settings.json of the same shape as this + // machine's; a stopped distro's is looked at once it is started + {id: "qwen", name: "Qwen Code", dir: ".qwen", bin: "qwen", in: qwenIn, + restart: "reads a session's model at start-up — start a new session, or /model anew, to use this.", + asleep: func(key string) func(map[string]string) []Option { + if key != "model" { + return nil + } + return func(cur map[string]string) []Option { + return append(qwenOwnOptions("", cur["model"]), viaMagpie("qwen", magpieID+"/")...) + } + }}, {id: "omp", name: "omp", dir: ".omp", bin: "omp", in: ompIn, version: true, restart: "reads its settings at start-up — restart open omp sessions to use this.", asleep: func(key string) func(map[string]string) []Option { diff --git a/internal/agentenv/agentenv.go b/internal/agentenv/agentenv.go index 5fef59e7..f5a640fc 100644 --- a/internal/agentenv/agentenv.go +++ b/internal/agentenv/agentenv.go @@ -51,6 +51,8 @@ var Vars = []string{ "GROK_HOME", "GROK_BIN_DIR", // Kimi Code and its shared folder "KIMI_CODE_HOME", "KIMI_SHARE_DIR", + // Qwen Code's home, ~/.qwen without it + "QWEN_HOME", // Ante "ANTE_HOME", // MiMo Code, MiniMax Code, OpenHanako, Hermes, dsh, WorkBuddy, CodeBuddy Code diff --git a/qwen_cli_test.go b/qwen_cli_test.go new file mode 100644 index 00000000..a3b73838 --- /dev/null +++ b/qwen_cli_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// `magpie qwen a/m` writes its provider entries and the gateway's key, +// `magpie qwen default` takes magpie back out and restores the model the +// user had, their own provider entry and settings kept. +func TestAgentQwenDefault(t *testing.T) { + groupsHome(t) + path := filepath.Join(os.Getenv("HOME"), ".qwen", "settings.json") + os.MkdirAll(filepath.Dir(path), 0o755) + own := `{ + "env": { + "IDEALAB_API_KEY": "team-key" + }, + "modelProviders": { + "openai": [ + { + "baseUrl": "https://idealab.example.com/v1", + "envKey": "IDEALAB_API_KEY", + "id": "glm-5", + "name": "[IdeaLab] glm-5" + } + ] + }, + "model": { + "baseUrl": "https://idealab.example.com/v1", + "name": "glm-5" + }, + "outputLanguage": "zh-CN" +} +` + if err := os.WriteFile(path, []byte(own), 0o644); err != nil { + t.Fatal(err) + } + read := func() string { + b, _ := os.ReadFile(path) + return string(b) + } + if err := run([]string{"qwen", "a/m"}); err != nil { + t.Fatal(err) + } + s := read() + for _, want := range []string{`"id": "a/m"`, `"name": "magpie/a/m"`, `"MAGPIE_QWEN_API_KEY"`, `"selectedType": "openai"`, `"baseUrl": "https://idealab.example.com/v1"`, `"outputLanguage": "zh-CN"`} { + if !strings.Contains(s, want) { + t.Fatalf("not wired (%s missing):\n%s", want, s) + } + } + if err := run([]string{"qwen", "default"}); err != nil { + t.Fatal(err) + } + if s = read(); s != own { + t.Fatalf("default didn't put back what was there:\n%s", s) + } +}