From 4eead4d4e087f90d7c5ff037a7d3bd25c9f5c122 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 13:40:50 +0800 Subject: [PATCH] =?UTF-8?q?agents:=20the=20effort=20picked=20for=20Cline?= =?UTF-8?q?=20and=20Qoder=20is=20the=20one=20they=20use=20=E2=80=94=20Clin?= =?UTF-8?q?e's=20reasoning=20is=20written=20{enabled:true,effort}=20({enab?= =?UTF-8?q?led:false}=20for=20none),=20as=20its=20own=20pickers=20leave=20?= =?UTF-8?q?an=20enabled=20false=20that=20drops=20an=20effort=20beside=20it?= =?UTF-8?q?,=20and=20such=20a=20one=20reads=20as=20none;=20Cline's=20VS=20?= =?UTF-8?q?Code=20extension=20(4.x),=20which=20takes=20its=20provider,=20m?= =?UTF-8?q?odel,=20base=20URL=20and=20key=20from=20globalState.json=20and?= =?UTF-8?q?=20secrets.json=20before=20providers.json,=20has=20those=20set?= =?UTF-8?q?=20too=20where=20they=20are,=20stashed=20and=20put=20back=20on?= =?UTF-8?q?=20the=20way=20out,=20and=20$CLINE=5FDATA=5FDIR=20is=20honoured?= =?UTF-8?q?.=20Qoder=20(1.1.6x)=20keeps=20each=20model's=20effort=20in=20m?= =?UTF-8?q?odel.preferences=20and=20moves=20model.reasoningEffort=20there?= =?UTF-8?q?=20once:=20magpie=20sets=20the=20model's=20preference=20(a=20mo?= =?UTF-8?q?del=20id=20with=20dots=20too),=20carries=20the=20effort=20in=20?= =?UTF-8?q?use=20to=20a=20model=20with=20none=20of=20its=20own,=20and=20ke?= =?UTF-8?q?eps=20reasoningEffort=20for=20older=20Qoders.=20Cline's=20deskt?= =?UTF-8?q?op=20app=20keeps=20its=20composer's=20model=20and=20effort=20in?= =?UTF-8?q?=20its=20web=20view's=20storage,=20which=20magpie=20leaves=20al?= =?UTF-8?q?one;=20its=20notice=20says=20to=20pick=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/cline.go | 173 +++++++++++++++++++++++++++++++++-- internal/agent/cline_test.go | 66 ++++++++++++- internal/agent/qoder.go | 134 ++++++++++++++++++++++++--- internal/agent/qoder_test.go | 40 +++++++- 4 files changed, 390 insertions(+), 23 deletions(-) diff --git a/internal/agent/cline.go b/internal/agent/cline.go index 30218318..34f1198f 100644 --- a/internal/agent/cline.go +++ b/internal/agent/cline.go @@ -19,6 +19,24 @@ package agent // which takes the place of the built-in list. What was there, and the // provider in use, are stashed and put back when magpie steps out. Cline's // requests name only the AI SDK, so the header says they are Cline's. +// +// reasoning is {"enabled":false} for no thinking, as Cline's own pickers +// write it; an effort beside a false enabled is dropped, so magpie writes +// {"enabled":true,"effort":…} for an effort. +// +// Cline's VS Code extension (4.x) runs on the same files, but takes the +// provider, model, base URL and key from its own state beside them first — +// $CLINE_DATA_DIR (else $CLINE_DIR/data) globalState.json's +// act/planModeApiProvider ("openai" for openai-compatible), +// act/planModeOpenAiModelId and openAiBaseUrl, secrets.json's openAiApiKey +// — and providers.json's lastUsedProvider only when no mode has one. Where +// that state is, magpie sets those too, stashing what they were. The +// extension reads the files once, at start-up. +// +// Cline's desktop app (0.0.x) keeps the provider, model and effort picked +// in its composer in its web view's localStorage, per provider, and never +// reads them from these files: magpie's models are in its picker, under +// magpie, but which one it runs is picked there. import ( "encoding/json" @@ -39,8 +57,13 @@ func cline(home string) *Agent { if dir == "" { dir = filepath.Join(home, ".cline") } - path := filepath.Join(dir, "data", "settings", "providers.json") - models := filepath.Join(dir, "data", "settings", "models.json") + data := os.Getenv("CLINE_DATA_DIR") + if data == "" { + data = filepath.Join(dir, "data") + } + path := filepath.Join(data, "settings", "providers.json") + models := filepath.Join(data, "settings", "models.json") + state, secrets := filepath.Join(data, "globalState.json"), filepath.Join(data, "secrets.json") key := "cline:" + path + ":" slot := "providers." + clineSlot get := func(k string) string { v, _ := edit.GetJSON(path, k); return v } @@ -48,9 +71,62 @@ func cline(home string) *Agent { onMagpie := func() bool { return inUse() == clineSlot && get(slot+".settings.apiKey") == gateway.Token } + // stateOnMagpie: the extension's state is magpie's + stateOnMagpie := func() bool { + k, _ := edit.GetJSON(secrets, "openAiApiKey") + u, _ := edit.GetJSON(state, "openAiBaseUrl") + return k == gateway.Token && u == gatewayV1() + } + // wireState points the extension's state, when there is one, at model + // on magpie's openai-compatible + wireState := func(model string) error { + if _, err := os.Stat(state); err != nil { + return nil + } + if !stateOnMagpie() { + k, _ := edit.GetJSON(secrets, "openAiApiKey") + stash(map[string]string{key + "state": clineSnapshot(state), key + "secret": k, key + "stated": "1"}) + } + // the model info a pick leaves is the old model's; magpie's is in + // models.json + if err := edit.DelJSON(state, "actModeOpenAiModelInfo", "planModeOpenAiModelInfo"); err != nil { + return err + } + if err := edit.SetJSON(state, + edit.KV{Path: "actModeApiProvider", Value: "openai"}, edit.KV{Path: "planModeApiProvider", Value: "openai"}, + edit.KV{Path: "actModeOpenAiModelId", Value: model}, edit.KV{Path: "planModeOpenAiModelId", Value: model}, + edit.KV{Path: "openAiBaseUrl", Value: gatewayV1()}, + edit.KV{Path: "openAiHeaders", Value: map[string]string{"User-Agent": "cline"}}); err != nil { + return err + } + return clineWrite(secrets, "{}", edit.KV{Path: "openAiApiKey", Value: gateway.Token}) + } + // restoreState puts back the extension's state from before magpie; + // magpie's keys go when nothing was stashed + restoreState := func() error { + saved, secret, stated := unstash(key+"state"), unstash(key+"secret"), unstash(key+"stated") + if stated == "" && !stateOnMagpie() { + return nil + } + if _, err := os.Stat(state); err == nil { + if err := clineRestore(state, saved); err != nil { + return err + } + } + if _, err := os.Stat(secrets); err != nil { + return nil + } + if secret != "" { + return edit.SetJSON(secrets, edit.KV{Path: "openAiApiKey", Value: secret}) + } + return edit.DelJSON(secrets, "openAiApiKey") + } // restore puts back the openai-compatible provider and the provider in // use the user had before magpie restore := func() error { + if err := restoreState(); err != nil { + return err + } entry, last := unstash(key+"entry"), unstash(key+"lastUsedProvider") if list := unstash(key + "models"); list != "" { if err := edit.SetJSON(models, edit.KV{Path: "providers." + clineSlot, Value: json.RawMessage(list)}); err != nil { @@ -90,11 +166,19 @@ func cline(home string) *Agent { if err := syncJSON(path, slot+".settings.baseUrl", func() any { return gatewayV1() }); err != nil { return err } + if k, _ := edit.GetJSON(secrets, "openAiApiKey"); k == gateway.Token { + if err := syncJSON(state, "openAiBaseUrl", func() any { return gatewayV1() }); err != nil { + return err + } + } return syncJSON(models, "providers."+clineSlot, func() any { return clineModels(get(slot + ".settings.model")) }) }, Notice: func() string { + if Running(`Cline\.app/`, `(^|/)cline-app( |$)`) { + return "Cline's desktop app keeps the model and effort picked in its own composer — pick magpie's there; this sets Cline's CLI and VS Code extension." + } if Running(`(^|/)cline( |$)`) { - return "Cline reads its provider as a session starts — open sessions keep the model they have; new ones use this." + return "Cline reads its provider as a session starts — open sessions keep the model they have; new ones use this. Reload VS Code's window for its extension." } return "" }, @@ -129,7 +213,7 @@ func cline(home string) *Agent { return nil } if ref, ok := cutMagpie(v); ok { - effort := get("providers." + own() + ".settings.reasoning.effort") + effort := clineEffort(get("providers." + own() + ".settings.reasoning")) if !onMagpie() { list, _ := edit.GetJSON(models, "providers."+clineSlot) stash(map[string]string{ @@ -142,6 +226,9 @@ func cline(home string) *Agent { edit.KV{Path: "providers." + clineSlot, Value: clineModels(ref)}); err != nil { return err } + if err := wireState(ref); err != nil { + return err + } return clineWrite(path, providersEmpty, edit.KV{Path: slot, Value: clineProvider(ref, effort)}, edit.KV{Path: "lastUsedProvider", Value: clineSlot}) @@ -160,22 +247,22 @@ func cline(home string) *Agent { return append(ownOptions("", cur["model"]), viaMagpie("cline", magpieID+"/")...) }, }, { - // the reasoning.effort of the provider in use, what Cline's - // --thinking asks for when it is not given + // the reasoning of the provider in use, what Cline's --thinking + // (and its extension) asks for when it is not given Key: "effort", Label: "effort", Get: func() string { p := inUse() if p == "" { return "" } - return get("providers." + p + ".settings.reasoning.effort") + return clineEffort(get("providers." + p + ".settings.reasoning")) }, Set: func(v string) error { k := "providers." + own() + ".settings.reasoning" if v == "" { return edit.DelJSON(path, k) } - return clineWrite(path, providersEmpty, edit.KV{Path: k + ".effort", Value: v}) + return clineWrite(path, providersEmpty, edit.KV{Path: k, Value: clineReasoning(v)}) }, Options: func(map[string]string) []Option { return static("none", "low", "medium", "high", "xhigh") @@ -198,11 +285,79 @@ func clineProvider(model, effort string) map[string]any { "headers": map[string]string{"User-Agent": "cline"}, } if effort != "" { - s["reasoning"] = map[string]any{"effort": effort} + s["reasoning"] = clineReasoning(effort) } return map[string]any{"settings": s, "updatedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), "tokenSource": "manual"} } +// clineReasoning is a provider's reasoning asking for effort, none for no +// thinking. +func clineReasoning(effort string) map[string]any { + if effort == "none" { + return map[string]any{"enabled": false} + } + return map[string]any{"enabled": true, "effort": effort} +} + +// clineEffort is the effort a provider's reasoning asks for: none when it +// is off, whatever effort is left beside that. +func clineEffort(raw string) string { + var r struct { + Enabled *bool `json:"enabled"` + Effort string `json:"effort"` + } + if json.Unmarshal([]byte(raw), &r) != nil { + return "" + } + if r.Enabled != nil && !*r.Enabled { + return "none" + } + return r.Effort +} + +// clineStateKeys are the keys of the extension's globalState.json magpie +// sets. +var clineStateKeys = []string{"actModeApiProvider", "planModeApiProvider", "actModeOpenAiModelId", "planModeOpenAiModelId", + "actModeOpenAiModelInfo", "planModeOpenAiModelInfo", "openAiBaseUrl", "openAiHeaders"} + +// clineSnapshot is what path has of clineStateKeys, as a JSON object. +func clineSnapshot(path string) string { + var all map[string]json.RawMessage + b, _ := os.ReadFile(path) + json.Unmarshal(b, &all) + m := map[string]json.RawMessage{} + for _, k := range clineStateKeys { + if v, ok := all[k]; ok { + m[k] = v + } + } + b, _ = json.Marshal(m) + return string(b) +} + +// clineRestore sets path's clineStateKeys back to a snapshot, removing +// those it has not. +func clineRestore(path, snapshot string) error { + var m map[string]json.RawMessage + json.Unmarshal([]byte(snapshot), &m) + var kvs []edit.KV + var gone []string + for _, k := range clineStateKeys { + if v, ok := m[k]; ok { + kvs = append(kvs, edit.KV{Path: k, Value: v}) + } else { + gone = append(gone, k) + } + } + if err := edit.DelJSON(path, gone...); err != nil { + return err + } + if len(kvs) == 0 { + return nil + } + return edit.SetJSON(path, kvs...) +} + // clineModels is magpie's entry in models.json: every magpie model, model // the one the provider starts on. func clineModels(model string) map[string]any { diff --git a/internal/agent/cline_test.go b/internal/agent/cline_test.go index 272cd254..628b35c2 100644 --- a/internal/agent/cline_test.go +++ b/internal/agent/cline_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" + "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" ) @@ -15,6 +17,7 @@ func TestCline(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("CLINE_DIR", "") + t.Setenv("CLINE_DATA_DIR", "") t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}}); err != nil { @@ -54,6 +57,21 @@ func TestCline(t *testing.T) { os.WriteFile(path, []byte(`{"version":1,"lastUsedProvider":"anthropic","modes":{},"providers":{ "anthropic":{"settings":{"provider":"anthropic","apiKey":"sk-a","model":"claude-opus-5","reasoning":{"effort":"high"}},"updatedAt":"2026-09-01T00:00:00.000Z","tokenSource":"manual"}, "openai-compatible":{"settings":{"provider":"openai-compatible","apiKey":"sk-o","model":"mine","baseUrl":"https://x/v1"},"updatedAt":"2026-09-01T00:00:00.000Z","tokenSource":"manual"}}}`), 0o600) + // the VS Code extension's state, which it takes its provider from first + data := filepath.Join(home, ".cline", "data") + statePath, secretsPath := filepath.Join(data, "globalState.json"), filepath.Join(data, "secrets.json") + userState := `{"actModeApiProvider":"anthropic","planModeApiProvider":"anthropic","actModeOpenAiModelId":"mine",` + + `"actModeOpenAiModelInfo":{"maxTokens":10},"openAiBaseUrl":"https://x/v1","telemetrySetting":"disabled"}` + os.WriteFile(statePath, []byte(userState), 0o600) + os.WriteFile(secretsPath, []byte(`{"openAiApiKey":"sk-o","anthropicApiKey":"sk-a"}`), 0o600) + vscode := func() (map[string]any, map[string]any) { + var s, k map[string]any + b, _ := os.ReadFile(statePath) + json.Unmarshal(b, &s) + b, _ = os.ReadFile(secretsPath) + json.Unmarshal(b, &k) + return s, k + } a := cline(home) f, e := a.Field("model"), a.Field("effort") @@ -80,6 +98,12 @@ func TestCline(t *testing.T) { if f.Get() != "magpie/deepseek/pro" || e.Get() != "high" || a.Check() != "" { t.Fatalf("get: %q %q %q", f.Get(), e.Get(), a.Check()) } + st, sec := vscode() + if st["actModeApiProvider"] != "openai" || st["planModeApiProvider"] != "openai" || st["actModeOpenAiModelId"] != "deepseek/pro" || + st["planModeOpenAiModelId"] != "deepseek/pro" || st["openAiBaseUrl"] != gatewayV1() || st["actModeOpenAiModelInfo"] != nil || + st["telemetrySetting"] != "disabled" || sec["openAiApiKey"] != gateway.Token || sec["anthropicApiKey"] != "sk-a" { + t.Fatalf("vscode: %v %v", st, sec) + } // another magpie model keeps what was stashed first; the effort is set // on magpie's provider if err := f.Set("magpie/deepseek/flash"); err != nil { @@ -89,9 +113,34 @@ func TestCline(t *testing.T) { t.Fatal(err) } c, raw = read() - if s := c.Providers["openai-compatible"].Settings; s["model"] != "deepseek/flash" || s["reasoning"].(map[string]any)["effort"] != "low" { + if s := c.Providers["openai-compatible"].Settings; s["model"] != "deepseek/flash" || s["reasoning"].(map[string]any)["effort"] != "low" || + s["reasoning"].(map[string]any)["enabled"] != true { t.Fatalf("flash:\n%s", raw) } + if st, _ := vscode(); st["actModeOpenAiModelId"] != "deepseek/flash" { + t.Fatalf("vscode flash: %v", st) + } + // Cline's pickers turn thinking off as enabled false, which drops an + // effort beside it: that reads as none, and an effort turns it back on + edit.SetJSON(path, edit.KV{Path: "providers.openai-compatible.settings.reasoning", Value: map[string]any{"enabled": false, "effort": "low"}}) + if e.Get() != "none" { + t.Fatalf("off: %q", e.Get()) + } + if err := e.Set("high"); err != nil { + t.Fatal(err) + } + c, raw = read() + if r := c.Providers["openai-compatible"].Settings["reasoning"].(map[string]any); r["enabled"] != true || r["effort"] != "high" { + t.Fatalf("on again:\n%s", raw) + } + if err := e.Set("none"); err != nil { + t.Fatal(err) + } + c, raw = read() + if r := c.Providers["openai-compatible"].Settings["reasoning"].(map[string]any); r["enabled"] != false || r["effort"] != nil || e.Get() != "none" { + t.Fatalf("none:\n%s", raw) + } + e.Set("low") // back to Cline's own: the user's openai-compatible and provider return if err := f.Set("claude-sonnet-5"); err != nil { @@ -106,6 +155,12 @@ func TestCline(t *testing.T) { ml["openai-compatible"].Provider["baseUrl"] != "https://x/v1" || ml["ollama"].Models["q"] == nil { t.Fatalf("own models:\n%s", mraw) } + // the extension's state is the user's again, key and all + var want map[string]any + json.Unmarshal([]byte(userState), &want) + if st, sec := vscode(); !reflect.DeepEqual(st, want) || sec["openAiApiKey"] != "sk-o" || sec["anthropicApiKey"] != "sk-a" { + t.Fatalf("vscode own: %v %v", st, sec) + } // reset from magpie, with no Cline settings before it: nothing of // magpie's is left @@ -134,4 +189,13 @@ func TestCline(t *testing.T) { if ml, mraw := models(); len(ml) != 0 || !strings.Contains(mraw, `"version"`) { t.Fatalf("reset models:\n%s", mraw) } + if st, sec := vscode(); !reflect.DeepEqual(st, want) || sec["openAiApiKey"] != "sk-o" { + t.Fatalf("vscode reset: %v %v", st, sec) + } + + // $CLINE_DATA_DIR is where the files are, as for Cline + t.Setenv("CLINE_DATA_DIR", filepath.Join(home, "d")) + if p := cline(home).Path; p != filepath.Join(home, "d", "settings", "providers.json") { + t.Fatalf("CLINE_DATA_DIR: %q", p) + } } diff --git a/internal/agent/qoder.go b/internal/agent/qoder.go index 9d9f9685..ea35bb7d 100644 --- a/internal/agent/qoder.go +++ b/internal/agent/qoder.go @@ -9,7 +9,14 @@ package agent // "models":[{"model":…,"displayName":…,"contextWindow":…,"maxOutputTokens":…, // "capabilities":{"tools":true,"vision":…,"thinking":{"modes":["enabled"], // "supportsEffort":true,"supportedEffortLevels":[…]}}}]}}, -// "model":{"name":"magpie/","reasoningEffort":…}} +// "model":{"name":"magpie/","reasoningEffort":…, +// "preferences":{"magpie/":{"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 @@ -21,6 +28,7 @@ package agent // ~/.qoder-cn by default. import ( + "encoding/json" "os" "path/filepath" "slices" @@ -59,6 +67,38 @@ func qoderSite(home string, b qoderBuild) *Agent { 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) @@ -94,9 +134,18 @@ func qoderSite(home string, b qoderBuild) *Agent { if !onMagpie() { stash(map[string]string{key + "model": get("model.name")}) } - return edit.SetJSON(path, + // 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}) + 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 @@ -117,16 +166,10 @@ func qoderSite(home string, b qoderBuild) *Agent { return append(ownOptions("", cur["model"]), viaMagpie(b.id, magpieID+"/")...) }, }, { - // what Qoder asks a model for when it has no effort of its own - // in model.preferences + // the effort of the model in use, in its model.preferences Key: "effort", Label: "effort", - Get: func() string { return get("model.reasoningEffort") }, - Set: func(v string) error { - if v == "" { - return edit.DelJSON(path, "model.reasoningEffort") - } - return edit.SetJSON(path, edit.KV{Path: "model.reasoningEffort", Value: v}) - }, + 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") }, @@ -134,6 +177,73 @@ func qoderSite(home string, b qoderBuild) *Agent { } } +// 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"} diff --git a/internal/agent/qoder_test.go b/internal/agent/qoder_test.go index 2f189521..94237e13 100644 --- a/internal/agent/qoder_test.go +++ b/internal/agent/qoder_test.go @@ -35,7 +35,7 @@ func testQoder(t *testing.T, mk func(string) *Agent, rel string) { t.Setenv("QODERCN_CONFIG_DIR", "") t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) - if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}}); err != nil { + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash", "v4.1-flash"}}); err != nil { t.Fatal(err) } path := filepath.Join(home, rel) @@ -90,6 +90,44 @@ func testQoder(t *testing.T, mk func(string) *Agent, rel string) { if c.Providers[magpieID]["model"] != "deepseek/flash" || c.Model["reasoningEffort"] != "low" { t.Fatalf("flash:\n%s", raw) } + // the effort is the model's own, in model.preferences: Qoder asks + // reasoningEffort only of a model with none there + pref := func(model string) any { + c, _ := read() + ps, _ := c.Model["preferences"].(map[string]any) + p, _ := ps[model].(map[string]any) + r, _ := p["reasoning"].(map[string]any) + return r["effort"] + } + if pref("magpie/deepseek/flash") != "low" { + t.Fatalf("flash preference:\n%s", raw) + } + // Qoder moved reasoningEffort into a preference of the model's own, and + // it was turned off: that is the effort shown, and the one set + edit.DelJSON(path, "model.reasoningEffort") + edit.SetJSON(path, edit.KV{Path: "model.preferences", Value: map[string]any{ + "magpie/deepseek/flash": map[string]any{"reasoning": map[string]any{"effort": "high", "enabled": false}, "contextWindow": 1000}, + "performance": map[string]any{"reasoning": map[string]any{"effort": "max"}}}}) + if e.Get() != "high" { + t.Fatalf("preference get: %q", e.Get()) + } + if err := e.Set("medium"); err != nil { + t.Fatal(err) + } + c, raw = read() + if p := c.Model["preferences"].(map[string]any)["magpie/deepseek/flash"].(map[string]any); pref("magpie/deepseek/flash") != "medium" || + p["reasoning"].(map[string]any)["enabled"] != nil || p["contextWindow"] != 1000.0 || pref("performance") != "max" || c.Model["reasoningEffort"] != "medium" { + t.Fatalf("preference set:\n%s", raw) + } + // a model with no effort of its own (a dot in its id) takes the one in use + if err := f.Set("magpie/deepseek/v4.1-flash"); err != nil { + t.Fatal(err) + } + if pref("magpie/deepseek/v4.1-flash") != "medium" || e.Get() != "medium" { + _, raw = read() + t.Fatalf("carried effort:\n%s", raw) + } + f.Set("magpie/deepseek/flash") // reset: magpie's provider goes and the user's model comes back if err := f.Set(""); err != nil {