From 7118aaabcc19a2a6ca57313b975332d0b4a7656f Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 15:38:44 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20Cursor=20Private=20Inference=20shows?= =?UTF-8?q?=20its=20Reasoning=20control=20for=20magpie's=20models=20(mamba?= =?UTF-8?q?=20on=20Discord)=20=E2=80=94=20its=20picker=20(cursor-agent-exe?= =?UTF-8?q?c=203.23.12)=20keeps=20a=20/models=20row's=20capabilities=20onl?= =?UTF-8?q?y=20when=20they=20say=20the=20model=20calls=20tools,=20streams?= =?UTF-8?q?=20and=20writes=20text,=20and=20magpie's=20said=20neither,=20so?= =?UTF-8?q?=20every=20model=20was=20listed=20with=20no=20Reasoning=20contr?= =?UTF-8?q?ol.=20The=20rows=20Cursor=20Private=20Inference=20is=20shown=20?= =?UTF-8?q?now=20carry=20supports=5Ftool=5Fuse,=20supports=5Fstreaming,=20?= =?UTF-8?q?output=5Fmodalities=20[text],=20supports=5Freasoning=20and=20th?= =?UTF-8?q?e=20model's=20levels=20(reasoning=5Feffort),=20and=20a=20Claude?= =?UTF-8?q?=20served=20on=20Anthropic=20Messages=20is=20listed=20there=20a?= =?UTF-8?q?lone,=20the=20only=20API=20Cursor=20sends=20a=20Claude's=20leve?= =?UTF-8?q?l=20(output=5Fconfig.effort)=20on.=20Which=20levels=20it=20offe?= =?UTF-8?q?rs=20Cursor=20still=20decides=20from=20the=20id=20(Claude=20Opu?= =?UTF-8?q?s/Sonnet=204.5+,=20GPT-5.x,=20Grok=204.x,=20Gemini);=20it=20has?= =?UTF-8?q?=20no=20Max=20toggle=20for=20a=20gateway's=20models.=20TestCurs?= =?UTF-8?q?orLocalModels=20fails=20without=20the=20change.=20go=20vet,=20g?= =?UTF-8?q?o=20build=20(darwin,=20GOOS=3Dlinux,=20GOOS=3Dwindows)=20and=20?= =?UTF-8?q?go=20test=20-tags=20nogui=20./...=20under=20a=20temp=20HOME=20p?= =?UTF-8?q?ass=20but=20for=20TestAccountsOfAPlugin,=20which=20fails=20on?= =?UTF-8?q?=20main=20as=20well.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/cursor_local_models_test.go | 35 ++++++++++++++++++-- internal/gateway/gateway.go | 18 +++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/gateway/cursor_local_models_test.go b/internal/gateway/cursor_local_models_test.go index 90f14dd9..befd0879 100644 --- a/internal/gateway/cursor_local_models_test.go +++ b/internal/gateway/cursor_local_models_test.go @@ -17,10 +17,16 @@ import ( // by any other client, the list is as it was. func TestCursorLocalModels(t *testing.T) { setup(t, provider.Anthropic, &fake{}) - if err := provider.Save(provider.Provider{ID: "ds", Name: "DS", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"flash"}}); err != nil { + if err := provider.Save(provider.Provider{ID: "ds", Name: "DS", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"flash", "claude-sonnet-4-6"}}); err != nil { t.Fatal(err) } - if err := catalog.SaveLive("ds", "http://127.0.0.1:1/v1", []catalog.Model{{ID: "flash", Context: 128000, Output: 8192}}); err != nil { + if err := catalog.SaveLive("ds", "http://127.0.0.1:1/v1", []catalog.Model{{ID: "flash", Context: 128000, Output: 8192}, {ID: "claude-sonnet-4-6"}}); err != nil { + t.Fatal(err) + } + if err := provider.Save(provider.Provider{ID: "or", Name: "OR", Key: "k", Chat: "http://127.0.0.1:2/v1", Anthropic: "http://127.0.0.1:2", Models: []string{"claude-opus-4-7"}}); err != nil { + t.Fatal(err) + } + if err := catalog.SaveLive("or", "http://127.0.0.1:2/v1", []catalog.Model{{ID: "claude-opus-4-7", Efforts: []string{"low", "medium", "high"}}}); err != nil { t.Fatal(err) } list := func(key string) map[string]map[string]any { @@ -64,6 +70,31 @@ func TestCursorLocalModels(t *testing.T) { if c["context_length"] != float64(128000) || c["max_output_tokens"] != float64(8192) { t.Errorf("capabilities = %v", ds["capabilities"]) } + // its picker drops capabilities that don't say the model streams, calls + // tools and writes text, and with them its Reasoning control (mamba on + // Discord); one that thinks says so, with its levels + if c["supports_tool_use"] != true || c["supports_streaming"] != true || c["supports_reasoning"] != false { + t.Errorf("capabilities = %v", c) + } + if mods, _ := c["output_modalities"].([]any); len(mods) != 1 || mods[0] != "text" { + t.Errorf("output_modalities = %v", c["output_modalities"]) + } + cl := got["or/claude-opus-4-7"] + if cl == nil { + t.Fatalf("models: %v", got) + } + cc, _ := cl["capabilities"].(map[string]any) + if efforts, _ := cc["reasoning_effort"].([]any); cc["supports_reasoning"] != true || len(efforts) != 3 { + t.Errorf("a thinking model's capabilities = %v", cc) + } + // it sends a Claude's level on Anthropic Messages only, so a Claude + // served on both is listed there alone; a Chat-only one stays on Chat + if ts := types(cl); !slices.Equal(ts, []string{"anthropic_messages"}) { + t.Errorf("a Claude on Chat and Messages: api_types = %v", ts) + } + if ts := types(got["ds/claude-sonnet-4-6"]); !slices.Equal(ts, []string{"chat_completions"}) { + t.Errorf("a Claude on Chat alone: api_types = %v", ts) + } for _, key := range []string{Token, TokenFor("cursor"), TokenFor("opencode")} { for id, m := range list(key) { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 1fcf47d5..3294914c 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -570,6 +570,14 @@ func modelObject(e provider.Entry) map[string]any { // keeps its thinking; any, for one translated anyway — and capabilities, // its window, output and whether it takes images, without which it falls // back to Chat and limits of its own. +// +// Its picker (cursor-agent-exec, 3.23.12) keeps a row's capabilities only +// when they say it streams, calls tools and writes text (supports_tool_use, +// supports_streaming, output_modalities); without them it shows the model +// with no Reasoning control (mamba on Discord). Which levels it offers it +// decides itself, from the id after its last "/" (claude-opus-4-7, gpt-5.5, +// gemini-*, which needs supports_reasoning), and it sends a Claude's level +// only on Anthropic Messages, so a Claude any API would do is asked there. func cursorLocalModel(m map[string]any, e provider.Entry) { names := map[string]string{"/v1/chat/completions": "chat_completions", "/v1/responses": "responses", "/v1/messages": "anthropic_messages"} types := []string{} @@ -579,8 +587,16 @@ func cursorLocalModel(m map[string]any, e provider.Entry) { if len(types) == 0 { types = []string{"chat_completions", "responses", "anthropic_messages"} } + bare := strings.ToLower(e.ID[strings.LastIndex(e.ID, "/")+1:]) + if strings.HasPrefix(bare, "claude-") && slices.Contains(types, "anthropic_messages") { + types = []string{"anthropic_messages"} + } m["api_types"] = types - c := map[string]any{} + c := map[string]any{"supports_tool_use": true, "supports_streaming": true, "output_modalities": []string{"text"}, + "supports_reasoning": e.Reasoning || len(e.Efforts) > 0} + if len(e.Efforts) > 0 { + c["reasoning_effort"] = e.Efforts + } if e.Context > 0 { c["context_length"] = e.Context }