Files
magpie/internal/agent/qoder.go
T
yetone dd70a4055a wsl: OpenCode, Kimi Code, omp and the other CLIs magpie sets up through a file in the home are set up in WSL distros too (#515: on Windows only Codex, Pi, OmO and Claude Code were found in a distro) — fourteen agents join wslKinds, each found by its folder under the distro's $HOME or its command on the distro's own PATH, and listed as <name> · WSL <distro> (opencode@wsl:Ubuntu…): OpenCode (~/.config/opencode, opencode), MiMo Code (~/.config/mimocode, mimo), Kimi Code (~/.kimi-code, kimi; the old kimi-cli's ~/.kimi where that is all there is, found by the command), omp (~/.omp/agent, omp), Crush (~/.config/crush/crush.json, Linux's place, not Windows' LOCALAPPDATA), Hermes Agent (~/.hermes), Grok Build (~/.grok, by its folder alone, as on this machine), Droid (~/.factory), fx (~/.fx, by its folder alone: fx is also the JSON viewer), Command Code (~/.commandcode), MiniMax Code's mcode (~/.minimax), Muse Code (~/.config/muse), Qoder and Qoder CN (~/.qoder, ~/.qoder-cn). Each is its host constructor at a place (opencodeIn, kimiIn, ompIn, crushIn, hermesIn, grokIn, droidIn, fxIn, commandCodeIn, miniMaxIn, museIn, qoderIn/qoderCNIn; the host's opencode(home, cfg) and the rest wrap them), so it writes the same keys and schema as on this machine, but the gateway as the distro reaches it (place.gw/v1: 127.0.0.1 when mirrored, Windows' address under NAT) in its provider, its Check and its Sync, and its folder at the default, as the distro's variables (OPENCODE_CONFIG_DIR, KIMI_CODE_HOME, HERMES_HOME, FACTORY_HOME_OVERRIDE, MINIMAX_DATA_DIR, QODER_CONFIG_DIR…) aren't read (place.getenv is "" there); OpenChamber, a Windows app, isn't looked at for a distro's OpenCode, and omp there, whose version isn't known, gets xhigh rather than a max an older omp refuses. Their notices are the restart advice of their own, as for Codex. A stopped distro's agent is built without looking at its files (place.cold: exists/isDir are false, so the defaults are taken), its options read none (wslOwnAsleep, kimiOwnOptions/ompOwnOptions/miniMaxOwnOptions without a file), and a pick, once the distro is started, builds the agent again warm, so it writes the file the agent reads — an opencode.jsonc already there, ~/.kimi for kimi-cli — not the default. Left out: Gemini CLI and Antigravity (a key lent through .env, a launch command), dsh (profiles and .env), Devin (no gateway; its model list from the devin command on this machine), Goose (no magpie routing; its name is pressly's goose too), Cursor and Copilot (no magpie routing), and the apps (Cline, Hanako, WorkBuddy, ZCode, Claude Desktop, OpenChamber, Alma, Cindy); and the library's skills, MCP and instructions for the new WSL agents, which wslTargetOf still gives only Claude Code, Codex, Pi and OmO. Nothing ran on Windows or in a real WSL. wsl_more_test.go, on fake distro roots under temp dirs with the host's variables pointed elsewhere: TestWSLProbeFindsMoreAgents (each folder and command in the probe, none for grok and fx, kimi-cli by its command), TestWSLMoreAgentsDiscovered (all fourteen listed, no command, UA or aliases), TestWSLMoreAgentsPick (each agent, mirrored and NAT: its file in the distro names the distro's gateway, no 127.0.0.1 under NAT, Check passes, Sync keeps it, the notice; this machine's home and the folders its variables name untouched), TestWSLMoreAgentsStopped (nothing asked nor opened, the last value shown, options, a pick starts the distro and writes there) fail without the change; TestWSLStoppedPickFindsItsFiles fails without the warm rebuild (the pick wrote a second opencode.json beside the .jsonc). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and vet ./internal/..., GOOS=windows go test -c ./internal/agent/ ./internal/library/, and go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState failed once on its TempDir cleanup race, passes on the rerun; untouched).
2026-10-02 14:16:38 +08:00

301 lines
9.8 KiB
Go

package agent
// Qoder's CLI (1.1) keeps its settings in $QODER_CONFIG_DIR/settings.json,
// ~/.qoder by default. A provider of one's own is an entry of "providers",
// its models picked as "<provider>/<model>":
//
// {"providers":{"magpie":{"displayName":"magpie","protocol":"openai",
// "baseUrl":"http://127.0.0.1:3425/v1","apiKey":"magpie","model":…,
// "models":[{"model":…,"displayName":…,"contextWindow":…,"maxOutputTokens":…,
// "capabilities":{"tools":true,"vision":…,"thinking":{"modes":["enabled"],
// "supportsEffort":true,"supportedEffortLevels":[…]}}}]}},
// "model":{"name":"magpie/<model>","reasoningEffort":…,
// "preferences":{"magpie/<model>":{"reasoning":{"effort":…}}}}}
//
// Each model's effort is its own, in model.preferences (1.1.6x); the one in
// model.reasoningEffort is asked for only by a model with none there, and
// Qoder moves it into the preferences of the model it starts on, and drops
// it, so it counts once. magpie sets the model's preference, and keeps
// reasoningEffort for the Qoders before preferences.
//
// An openai provider is asked at baseUrl + /chat/completions, with
// reasoning_effort when the model says it takes one. Qoder offers custom
// providers only to a signed-in account whose plan has BYOK. Its requests
// say only undici, so they are not told apart from other clients'.
//
// Qoder CN (qoderclicn, @qodercn-ai/qoderclicn) is the same CLI for the
// China site, its own accounts and settings in $QODERCN_CONFIG_DIR,
// ~/.qoder-cn by default.
import (
"encoding/json"
"path/filepath"
"slices"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
func qoder(home string) *Agent {
return qoderSite(home, qoderGlobal)
}
func qoderCN(home string) *Agent {
return qoderSite(home, qoderChina)
}
// qoderBuild is what tells Qoder's two builds apart.
type qoderBuild struct {
id, name, env, dir, bin string
aliases, procs []string
}
var (
qoderGlobal = qoderBuild{id: "qoder", name: "Qoder", env: "QODER_CONFIG_DIR", dir: ".qoder", bin: "qodercli",
aliases: []string{"qodercli", "qoder-cli"}, procs: []string{`(^|/)qodercli( |$)`, `(^|/)qoder( |$)`}}
qoderChina = qoderBuild{id: "qoder-cn", name: "Qoder CN", env: "QODERCN_CONFIG_DIR", dir: ".qoder-cn", bin: "qoderclicn",
aliases: []string{"qoderclicn", "qodercn", "qodercn-cli"}, procs: []string{`(^|/)qoderclicn( |$)`, `(^|/)qodercn( |$)`}}
)
// qoderIn and qoderCNIn are Qoder's two builds in a WSL distro (see
// wsl.go): at their default folders, as the distro's variables aren't read.
func qoderIn(at place) *Agent { return qoderAt(at, qoderGlobal) }
func qoderCNIn(at place) *Agent { return qoderAt(at, qoderChina) }
func qoderSite(home string, b qoderBuild) *Agent { return qoderAt(here(home), b) }
// qoderAt is a build of Qoder at a place, its provider naming the gateway
// as it reaches it from there.
func qoderAt(at place, b qoderBuild) *Agent {
qoderProvider := func(agent, model string) map[string]any { return qoderProviderAt(agent, model, at.v1()) }
dir := at.getenv(b.env)
if dir == "" {
dir = filepath.Join(at.home, b.dir)
}
path := filepath.Join(dir, "settings.json")
key := b.id + ":" + path + ":"
slot := "providers." + magpieID
get := func(k string) string { v, _ := edit.GetJSON(path, k); return v }
// effort is what the model in use asks for: its preference, else
// reasoningEffort
effort := func() string {
if v := qoderEffort(get("model.preferences"), get("model.name")); v != "" {
return v
}
return get("model.reasoningEffort")
}
setEffort := func(model, v string) error {
kvs := []edit.KV{}
if model != "" {
prefs, drop := qoderPreferences(get("model.preferences"), model, v)
if drop {
if err := edit.DelJSON(path, "model.preferences"); err != nil {
return err
}
} else {
kvs = append(kvs, edit.KV{Path: "model.preferences", Value: prefs})
}
}
if v == "" {
if err := edit.DelJSON(path, "model.reasoningEffort"); err != nil {
return err
}
} else {
kvs = append(kvs, edit.KV{Path: "model.reasoningEffort", Value: v})
}
if len(kvs) == 0 {
return nil
}
return edit.SetJSON(path, kvs...)
}
onMagpie := func() bool {
_, ok := cutMagpie(get("model.name"))
return ok && qoderKeyed(get(slot+".apiKey"), b.id)
}
return &Agent{
ID: b.id, Name: b.name, Icon: "qoder", Aliases: b.aliases,
Bin: b.bin, Dir: dir, Path: path,
Sync: func() error {
ref, ok := cutMagpie(get("model.name"))
if !ok {
return nil
}
return syncJSON(path, slot, func() any { return qoderProvider(b.id, ref) })
},
Notice: func() string {
if Running(b.procs...) {
return b.name + " reads its settings as a session starts — open sessions keep the model they have; new ones use this."
}
return ""
},
Check: func() string {
if !onMagpie() {
return ""
}
return wiringOff(b.name, path, func(k string) (string, bool) { return edit.GetJSON(path, slot+"."+k) },
"baseUrl", at.v1())
},
Fields: []Field{{
Key: "model", Label: "model",
Get: func() string { return get("model.name") },
Set: func(v string) error {
if ref, ok := cutMagpie(v); ok {
if !onMagpie() {
stash(map[string]string{key + "model": get("model.name")})
}
// the effort in use goes with it to a model with none of
// its own
was := effort()
if err := edit.SetJSON(path,
edit.KV{Path: slot, Value: qoderProvider(b.id, ref)},
edit.KV{Path: "model.name", Value: v}); err != nil {
return err
}
if was == "" || qoderEffort(get("model.preferences"), v) != "" {
return nil
}
return setEffort(v, was)
}
// out of magpie: its provider goes, and the model the user
// had comes back when none is asked for
if onMagpie() || qoderKeyed(get(slot+".apiKey"), b.id) {
if err := edit.DelJSON(path, slot); err != nil {
return err
}
if v == "" {
v = unstash(key + "model")
}
}
if v == "" {
return edit.DelJSON(path, "model.name")
}
return edit.SetJSON(path, edit.KV{Path: "model.name", Value: v})
},
Options: func(cur map[string]string) []Option {
return append(ownOptions("", cur["model"]), viaMagpie(b.id, magpieID+"/")...)
},
}, {
// the effort of the model in use, in its model.preferences
Key: "effort", Label: "effort",
Get: effort,
Set: func(v string) error { return setEffort(get("model.name"), v) },
Options: func(map[string]string) []Option {
return static("low", "medium", "high", "xhigh", "max")
},
}},
}
}
// qoderEffort is the effort model's preference in prefs (model.preferences)
// asks for, from its reasoning or an older generation.reasoning.
func qoderEffort(prefs, model string) string {
var m map[string]struct {
Reasoning *struct{ Effort string } `json:"reasoning"`
Generation struct {
Reasoning *struct{ Effort string } `json:"reasoning"`
} `json:"generation"`
}
if model == "" || json.Unmarshal([]byte(prefs), &m) != nil {
return ""
}
p := m[model]
if p.Reasoning != nil {
return p.Reasoning.Effort
}
if p.Generation.Reasoning != nil {
return p.Generation.Reasoning.Effort
}
return ""
}
// qoderPreferences is prefs (model.preferences) with model's effort set to
// effort, or taken out when it is ""; drop when nothing is left.
func qoderPreferences(prefs, model, effort string) (out map[string]any, drop bool) {
out = map[string]any{}
json.Unmarshal([]byte(prefs), &out)
if out == nil {
out = map[string]any{}
}
p, _ := out[model].(map[string]any)
if p == nil {
p = map[string]any{}
}
if g, ok := p["generation"].(map[string]any); ok {
// Qoder's own migration lifts generation's fields up; the effort
// set here is the one to keep
delete(g, "reasoning")
if len(g) == 0 {
delete(p, "generation")
}
}
r, _ := p["reasoning"].(map[string]any)
if r == nil {
r = map[string]any{}
}
if effort == "" {
delete(r, "effort")
} else {
r["effort"] = effort
if r["enabled"] == false {
delete(r, "enabled")
}
}
if len(r) == 0 {
delete(p, "reasoning")
} else {
p["reasoning"] = r
}
if len(p) == 0 {
delete(out, model)
} else {
out[model] = p
}
return out, len(out) == 0
}
// qoderLevels are the efforts Qoder can ask for.
var qoderLevels = []string{"low", "medium", "high", "xhigh", "max"}
// qoderProvider is magpie's entry in Qoder's providers, every magpie model
// in it, model the one it starts on.
func qoderProvider(agent, model string) map[string]any {
return qoderProviderAt(agent, model, gatewayV1())
}
// qoderProviderAt is qoderProvider for a Qoder reaching the gateway's /v1
// at v1.
func qoderProviderAt(agent, model, v1 string) map[string]any {
var ms []map[string]any
for _, m := range magpieModels(agent) {
caps := map[string]any{"tools": true, "vision": m.Images}
var levels []string
for _, e := range m.Efforts {
if slices.Contains(qoderLevels, e) && !slices.Contains(levels, e) {
levels = append(levels, e)
}
}
if len(levels) > 0 {
caps["thinking"] = map[string]any{"modes": []string{"enabled"}, "supportsEffort": true,
"supportedEffortLevels": levels, "requiresBudgetForEnabled": false}
}
e := map[string]any{"model": m.ID, "displayName": m.Name, "capabilities": caps}
if m.Context > 0 {
e["contextWindow"] = m.Context
}
if m.Output > 0 {
e["maxOutputTokens"] = maxTokens(m)
}
ms = append(ms, e)
}
return map[string]any{"displayName": "magpie", "protocol": "openai", "baseUrl": v1,
"apiKey": gateway.TokenFor(agent), "model": model, "models": ms}
}
// qoderKeyed: key is one magpie gives Qoder's provider — its own,
// gateway.TokenFor, since Qoder's requests carry Bun's User-Agent and
// nothing of Qoder's, or gateway.Token, which it was given before; a sync
// gives it the new one.
func qoderKeyed(key, agent string) bool {
return key == gateway.TokenFor(agent) || key == gateway.Token
}