diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 75e06139..4e538440 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -611,6 +611,8 @@ func piLike(at place, id, name, dir string) *Agent { get := func(k string) (string, bool) { return edit.GetJSON(path, k) } set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) } pair := pairSet(set, "defaultProvider", "defaultModel") + // the model a new session starts on, as the model field shows it + startup := func() string { return piStartup(path, pairGet(get, "defaultProvider", "defaultModel")()) } writeMagpie := func() error { return edit.SetJSON(modelsPath, edit.KV{Path: "providers." + magpieID, Value: magpieProviderJSONAt("pi", id, at.gw())}) } @@ -630,7 +632,7 @@ func piLike(at place, id, name, dir string) *Agent { { Key: "model", Label: "model", // what a new session starts on, which enabledModels decides - Get: func() string { return piStartup(path, pairGet(get, "defaultProvider", "defaultModel")()) }, + Get: startup, Set: func(v string) error { if v == "" { if err := edit.DelJSON(path, "defaultProvider", "defaultModel"); err != nil { @@ -663,8 +665,20 @@ func piLike(at place, id, name, dir string) *Agent { // here replaced model fields a person had edited, such as a // contextWindow. Picking the model, and the catalog sync, still // refresh the provider. + // + // For a model of magpie's, the levels are those Pi offers for + // it, from the entry magpie writes, and a level it doesn't + // offer is shown as the one Pi runs it at: magpie showed max + // for a group Pi offered off alone for, so ran without + // reasoning (#597). Key: "effort", Label: "thinking", - Get: func() string { v, _ := get("defaultThinkingLevel"); return v }, + Get: func() string { + v, _ := get("defaultThinkingLevel") + if offered := piOffered(id, startup()); v != "" && offered != nil { + return piClamp(v, offered) + } + return v + }, Set: func(v string) error { cur, _ := get("defaultThinkingLevel") if v == cur { @@ -675,8 +689,11 @@ func piLike(at place, id, name, dir string) *Agent { } return set(edit.KV{Path: "defaultThinkingLevel", Value: v}) }, - Options: func(map[string]string) []Option { - return static("off", "minimal", "low", "medium", "high", "xhigh", "max") + Options: func(cur map[string]string) []Option { + if offered := piOffered(id, cur["model"]); offered != nil { + return static(offered...) + } + return static(piLevels...) }, }, }, diff --git a/internal/agent/legacy_test.go b/internal/agent/legacy_test.go index 3615ca32..5bf5fa02 100644 --- a/internal/agent/legacy_test.go +++ b/internal/agent/legacy_test.go @@ -111,6 +111,11 @@ func TestMoveCursorEfforts(t *testing.T) { if err := provider.Save(provider.Provider{ID: "curs", Name: "Curs", Chat: "https://curs.example/v1", Key: "k", Models: []string{"grok-4.7"}}); err != nil { t.Fatal(err) } + // Pi is offered the levels its entry gives the model (#597): a model + // with none would be offered off alone, and low not moved + if err := provider.SetModelEfforts("curs/grok-4.7", []string{"low", "high"}); err != nil { + t.Fatal(err) + } base := func(id string) (string, string, bool) { if id == "grok-4.7-low" || id == "gone-low" { return strings.TrimSuffix(id, "-low"), "low", true diff --git a/internal/agent/pi.go b/internal/agent/pi.go index 068caee2..24986116 100644 --- a/internal/agent/pi.go +++ b/internal/agent/pi.go @@ -107,3 +107,67 @@ func piStartup(settings, def string) string { } return first } + +// piOffered are the thinking levels Pi offers for model, a provider/model +// as the model field shows it, when it is one of magpie's: what Pi's +// /thinking lists for the entry magpie writes for it in models.json. nil +// for any other model, whose levels are Pi's own business. +func piOffered(agentID, model string) []string { + ref, ok := strings.CutPrefix(model, magpieID+"/") + if !ok { + return nil + } + for _, m := range magpieModels(agentID) { + if m.ID == ref { + return piSupported(piModelJSON(m, "", true)) + } + } + return nil +} + +// piSupported are the thinking levels Pi offers for a models.json entry, as +// pi-ai's getSupportedThinkingLevels reads it: off alone for a model not +// marked reasoning; else each of its levels not mapped to null, xhigh and +// max only where they are mapped. +func piSupported(e map[string]any) []string { + if r, _ := e["reasoning"].(bool); !r { + return []string{"off"} + } + levels, _ := e["thinkingLevelMap"].(map[string]any) + var out []string + for _, l := range piLevels { + v, set := levels[l] + if set && v == nil || !set && (l == "xhigh" || l == "max") { + continue + } + out = append(out, l) + } + return out +} + +// piClamp is the level Pi runs a model at when it is set to level, as +// pi-ai's clampThinkingLevel picks it among the levels it offers: level +// itself, else the nearest above it, else the nearest below. +func piClamp(level string, offered []string) string { + if slices.Contains(offered, level) { + return level + } + at := slices.Index(piLevels, level) + if at < 0 { + if len(offered) > 0 { + return offered[0] + } + return "off" + } + for _, l := range piLevels[at:] { + if slices.Contains(offered, l) { + return l + } + } + for i := at - 1; i >= 0; i-- { + if slices.Contains(offered, piLevels[i]) { + return piLevels[i] + } + } + return "off" +} diff --git a/internal/agent/pi_group_effort_test.go b/internal/agent/pi_group_effort_test.go new file mode 100644 index 00000000..ea98eb3c --- /dev/null +++ b/internal/agent/pi_group_effort_test.go @@ -0,0 +1,117 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// #597: Pi on a routing group of DeepSeek V4 Pro (TokenHub's, and the Token +// Plan's deepseek-v4-pro-202606 that nothing magpie reads knows) was +// written the group without reasoning, so its /thinking offered off alone, +// while magpie's thinking field offered every level and showed max — which +// Pi clamped to off, sending no reasoning_effort. The group now has its +// known member's levels, Pi's entry offers them, and magpie's field offers +// what Pi offers for the model and shows the level Pi runs it at. +func TestPiGroupThinkingIsWhatPiRuns(t *testing.T) { + home := syncHome(t) + if err := os.WriteFile(catalog.CachePath(), []byte(`{ + "deepseek": {"models": {"deepseek-v4-pro": {"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","reasoning":true, + "reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000}}}} + }`), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + for _, p := range []provider.Provider{ + {ID: "th", Name: "TokenHub", Key: "k", Chat: "http://127.0.0.1:1/v1", Catalog: "tencent-tokenhub", Models: []string{"deepseek-v4-pro"}}, + {ID: "tp", Name: "Token Plan", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"deepseek-v4-pro-202606"}}, + } { + if err := provider.Save(p); err != nil { + t.Fatal(err) + } + } + if err := provider.SaveGroup(provider.Group{ID: "deepseek", Name: "腾讯DeepSeek", Members: []string{"tp/deepseek-v4-pro-202606", "th/deepseek-v4-pro"}}); err != nil { + t.Fatal(err) + } + if err := provider.SaveGroup(provider.Group{ID: "plan", Name: "Plan", Members: []string{"tp/deepseek-v4-pro-202606"}}); err != nil { + t.Fatal(err) + } + a := pi(home) + if err := a.Field("model").Set("magpie/group/deepseek"); err != nil { + t.Fatal(err) + } + if err := a.Field("effort").Set("max"); err != nil { + t.Fatal(err) + } + + var file map[string]any + if err := json.Unmarshal([]byte(readFile(filepath.Join(home, ".pi", "agent", "models.json"))), &file); err != nil { + t.Fatal(err) + } + var entry map[string]any + for _, raw := range file["providers"].(map[string]any)["magpie"].(map[string]any)["models"].([]any) { + if m := raw.(map[string]any); m["id"] == "group/deepseek" { + entry = m + } + } + if entry == nil || entry["reasoning"] != true { + t.Fatalf("group entry: %v", entry) + } + if got := piSupported(entry); !reflect.DeepEqual(got, []string{"low", "high", "max"}) { + t.Fatalf("Pi offers %v for the group: %v", got, entry["thinkingLevelMap"]) + } + + values := func(os []Option) []string { + var out []string + for _, o := range os { + out = append(out, o.Value) + } + return out + } + effort := a.Field("effort") + if got := values(effort.Options(map[string]string{"model": "magpie/group/deepseek"})); !reflect.DeepEqual(got, []string{"low", "high", "max"}) { + t.Fatalf("offered %v", got) + } + if got := effort.Get(); got != "max" { + t.Fatalf("shown %q, want max", got) + } + + // on a group Pi can't reason with, max is shown as the off Pi runs at + if err := a.Field("model").Set("magpie/group/plan"); err != nil { + t.Fatal(err) + } + if got := values(effort.Options(map[string]string{"model": "magpie/group/plan"})); !reflect.DeepEqual(got, []string{"off"}) { + t.Fatalf("plan: offered %v", got) + } + if got := effort.Get(); got != "off" { + t.Fatalf("plan: shown %q, want off", got) + } + // a model of Pi's own keeps every level + if got := values(effort.Options(map[string]string{"model": "anthropic/claude-x"})); !reflect.DeepEqual(got, piLevels) { + t.Fatalf("own model: offered %v", got) + } +} + +// Pi's clamp: the level itself, else the nearest above, else below. +func TestPiClamp(t *testing.T) { + for _, c := range []struct { + level string + offered []string + want string + }{ + {"max", []string{"off"}, "off"}, + {"max", []string{"low", "high", "max"}, "max"}, + {"medium", []string{"low", "high", "max"}, "high"}, + {"xhigh", []string{"off", "low", "medium", "high"}, "high"}, + {"minimal", []string{"low", "high"}, "low"}, + } { + if got := piClamp(c.level, c.offered); got != c.want { + t.Errorf("%s of %v: %s, want %s", c.level, c.offered, got, c.want) + } + } +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 53f7002f..3003c370 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -485,6 +485,12 @@ func Thinks(id string) bool { return thinks[bareID(id)] } +// Knows reports whether models.dev lists a model of this id at all, under +// any provider, as ContextOf and EffortsOf match it. +func Knows(id string) bool { + return ContextOf(id) > 0 || len(EffortsOf(id)) > 0 || Thinks(id) +} + // SeesImages reports whether models.dev says a model of this id takes // images, as most of the providers it lists serving it do; for a vendor it // doesn't list, serving a model it knows from others ("z-ai/glm-5.3" is diff --git a/internal/provider/group.go b/internal/provider/group.go index 63366696..a1b9ec02 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -26,6 +26,7 @@ import ( "slices" "strings" + "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/settings" ) @@ -497,7 +498,8 @@ func membersIn(entries []Entry, all []Group, g Group) []Member { // as the user named it, answering for its first member when an agent asks // what the model can do, and offering only the reasoning levels every // member has — but for those fixed at an effort of their own, which take -// whatever the agent asks. With every member fixed, the group offers the +// whatever the agent asks, and those whose levels nothing magpie reads +// knows (levelsUnknown), which are sent it as asked. With every member fixed, the group offers the // levels they are fixed at, so that an agent still asks it to reason. // A group that names its own levels (Group.Levels) offers those, and so // does a group in it for its models. @@ -527,9 +529,11 @@ func groupEntries(entries []Entry) []Entry { var efforts []string images, thinks, ctx, output := false, false, 0, 0 var imageInput *bool + unknown := false for _, x := range entries { if x.Provider.ID == m.Provider.ID && x.Model == m.Model { efforts, images, thinks, ctx, output, imageInput = x.Efforts, x.Images, x.Reasoning, x.Context, x.Output, x.ImageInput + unknown = levelsUnknown(x) } } e.Reasoning = e.Reasoning && thinks @@ -554,6 +558,14 @@ func groupEntries(entries []Entry) []Entry { fixed = append(fixed, m.Effort) } continue + } else if unknown { + // nothing magpie reads says which levels it takes, or that + // it takes none: the gateway sends it the effort asked as + // it is (fitEffort), so it doesn't take the others' away — + // a Token Plan's deepseek-v4-pro-202606 beside a TokenHub + // deepseek-v4-pro left the group none, and Pi only off + // (#597) + continue } ultra = ultra || slices.Contains(efforts, "ultra") if !levelled { @@ -586,6 +598,23 @@ func groupEntries(entries []Entry) []Entry { return out } +// levelsUnknown reports whether nothing magpie reads says which reasoning +// levels the model of x takes, or that it takes none: it has none in the +// catalog, no account's list gives it, its vendor and its maker don't list +// it, and models.dev lists no model of its id at all. +func levelsUnknown(x Entry) bool { + if len(x.Efforts) > 0 || x.Provider.Account != nil && x.Provider.Account.models != nil { + return false + } + if _, ok := catalog.ListedBy(x.Provider.Catalogs(), x.Model); ok { + return false + } + if _, ok := catalog.ListedBy(makerCatalogs(), x.Model); ok { + return false + } + return !catalog.Knows(x.Model) +} + // SaveGroup adds or replaces a group of the user's. Changing one magpie // found makes it the user's. func SaveGroup(g Group) error { diff --git a/internal/provider/grouplevels_unknown_test.go b/internal/provider/grouplevels_unknown_test.go new file mode 100644 index 00000000..2352a6fa --- /dev/null +++ b/internal/provider/grouplevels_unknown_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// #597: a routing group of DeepSeek V4 Pro on Tencent's TokenHub and on its +// Token Plan (deepseek-v4-pro-202606, which nothing magpie reads knows) +// offered no reasoning levels, so Pi listed off alone for it and ran it +// without reasoning while magpie showed max. A model a provider serves that +// its vendor's list leaves out takes the levels the gateway fits an effort +// to (Provider.Efforts), and a member whose levels nothing knows takes +// whatever the agent asks rather than taking the others' away. A member +// known to have none still does. +func TestGroupLevelsWithUnknownMember(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cache := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cache) + os.MkdirAll(filepath.Join(cache, "magpie"), 0o755) + if err := os.WriteFile(catalog.CachePath(), []byte(`{ + "deepseek": {"models": {"deepseek-v4-pro": {"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","reasoning":true, + "reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"limit":{"context":1000000}}}}, + "tencent-tokenhub": {"models": {"hy3": {"id":"hy3","reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}]}}}, + "plaincat": {"models": {"plain-chat": {"id":"plain-chat","limit":{"context":128000}}}} + }`), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + t.Cleanup(catalog.Reset) + for _, p := range []Provider{ + // TokenHub's models.dev list has its Hy models alone + {ID: "th", Name: "TokenHub", Key: "k", Chat: "http://127.0.0.1:1/v1", Catalog: "tencent-tokenhub", Models: []string{"deepseek-v4-pro"}}, + {ID: "tp", Name: "Token Plan", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"deepseek-v4-pro-202606"}}, + {ID: "pl", Name: "Plain", Key: "k", Chat: "http://127.0.0.1:1/v1", Catalog: "plaincat", Models: []string{"plain-chat"}}, + } { + if err := Save(p); err != nil { + t.Fatal(err) + } + } + want := []string{"low", "high", "max"} + entry := func(id string) Entry { + t.Helper() + e, ok := EntryOf(id) + if !ok { + t.Fatalf("%s not listed", id) + } + return e + } + // the catalog and the gateway agree on the model's levels + p, _ := Find("th") + if got := p.Efforts("deepseek-v4-pro"); !slices.Equal(got, want) { + t.Fatalf("gateway's levels: %v", got) + } + if e := entry("th/deepseek-v4-pro"); !slices.Equal(e.Efforts, want) { + t.Fatalf("catalog's levels: %v", e.Efforts) + } + for name, members := range map[string][]string{ + "ds": {"tp/deepseek-v4-pro-202606", "th/deepseek-v4-pro"}, + "plain": {"th/deepseek-v4-pro", "pl/plain-chat"}, + "tp": {"tp/deepseek-v4-pro-202606"}, + } { + if err := SaveGroup(Group{Name: name, Members: members}); err != nil { + t.Fatal(err) + } + } + if e := entry("group/ds"); !slices.Equal(e.Efforts, want) || !e.Reasoning { + t.Fatalf("ds: levels %v, reasoning %v", e.Efforts, e.Reasoning) + } + // a member listed without levels still takes them away, and a group + // of models nothing knows offers none + if e := entry("group/plain"); len(e.Efforts) != 0 { + t.Fatalf("plain: levels %v", e.Efforts) + } + if e := entry("group/tp"); len(e.Efforts) != 0 { + t.Fatalf("tp: levels %v", e.Efforts) + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index 9c6cab82..db1104a6 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -551,7 +551,10 @@ func (p Provider) Exposed() []catalog.Model { if m, ok := byID[id]; ok { out = append(out, m) } else { - out = append(out, catalog.Model{ID: id, Name: id, Provider: p.firstCatalog()}) + // with the levels the gateway fits an effort to (Known), + // not the none effortsOf takes a vendor's word for: the + // vendor's list doesn't have it, so it gave no word (#597) + out = append(out, catalog.Model{ID: id, Name: id, Provider: p.firstCatalog(), Efforts: p.knownElsewhere(id)}) } } return out @@ -617,8 +620,13 @@ func (p Provider) Known(model string) []string { return []string{l} } } - // one of the vendor's own its list leaves out (a preview) or typed in: - // the vendor's word on it, before the others' + return p.knownElsewhere(model) +} + +// knownElsewhere are the reasoning levels of a model the provider's list +// doesn't have — one of the vendor's own its list leaves out (a preview), +// or typed in: the vendor's word on it, before the others'. +func (p Provider) knownElsewhere(model string) []string { if e, ok := catalog.ListedBy(p.Catalogs(), model); ok { return e }