agents: the effort picked for Cline and Qoder is the one they use — Cline's reasoning is written {enabled:true,effort} ({enabled:false} for none), as its own pickers leave an enabled false that drops an effort beside it, and such a one reads as none; Cline's VS Code extension (4.x), which takes its provider, model, base URL and key from globalState.json and secrets.json before providers.json, has those set too where they are, stashed and put back on the way out, and $CLINE_DATA_DIR is honoured. Qoder (1.1.6x) keeps each model's effort in model.preferences and moves model.reasoningEffort there once: magpie sets the model's preference (a model id with dots too), carries the effort in use to a model with none of its own, and keeps reasoningEffort for older Qoders. Cline's desktop app keeps its composer's model and effort in its web view's storage, which magpie leaves alone; its notice says to pick there
This commit is contained in:
4 files changed
+390
-23
No files matched your search
+164
-9
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+122
-12
@@ -9,7 +9,14 @@ package agent
|
||||
// "models":[{"model":…,"displayName":…,"contextWindow":…,"maxOutputTokens":…,
|
||||
// "capabilities":{"tools":true,"vision":…,"thinking":{"modes":["enabled"],
|
||||
// "supportsEffort":true,"supportedEffortLevels":[…]}}}]}},
|
||||
// "model":{"name":"magpie/<model>","reasoningEffort":…}}
|
||||
// "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
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user