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)
+ }
+}