gateway: a model that thinks with no levels to pick says it thinks on /v1/models (#402) — mimo-v2.6-flash (Xiaomi's, OpenCode Go's, and a group of them) was listed reasoning: false with no levels, though models.dev says it reasons: /v1/models took reasoning to be having levels, and the model thinks with a switch alone. A model now carries whether it thinks — models.dev's reasoning flag for its own vendor, a plugin's, else what most of the providers models.dev lists serving it say, for a vendor's or relay's list that says nothing of it (Xiaomi's API gives ids alone) — and is listed reasoning: true with empty supported_reasoning_levels, told apart from one that doesn't think; levels the user kept down to none leave it thinking. A group thinks when every member does, or when it offers levels. TestModelsReasoningWithoutLevels fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeRunEndsWhenTheClientLeaves and TestUserPath, load flakes, pass alone).

This commit is contained in:
yetone committed 2026-10-01 16:20:10 +08:00
1 parent 5b917c1d8f
commit 9ac96e23cd
7 files changed
+145 -7

No files matched your search

+30 -2
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -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 {
+99
View File
@@ -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)
}
}
}
+5 -3
View File
@@ -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
}
+8
View File
@@ -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
}
+1 -1
View File
@@ -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