diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 8bdd4677..b88f9aed 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -1,6 +1,7 @@ package agent import ( + "cmp" "fmt" "path/filepath" "runtime" @@ -30,8 +31,13 @@ var claudeEnv = []string{ // A tier that has none follows the main model. var claudeTiers = []string{"opus", "sonnet", "haiku", "fable"} -// claudeEfforts are the levels settings.json's effortLevel takes. -var claudeEfforts = []string{"low", "medium", "high", "xhigh"} +// claudeEfforts are the levels Claude Code starts with: settings.json's +// effortLevel takes the first four, max is claudeEffortEnv. +var claudeEfforts = []string{"low", "medium", "high", "xhigh", "max"} + +// claudeEffortEnv is the effort every Claude Code session asks for, max +// among them. +const claudeEffortEnv = "CLAUDE_CODE_EFFORT_LEVEL" func tierEnv(tier string) string { return "ANTHROPIC_DEFAULT_" + strings.ToUpper(tier) + "_MODEL" } @@ -153,13 +159,23 @@ func claude(home string) *Agent { }, }, { // the effort Claude Code starts with, as its /effort saves it; - // settings.json keeps low to xhigh (max lasts a session only) + // settings.json keeps low to xhigh (/effort max lasts a session + // only), so max is CLAUDE_CODE_EFFORT_LEVEL in its env, which every + // session starts with and asks for Key: "effort", Label: "effort", - Get: jsonGet(path, "effortLevel"), + Get: func() string { return cmp.Or(env(claudeEffortEnv), jsonGet(path, "effortLevel")()) }, Set: func(v string) error { if v != "" && !contains(claudeEfforts, v) { return fmt.Errorf("Claude Code keeps an effort of %s, not %q", strings.Join(claudeEfforts, ", "), v) } + if v == "max" { + return edit.SetJSON(path, edit.KV{Path: "env." + claudeEffortEnv, Value: v}) + } + if env(claudeEffortEnv) != "" { + if err := edit.DelJSON(path, "env."+claudeEffortEnv); err != nil { + return err + } + } return jsonSet(path, "effortLevel")(v) }, Options: func(map[string]string) []Option { diff --git a/internal/agent/claude_test.go b/internal/agent/claude_test.go index 3e4f5d15..18c966a6 100644 --- a/internal/agent/claude_test.go +++ b/internal/agent/claude_test.go @@ -72,3 +72,41 @@ func TestClaudeTiers(t *testing.T) { t.Fatal("theme lost") } } + +// Claude Code's settings keep an effort up to xhigh; max is its +// CLAUDE_CODE_EFFORT_LEVEL, which another level takes away again. +func TestClaudeEffortMax(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + path := filepath.Join(home, ".claude", "settings.json") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{"effortLevel": "high", "env": {"FOO": "1"}}`), 0o644) + e := claude(home).Field("effort") + get := func(k string) string { v, _ := edit.GetJSON(path, k); return v } + if e.Get() != "high" { + t.Fatalf("get %q", e.Get()) + } + if err := e.Set("max"); err != nil { + t.Fatal(err) + } + if e.Get() != "max" || get("env.CLAUDE_CODE_EFFORT_LEVEL") != "max" || get("env.FOO") != "1" { + b, _ := os.ReadFile(path) + t.Fatalf("max:\n%s", b) + } + if err := e.Set("xhigh"); err != nil { + t.Fatal(err) + } + if e.Get() != "xhigh" || get("env.CLAUDE_CODE_EFFORT_LEVEL") != "" || get("effortLevel") != "xhigh" || get("env.FOO") != "1" { + b, _ := os.ReadFile(path) + t.Fatalf("xhigh:\n%s", b) + } + e.Set("max") + if err := e.Set(""); err != nil || e.Get() != "" { + t.Fatalf("reset: %v %q", err, e.Get()) + } + if e.Set("ultra") == nil { + t.Fatal("ultra taken") + } +} diff --git a/internal/agent/effort_test.go b/internal/agent/effort_test.go index 8e3b6718..186b4a74 100644 --- a/internal/agent/effort_test.go +++ b/internal/agent/effort_test.go @@ -67,10 +67,9 @@ func TestEffortFields(t *testing.T) { writeFile(t, filepath.Join(home, ".claude", "settings.json"), `{"theme":"dark","model":"opus"}`) c := claude(home) setEffort(t, c, "xhigh", `"theme"`) - // max lasts a session in Claude Code; settings.json drops it - if err := c.Field("effort").Set("max"); err == nil { - t.Fatal("claude took max") - } + // max lasts a session in Claude Code's /effort; magpie keeps it as its + // env (TestClaudeEffortMax) + setEffort(t, c, "max", `"theme"`) writeFile(t, filepath.Join(home, ".hermes", "config.yaml"), "model:\n default: x\nagent:\n max_turns: 9\n") h := hermes(home) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 64ee58cd..77266924 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2833,16 +2833,19 @@ function renderModels(p) { who.append(name, el("code", "", m.id)); row.append(who); const levels = m.efforts || []; + // a model whose levels aren't known (m.given) can be given any + // of them, and none again if (levels.length > 1) { const lv = el("div", "mlevels"); - lv.title = t("Reasoning levels agents are offered"); - const kept = m.kept?.length ? m.kept : levels; + lv.title = t(m.given ? "Its reasoning levels aren't known: tick the ones it takes" : "Reasoning levels agents are offered"); + const kept = m.kept?.length || m.given ? m.kept || [] : levels; for (const l of levels) { const [tk, cb] = tick(t(l), kept.includes(l)); cb.onchange = () => { const next = levels.filter((x) => x === l ? cb.checked : kept.includes(x)); - if (!next.length) { cb.checked = true; status(t("Keep at least one level"), "err"); return; } - accountAction("provider/efforts", { id: p.id, model: m.id, efforts: next.length === levels.length ? [] : next }, t("{id}: {levels}", { id: m.id, levels: next.map((x) => t(x)).join(", ") })); + if (!next.length && !m.given) { cb.checked = true; status(t("Keep at least one level"), "err"); return; } + accountAction("provider/efforts", { id: p.id, model: m.id, efforts: next.length === levels.length && !m.given ? [] : next }, + next.length ? t("{id}: {levels}", { id: m.id, levels: next.map((x) => t(x)).join(", ") }) : t("{id} is as its provider has it again", { id: m.id })); }; lv.append(tk); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index ea5fb743..19981fea 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -383,6 +383,7 @@ const I18N = { "{id} is called {name}": "{id} 已改名为 {name}", "{id} has its own name again": "{id} 已恢复默认名称", "Reasoning levels agents are offered": "提供给 Agent 的推理档位", + "Its reasoning levels aren't known: tick the ones it takes": "未知这个模型的推理档位:勾选它支持的档位", "Keep at least one level": "至少保留一个档位", "{id}: {levels}": "{id}:{levels}", "Restore default": "恢复默认", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 44f11b16..d83064d1 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -27,7 +27,8 @@ type modelJSON struct { Default string `json:"default,omitempty"` // its own name, when the user gave it another Kept []string `json:"kept,omitempty"` // the reasoning levels the user keeps of Efforts, when not all Efforts []string `json:"efforts,omitempty"` - On bool `json:"on"` // exposed to agents + Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was + On bool `json:"on"` // exposed to agents } type providerJSON struct { @@ -219,6 +220,9 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { j.Default = cmp.Or(m.Name, m.ID) j.Name = n } + if len(j.Efforts) == 0 { + j.Efforts, j.Given = provider.Levels, true + } if k, ok := kept[m.ID]; ok { j.Kept = slices.DeleteFunc(slices.Clone(j.Efforts), func(e string) bool { return !slices.Contains(k, e) }) } diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 4235bd03..d8dfba9c 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -22,7 +22,9 @@ import ( // (low, medium and high of its six): settings' ModelEfforts, kept the same // way. The lists magpie hands out — the gateway's, and those it writes into // the agents' files — offer only those, in the vendor's order; a request -// for another still reaches the vendor as it did. +// for another still reaches the vendor as it did. A model whose levels +// aren't known (one models.dev doesn't list, a custom provider's) can be +// given some of Levels the same way, which it is then taken to have. // ModelName is the name the user gave a provider's model, if any. func ModelName(pid, model string) (string, bool) { @@ -88,25 +90,31 @@ func SetModelName(ref, name string) error { return nil } +// Levels are the reasoning levels a model whose own aren't known can be +// given. +var Levels = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} + // SetModelEfforts keeps only these of a provider's model's reasoning // levels in the lists magpie hands out; none, or all it has, offers them -// all again. They must be levels the model has. +// all again. They must be levels the model has — or, for a model whose +// levels aren't known, any of Levels, which it is given; none takes them +// away. func SetModelEfforts(ref string, efforts []string) error { p, model, err := splitRef(ref) if err != nil { return err } - all := p.Efforts(model) + all := p.Known(model) var keep []string for _, e := range efforts { e = strings.ToLower(strings.TrimSpace(e)) if e == "" { continue } - if !slices.Contains(all, e) { - if len(all) == 0 { - return fmt.Errorf("%s/%s has no reasoning levels to choose from", p.ID, model) - } + if len(all) == 0 && !slices.Contains(Levels, e) { + return fmt.Errorf("%q is not a reasoning level (they are %s)", e, strings.Join(Levels, ", ")) + } + if len(all) > 0 && !slices.Contains(all, e) { return fmt.Errorf("%s/%s has no reasoning level %q (it has %s)", p.ID, model, e, strings.Join(all, ", ")) } if !slices.Contains(keep, e) { @@ -151,11 +159,15 @@ func (p Provider) ModelEfforts() map[string][]string { // effortsKept is all without the levels the user didn't keep, in its own // order; all of them when none of those kept is among them any more (the -// vendor's list changed). +// vendor's list changed). A model with none known has those the user gave +// it. func effortsKept(all, kept []string) []string { if len(kept) == 0 { return all } + if len(all) == 0 { + return slices.DeleteFunc(slices.Clone(Levels), func(e string) bool { return !slices.Contains(kept, e) }) + } out := slices.DeleteFunc(slices.Clone(all), func(e string) bool { return !slices.Contains(kept, e) }) if len(out) == 0 { return all diff --git a/internal/provider/modelprefs_test.go b/internal/provider/modelprefs_test.go index cde6162b..12dba2f0 100644 --- a/internal/provider/modelprefs_test.go +++ b/internal/provider/modelprefs_test.go @@ -179,3 +179,36 @@ func TestModelEfforts(t *testing.T) { t.Fatal(got) } } + +// A model whose levels aren't known can be given some, which the catalog, +// the lists agents get and the gateway then take it to have; none takes +// them away, and a model with levels of its own takes no others. +func TestModelEffortsGiven(t *testing.T) { + prefsHome(t) + if err := Save(Provider{ID: "c", Name: "C", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"mystery-7"}}); err != nil { + t.Fatal(err) + } + p, _, _ := Resolve("c/mystery-7") + if e := entry(t, "c/mystery-7"); len(e.Efforts) != 0 || len(p.Efforts("mystery-7")) != 0 { + t.Fatalf("unknown model has levels %v", e.Efforts) + } + if err := SetModelEfforts("c/mystery-7", []string{"max", "low", "high"}); err != nil { + t.Fatal(err) + } + want := []string{"low", "high", "max"} + if e := entry(t, "c/mystery-7"); !slices.Equal(e.Efforts, want) || !slices.Equal(p.Efforts("mystery-7"), want) { + t.Fatalf("given: %v %v", e.Efforts, p.Efforts("mystery-7")) + } + if err := SetModelEfforts("c/mystery-7", []string{"turbo"}); err == nil { + t.Fatal("turbo taken") + } + if err := SetModelEfforts("c/mystery-7", nil); err != nil { + t.Fatal(err) + } + if e := entry(t, "c/mystery-7"); len(e.Efforts) != 0 || settings.Load().ModelEfforts["c/mystery-7"] != nil { + t.Fatalf("taken away: %v", e.Efforts) + } + if err := SetModelEfforts("a/sol", []string{"xhigh"}); err == nil { + t.Fatal("sol took xhigh") + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index 61dfc1b7..26bc16bc 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -274,8 +274,17 @@ func (p Provider) RejectsTemperature(model string) bool { return false } -// Efforts are the reasoning levels the model takes, when known. +// Efforts are the reasoning levels the model takes, when known: its own, +// or those the user gave it when it has none known. func (p Provider) Efforts(model string) []string { + if all := p.Known(model); len(all) > 0 { + return all + } + return effortsKept(nil, settings.Load().ModelEfforts[p.ID+"/"+model]) +} + +// Known are the model's own reasoning levels, when known. +func (p Provider) Known(model string) []string { for _, m := range p.Available() { if m.ID == model { return effortsOf(m) diff --git a/model_cli.go b/model_cli.go index 84626e4a..8f2c73c2 100644 --- a/model_cli.go +++ b/model_cli.go @@ -115,9 +115,14 @@ func modelEfforts(args []string) error { id := p.ID + "/" + model all := p.Efforts(model) rest := args[1:] + given := len(p.Known(model)) == 0 // levels aren't known: those it has are the user's if len(rest) == 0 { if len(all) == 0 { - fmt.Println(muted.Render(id + " has no reasoning levels")) + fmt.Println(muted.Render(id+" has no reasoning levels known"), faint.Render("· give it some of "+strings.Join(provider.Levels, ", "))) + return nil + } + if given { + fmt.Println(bold.Render(strings.Join(all, " ")), muted.Render("· given it · --reset takes them away")) return nil } kept, narrowed := p.ModelEfforts()[model] @@ -146,6 +151,14 @@ func modelEfforts(args []string) error { if err := provider.SetModelEfforts(id, levels); err != nil { return err } + if given { + if all = p.Efforts(model); len(all) > 0 { + fmt.Println(green.Render("✓"), id, muted.Render("offers"), bold.Render(strings.Join(all, ", "))) + } else { + fmt.Println(green.Render("✓"), id, muted.Render("has no reasoning levels again")) + } + return nil + } if kept, ok := p.ModelEfforts()[model]; ok { fmt.Println(green.Render("✓"), id, muted.Render("offers"), bold.Render(strings.Join(kept, ", "))) } else {