agents: Qwen Code (qwen) as a managed agent (#1311)

* agents: Qwen Code (qwen) as a managed agent (#1279)

Qwen Code (QwenLM/qwen-code, a Gemini CLI fork) keeps custom model
providers in ~/.qwen/settings.json modelProviders.<protocol> entries
({baseUrl, envKey, id, name}, envKey naming a variable in the same
file's env object) and its session model in settings.model
{name, baseUrl}.

magpie writes one modelProviders.openai entry per catalog model —
wire id the gateway takes, name magpie/<provider>/<model>, every one
on env.MAGPIE_API_KEY holding the gateway's key, which is how an entry
of magpie's is told from the user's own — and points settings.model at
a pick. Stash, Check/drift, Sync, port-move Rewire and disconnect work
as for the other JSON-config agents; on disconnect the user's entries,
model and key are restored, the file byte-identical to before.

Verified: go build, GOOS=linux nogui + GOOS=windows builds, go vet
./...; go test -tags nogui ./internal/agent (TestQwen* pass;
TestDriftUnwiredEveryAgent/qwen and TestPortMovesEveryAgent pass after
binding drift identity to the entry, not the address), go test -race
-count=3 -run TestQwen, go test . (TestAgentQwenDefault: set then
'default' restores the settings byte-for-byte). TestQwen fails with
'unknown agent' when the registration is reverted. Real use: a copy of
a live 341-line settings.json in a sandbox HOME — wire, then
'qwen default' left it byte-identical. Not run: GUI suite (no
gui/assets change); TestReasonixCLI* and TestPluginHTTP2TakesTheProxy
fail on main too (fixture/network), unchanged by this.

* agents: qwen honors QWEN_HOME, sets auth type, owns a qwen-specific key name, carries context windows (#1279 review)

- $QWEN_HOME stands for ~/.qwen, resolved as the other agents'
  (via place.getenv, appdir.Getenv; a distro's isn't read), and is in
  agentenv.Vars so the sandbox clears it
- wiring sets security.auth.selectedType openai so qwen skips the
  /auth prompt; the user's own (including a pre-existing "openai",
  told from magpie's by a stash marker) is restored on disengage
- envKey is MAGPIE_QWEN_API_KEY: a shell-exported or ~/.qwen/.env
  value wins over the settings' env, so the magpie-generic name is
  not used
- entries carry generationConfig.contextWindowSize from the catalog
- a settings.json without modelProviders reads as no models of anyone,
  instead of gjson's 'cannot use null as iterable'

Verified: TestQwen* incl. TestQwenAuthOpenAIOfTheUsersOwn and
TestQwenHomeOverride, TestAgentQwenDefault (CLI), -race -count=3;
unwired-drift and port-move generic tests pass. Real end-to-end:
dev gateway on 127.0.0.1:3999 in a sandbox HOME, real qwen binary in
a sandbox QWEN_HOME ran a prompt through the gateway (PONG), and
wire → default left the settings byte-identical. Reasonix fixture
tests fail on main too (environment).

* agents: qwen's requests are attributed by their QwenCode/<version> User-Agent
This commit is contained in:
易良 authored and GitHub committed 2026-10-08 20:33:11 +08:00
1 parent b56476b970
commit ae39bc8ec8
8 files changed
+855 -1

No files matched your search

+1 -1
View File
@@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
<table>
<tr><td>
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
</td></tr>
</table>
+1
View File
@@ -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) |
+1
View File
@@ -97,6 +97,7 @@ func All() []*Agent {
hermes(home),
morph(home),
kimi(home),
qwen(home),
muse(cfg),
empryo(home),
ante(home),
+431
View File
@@ -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.<protocol> 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 ("<provider>/<model>") and the name
// "magpie/<provider>/<model>", 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/<version> (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)
}
+347
View File
@@ -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)
}
}
+12
View File
@@ -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 {
+2
View File
@@ -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
+60
View File
@@ -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)
}
}