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:
8 files changed
+855
-1
No files matched your search
@@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
|
|||||||
<table>
|
<table>
|
||||||
<tr><td>
|
<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>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ line; agents connected to magpie lose it when it quits.
|
|||||||
| Hermes Agent | `~/.hermes/config.yaml` (`$HERMES_HOME`) | model |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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`) |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func All() []*Agent {
|
|||||||
hermes(home),
|
hermes(home),
|
||||||
morph(home),
|
morph(home),
|
||||||
kimi(home),
|
kimi(home),
|
||||||
|
qwen(home),
|
||||||
muse(cfg),
|
muse(cfg),
|
||||||
empryo(home),
|
empryo(home),
|
||||||
ante(home),
|
ante(home),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -335,6 +335,18 @@ var wslKinds = []wslKind{
|
|||||||
return append(kimiOwnOptions("", cur["model"]), viaMagpie("kimi", magpieID+"/")...)
|
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,
|
{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.",
|
restart: "reads its settings at start-up — restart open omp sessions to use this.",
|
||||||
asleep: func(key string) func(map[string]string) []Option {
|
asleep: func(key string) func(map[string]string) []Option {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ var Vars = []string{
|
|||||||
"GROK_HOME", "GROK_BIN_DIR",
|
"GROK_HOME", "GROK_BIN_DIR",
|
||||||
// Kimi Code and its shared folder
|
// Kimi Code and its shared folder
|
||||||
"KIMI_CODE_HOME", "KIMI_SHARE_DIR",
|
"KIMI_CODE_HOME", "KIMI_SHARE_DIR",
|
||||||
|
// Qwen Code's home, ~/.qwen without it
|
||||||
|
"QWEN_HOME",
|
||||||
// Ante
|
// Ante
|
||||||
"ANTE_HOME",
|
"ANTE_HOME",
|
||||||
// MiMo Code, MiniMax Code, OpenHanako, Hermes, dsh, WorkBuddy, CodeBuddy Code
|
// MiMo Code, MiniMax Code, OpenHanako, Hermes, dsh, WorkBuddy, CodeBuddy Code
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user