diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 296f37d5..82d73670 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -57,6 +57,9 @@ type Model struct { // Free is set on a model a subscription serves at no cost to its // allowance: WorkBuddy's "credits": "x0.00". Free bool `json:",omitempty"` + // Reasoning is set on a model that thinks, whether or not it takes + // levels: mimo-v2.6-flash thinks with a switch alone (#402). + Reasoning bool `json:",omitempty"` } func imageInput(modalities []string) *bool { @@ -94,6 +97,7 @@ type mdModel struct { Name string `json:"name"` ReleaseDate string `json:"release_date"` Temperature *bool `json:"temperature"` // false: rejects temperature/top_p + Thinks bool `json:"reasoning"` Reasoning []struct { Type string `json:"type"` Values []string `json:"values"` @@ -154,6 +158,9 @@ var ( // efforts are the models' reasoning levels, by bare id, as most of the // providers that give any for them give them efforts map[string][]string + // thinks are the models, by bare id, most of the providers serving + // them say reason, levels or not + thinks map[string]bool syncMu sync.Mutex ) @@ -196,7 +203,7 @@ func load() map[string]mdProvider { var m map[string]mdProvider if json.Unmarshal(b, &m) == nil && len(m) > 0 { mdev = m - votes := map[string]int{} + votes, reasons := map[string]int{}, map[string]int{} sizes, outs := map[string]map[int]int{}, map[string]map[int]int{} levels := map[string]map[string]int{} // one vote a provider for each list it gives a model: a @@ -216,6 +223,11 @@ func load() map[string]mdProvider { levels[bareID(id)][l]++ } } + if x.Thinks || len(x.efforts()) > 0 { + reasons[bareID(id)]++ + } else { + reasons[bareID(id)]-- + } if slices.Contains(x.Modalities.Input, "image") { votes[bareID(id)]++ } else { @@ -247,6 +259,12 @@ func load() map[string]mdProvider { for id, by := range levels { efforts[id] = strings.Split(mostListed(by), ",") } + thinks = map[string]bool{} + for id, v := range reasons { + if v > 0 { + thinks[id] = true + } + } images = map[string]bool{} for id, v := range votes { if v > 0 { @@ -266,7 +284,7 @@ func Reset() { loadMu.Lock() defer loadMu.Unlock() loaded = false - mdev, images, windows, outputs, efforts = nil, nil, nil, nil, nil + mdev, images, windows, outputs, efforts, thinks = nil, nil, nil, nil, nil, nil } // Sync downloads the models.dev catalog into CachePath. It serializes with @@ -413,6 +431,7 @@ func Provider(id string) []Model { mm := Model{ID: m.ID, Name: m.Name, Provider: id, Released: m.ReleaseDate, Price: m.Cost, Temperature: m.Temperature, Images: slices.Contains(m.Modalities.Input, "image"), ImageInput: imageInput(m.Modalities.Input), Context: m.window(), Output: m.Limit.Output} mm.Efforts = m.efforts() + mm.Reasoning = m.Thinks || len(mm.Efforts) > 0 out = append(out, mm) } sort.SliceStable(out, func(i, j int) bool { @@ -430,6 +449,15 @@ func ProviderName(id string) string { return load()[id].Name } +// Thinks reports whether models.dev says a model of this id reasons, as +// most of the providers it lists serving it do, whether or not they give +// it levels; for a vendor it doesn't list, serving a model it knows from +// others. +func Thinks(id string) bool { + load() + return thinks[bareID(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/catalog/live.go b/internal/catalog/live.go index 24c1aa69..a9053e69 100644 --- a/internal/catalog/live.go +++ b/internal/catalog/live.go @@ -409,6 +409,7 @@ func Decorate(live []Model, known []Model) []Model { m.Name = k.Name } m.Efforts, m.Released, m.Provider = k.Efforts, k.Released, k.Provider + m.Reasoning = m.Reasoning || k.Reasoning if m.ImageInput == nil { m.ImageInput = k.ImageInput m.Images = m.Images || k.Images diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 51edd331..f408cd42 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -410,7 +410,7 @@ func modelObject(e provider.Entry) map[string]any { levels = append(levels, reasoningLevel{Effort: effort}) } m := map[string]any{"id": e.ID, "object": "model", "type": "model", "created": 0, "created_at": "2025-01-01T00:00:00Z", - "owned_by": e.Provider.ID, "display_name": e.Name, "reasoning": len(levels) > 0, "supported_reasoning_levels": levels} + "owned_by": e.Provider.ID, "display_name": e.Name, "reasoning": e.Reasoning || len(levels) > 0, "supported_reasoning_levels": levels} // the window, as the names clients read it by: a group's context // (magpie group set … context=) included if e.Context > 0 { diff --git a/internal/gateway/thinks_test.go b/internal/gateway/thinks_test.go new file mode 100644 index 00000000..6d75c879 --- /dev/null +++ b/internal/gateway/thinks_test.go @@ -0,0 +1,99 @@ +package gateway + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// thinksCatalog is models.dev as it has mimo-v2.6-flash: reasoning with a +// thinking switch alone (Xiaomi's own) or no options at all (OpenCode +// Go's), never levels; a model that doesn't think, and one with levels. +const thinksCatalog = `{ + "xiaomi": {"models": { + "mimo-v2.6-flash": {"id":"mimo-v2.6-flash","reasoning":true,"reasoning_options":[{"type":"toggle"}]}}}, + "opencode-go": {"models": { + "mimo-v2.6-flash": {"id":"mimo-v2.6-flash","reasoning":true,"reasoning_options":[]}, + "plain": {"id":"plain","reasoning":false}, + "levelled": {"id":"levelled","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high"]}]}}} +}` + +// A model that thinks but takes no levels is listed on /v1/models with +// reasoning true and no levels, told apart from one that doesn't think +// (#402) — Xiaomi's own, a relay's whose list says nothing of reasoning, +// and a group of them. A group with a member that doesn't think doesn't +// say it does; a model with levels still lists them. +func TestModelsReasoningWithoutLevels(t *testing.T) { + fresh(t) + if err := os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalog.CachePath(), []byte(thinksCatalog), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + t.Cleanup(catalog.Reset) + xiaomi, err := provider.FromPreset("xiaomi") + if err != nil { + t.Fatal(err) + } + xiaomi.Key, xiaomi.Models = "k", []string{"mimo-v2.6-flash"} + if err := provider.Save(xiaomi); err != nil { + t.Fatal(err) + } + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Chat: "https://relay.test/v1", Key: "k", + Models: []string{"mimo-v2.6-flash", "plain", "levelled", "mystery"}}); err != nil { + t.Fatal(err) + } + // the relay's own list, as Xiaomi's API answers: ids alone + if err := catalog.SaveLive("relay", "https://relay.test/v1", []catalog.Model{ + {ID: "mimo-v2.6-flash"}, {ID: "plain"}, {ID: "levelled"}, {ID: "mystery"}}); err != nil { + t.Fatal(err) + } + for _, g := range []provider.Group{ + {Name: "Auto MiMo", Members: []string{"xiaomi/mimo-v2.6-flash", "relay/mimo-v2.6-flash"}, Routing: provider.Ordered}, + {Name: "Mixed", Members: []string{"relay/mimo-v2.6-flash", "relay/plain"}, Routing: provider.Ordered}, + } { + if err := provider.SaveGroup(g); err != nil { + t.Fatal(err) + } + } + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + var list struct { + Data []struct { + ID string `json:"id"` + Reasoning bool `json:"reasoning"` + Levels []json.RawMessage `json:"supported_reasoning_levels"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatalf("%v: %s", err, rec.Body) + } + type want struct { + reasoning bool + levels int + } + got := map[string]want{} + for _, m := range list.Data { + got[m.ID] = want{m.Reasoning, len(m.Levels)} + } + for id, w := range map[string]want{ + "xiaomi/mimo-v2.6-flash": {true, 0}, + "relay/mimo-v2.6-flash": {true, 0}, + "group/auto-mimo": {true, 0}, + "group/mixed": {false, 0}, + "relay/plain": {false, 0}, + "relay/mystery": {false, 0}, + "relay/levelled": {true, 2}, + } { + if g, ok := got[id]; !ok || g != w { + t.Errorf("%s: reasoning %v with %d levels (listed %v), want %v with %d", id, g.reasoning, g.levels, ok, w.reasoning, w.levels) + } + } +} diff --git a/internal/provider/group.go b/internal/provider/group.go index 66155e7d..d4b1fb1b 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -376,7 +376,7 @@ func groupEntries(entries []Entry) []Entry { if len(ms) == 0 { continue } - e := Entry{ID: GroupPrefix + g.ID, Model: ms[0].Model, Name: g.Name, Provider: ms[0].Provider, Group: g.ID, Images: true} + e := Entry{ID: GroupPrefix + g.ID, Model: ms[0].Model, Name: g.Name, Provider: ms[0].Provider, Group: g.ID, Images: true, Reasoning: true} var fixed []string // the efforts members are fixed at levelled := false // a member that follows the agent's effort was met // Codex's ultra (max, with Codex handing parts of the task to agents @@ -389,13 +389,14 @@ func groupEntries(entries []Entry) []Entry { e.Icons = append(e.Icons, m.Provider.Icon) // each provider once, "" for one without } var efforts []string - images, ctx, output := false, 0, 0 + images, thinks, ctx, output := false, false, 0, 0 var imageInput *bool for _, x := range entries { if x.Provider.ID == m.Provider.ID && x.Model == m.Model { - efforts, images, ctx, output, imageInput = x.Efforts, x.Images, x.Context, x.Output, x.ImageInput + efforts, images, thinks, ctx, output, imageInput = x.Efforts, x.Images, x.Reasoning, x.Context, x.Output, x.ImageInput } } + e.Reasoning = e.Reasoning && thinks if output > 0 && (e.Output == 0 || output < e.Output) { e.Output = output } @@ -435,6 +436,7 @@ func groupEntries(entries []Entry) []Entry { if ultra && slices.Contains(e.Efforts, "max") && !slices.Contains(e.Efforts, "ultra") { e.Efforts = append(e.Efforts, "ultra") } + e.Reasoning = e.Reasoning || len(e.Efforts) > 0 if e.ImageInput != nil && !*e.ImageInput { e.Images = false } diff --git a/internal/provider/models.go b/internal/provider/models.go index 48eb7a61..415d9754 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -715,6 +715,10 @@ type Entry struct { // Shared are a group's levels its members have in common: its Efforts, // unless the group names its own (Group.Levels). Shared []string `json:"-"` + // Reasoning is set on a model that thinks, levels or not: one with a + // thinking switch alone has it and no Efforts (a group's: every + // member thinks). + Reasoning bool `json:"reasoning,omitempty"` } // Catalog lists the routing groups, then every exposed model of every ready @@ -782,6 +786,10 @@ func entryFor(p Provider, m catalog.Model, s settings.Settings) Entry { if n, ok := modelNameIn(s.ModelNames, p.ID, m.ID); ok { e.Name, e.Default = n, m.Name } + // a model that thinks still does with the levels the user kept or + // none at all; one its source says nothing of thinks as most of the + // providers serving it say (#402) + e.Reasoning = m.Reasoning || len(e.Efforts) > 0 || catalog.Thinks(m.ID) e.Efforts = effortsKept(e.Efforts, s.ModelEfforts[e.ID]) return e } diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index 95111d63..5f5540b7 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -150,7 +150,7 @@ func pluginCatalog(pp plugin.Provider) []catalog.Model { c.Name = m.ID } if m.Reasoning { - c.Efforts = m.Variants + c.Efforts, c.Reasoning = m.Variants, true } // a built-in moved onto its plugin keeps the levels it had for a // model its vendor gives none: its maker's, as effortsOf borrows