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:
7 files changed
+145
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user