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 }