effort: Claude Code can start at max — settings.json's effortLevel keeps up to xhigh (/effort max lasts a session), so magpie keeps max as CLAUDE_CODE_EFFORT_LEVEL in its env, which every session asks for (tested: Claude Code then sends output_config.effort max), and another level takes it away. A model whose reasoning levels aren't known (one models.dev doesn't list, a custom provider's) can be given some of none…max on the Providers page or with magpie model efforts, which the lists agents get and the gateway then take it to have: a request's effort is fitted to them, max sent as max

This commit is contained in:
yetone committed 2026-09-27 11:05:47 +08:00
1 parent 035dd5b5e3
commit c1d38cc50f
10 files changed
+151 -23

No files matched your search

+20 -4
View File
@@ -1,6 +1,7 @@
package agent package agent
import ( import (
"cmp"
"fmt" "fmt"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -30,8 +31,13 @@ var claudeEnv = []string{
// A tier that has none follows the main model. // A tier that has none follows the main model.
var claudeTiers = []string{"opus", "sonnet", "haiku", "fable"} var claudeTiers = []string{"opus", "sonnet", "haiku", "fable"}
// claudeEfforts are the levels settings.json's effortLevel takes. // claudeEfforts are the levels Claude Code starts with: settings.json's
var claudeEfforts = []string{"low", "medium", "high", "xhigh"} // 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" } 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; // 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", Key: "effort", Label: "effort",
Get: jsonGet(path, "effortLevel"), Get: func() string { return cmp.Or(env(claudeEffortEnv), jsonGet(path, "effortLevel")()) },
Set: func(v string) error { Set: func(v string) error {
if v != "" && !contains(claudeEfforts, v) { if v != "" && !contains(claudeEfforts, v) {
return fmt.Errorf("Claude Code keeps an effort of %s, not %q", strings.Join(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) return jsonSet(path, "effortLevel")(v)
}, },
Options: func(map[string]string) []Option { Options: func(map[string]string) []Option {
+38
View File
@@ -72,3 +72,41 @@ func TestClaudeTiers(t *testing.T) {
t.Fatal("theme lost") 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")
}
}
+3 -4
View File
@@ -67,10 +67,9 @@ func TestEffortFields(t *testing.T) {
writeFile(t, filepath.Join(home, ".claude", "settings.json"), `{"theme":"dark","model":"opus"}`) writeFile(t, filepath.Join(home, ".claude", "settings.json"), `{"theme":"dark","model":"opus"}`)
c := claude(home) c := claude(home)
setEffort(t, c, "xhigh", `"theme"`) setEffort(t, c, "xhigh", `"theme"`)
// max lasts a session in Claude Code; settings.json drops it // max lasts a session in Claude Code's /effort; magpie keeps it as its
if err := c.Field("effort").Set("max"); err == nil { // env (TestClaudeEffortMax)
t.Fatal("claude took max") setEffort(t, c, "max", `"theme"`)
}
writeFile(t, filepath.Join(home, ".hermes", "config.yaml"), "model:\n default: x\nagent:\n max_turns: 9\n") writeFile(t, filepath.Join(home, ".hermes", "config.yaml"), "model:\n default: x\nagent:\n max_turns: 9\n")
h := hermes(home) h := hermes(home)
+7 -4
View File
@@ -2833,16 +2833,19 @@ function renderModels(p) {
who.append(name, el("code", "", m.id)); who.append(name, el("code", "", m.id));
row.append(who); row.append(who);
const levels = m.efforts || []; 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) { if (levels.length > 1) {
const lv = el("div", "mlevels"); const lv = el("div", "mlevels");
lv.title = t("Reasoning levels agents are offered"); 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.kept : levels; const kept = m.kept?.length || m.given ? m.kept || [] : levels;
for (const l of levels) { for (const l of levels) {
const [tk, cb] = tick(t(l), kept.includes(l)); const [tk, cb] = tick(t(l), kept.includes(l));
cb.onchange = () => { cb.onchange = () => {
const next = levels.filter((x) => x === l ? cb.checked : kept.includes(x)); 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; } 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 ? [] : next }, t("{id}: {levels}", { id: m.id, levels: next.map((x) => t(x)).join(", ") })); 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); lv.append(tk);
} }
+1
View File
@@ -383,6 +383,7 @@ const I18N = {
"{id} is called {name}": "{id} 已改名为 {name}", "{id} is called {name}": "{id} 已改名为 {name}",
"{id} has its own name again": "{id} 已恢复默认名称", "{id} has its own name again": "{id} 已恢复默认名称",
"Reasoning levels agents are offered": "提供给 Agent 的推理档位", "Reasoning levels agents are offered": "提供给 Agent 的推理档位",
"Its reasoning levels aren't known: tick the ones it takes": "未知这个模型的推理档位:勾选它支持的档位",
"Keep at least one level": "至少保留一个档位", "Keep at least one level": "至少保留一个档位",
"{id}: {levels}": "{id}:{levels}", "{id}: {levels}": "{id}:{levels}",
"Restore default": "恢复默认", "Restore default": "恢复默认",
+5 -1
View File
@@ -27,7 +27,8 @@ type modelJSON struct {
Default string `json:"default,omitempty"` // its own name, when the user gave it another 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 Kept []string `json:"kept,omitempty"` // the reasoning levels the user keeps of Efforts, when not all
Efforts []string `json:"efforts,omitempty"` 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 { type providerJSON struct {
@@ -219,6 +220,9 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
j.Default = cmp.Or(m.Name, m.ID) j.Default = cmp.Or(m.Name, m.ID)
j.Name = n j.Name = n
} }
if len(j.Efforts) == 0 {
j.Efforts, j.Given = provider.Levels, true
}
if k, ok := kept[m.ID]; ok { if k, ok := kept[m.ID]; ok {
j.Kept = slices.DeleteFunc(slices.Clone(j.Efforts), func(e string) bool { return !slices.Contains(k, e) }) j.Kept = slices.DeleteFunc(slices.Clone(j.Efforts), func(e string) bool { return !slices.Contains(k, e) })
} }
+20 -8
View File
@@ -22,7 +22,9 @@ import (
// (low, medium and high of its six): settings' ModelEfforts, kept the same // (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 // 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 // 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. // ModelName is the name the user gave a provider's model, if any.
func ModelName(pid, model string) (string, bool) { func ModelName(pid, model string) (string, bool) {
@@ -88,25 +90,31 @@ func SetModelName(ref, name string) error {
return nil 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 // 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 // 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 { func SetModelEfforts(ref string, efforts []string) error {
p, model, err := splitRef(ref) p, model, err := splitRef(ref)
if err != nil { if err != nil {
return err return err
} }
all := p.Efforts(model) all := p.Known(model)
var keep []string var keep []string
for _, e := range efforts { for _, e := range efforts {
e = strings.ToLower(strings.TrimSpace(e)) e = strings.ToLower(strings.TrimSpace(e))
if e == "" { if e == "" {
continue continue
} }
if !slices.Contains(all, e) { if len(all) == 0 && !slices.Contains(Levels, e) {
if len(all) == 0 { return fmt.Errorf("%q is not a reasoning level (they are %s)", e, strings.Join(Levels, ", "))
return fmt.Errorf("%s/%s has no reasoning levels to choose from", p.ID, model) }
} 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, ", ")) 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) { 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 // 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 // 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 { func effortsKept(all, kept []string) []string {
if len(kept) == 0 { if len(kept) == 0 {
return all 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) }) out := slices.DeleteFunc(slices.Clone(all), func(e string) bool { return !slices.Contains(kept, e) })
if len(out) == 0 { if len(out) == 0 {
return all return all
+33
View File
@@ -179,3 +179,36 @@ func TestModelEfforts(t *testing.T) {
t.Fatal(got) 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")
}
}
+10 -1
View File
@@ -274,8 +274,17 @@ func (p Provider) RejectsTemperature(model string) bool {
return false 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 { 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() { for _, m := range p.Available() {
if m.ID == model { if m.ID == model {
return effortsOf(m) return effortsOf(m)
+14 -1
View File
@@ -115,9 +115,14 @@ func modelEfforts(args []string) error {
id := p.ID + "/" + model id := p.ID + "/" + model
all := p.Efforts(model) all := p.Efforts(model)
rest := args[1:] 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(rest) == 0 {
if len(all) == 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 return nil
} }
kept, narrowed := p.ModelEfforts()[model] kept, narrowed := p.ModelEfforts()[model]
@@ -146,6 +151,14 @@ func modelEfforts(args []string) error {
if err := provider.SetModelEfforts(id, levels); err != nil { if err := provider.SetModelEfforts(id, levels); err != nil {
return err 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 { if kept, ok := p.ModelEfforts()[model]; ok {
fmt.Println(green.Render("✓"), id, muted.Render("offers"), bold.Render(strings.Join(kept, ", "))) fmt.Println(green.Render("✓"), id, muted.Render("offers"), bold.Render(strings.Join(kept, ", ")))
} else { } else {