Skip to content

Commit 1b98342

Browse files
joocursoragent
andcommitted
feat(core): return Claude Desktop format for GET /v1/models
Serve CC Switch–compatible model list fields (type, created_at, pagination) when ingress is Claude or the gateway uses the clovapi-local bearer token, so Claude Desktop can probe the local proxy before chat. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6c4afcc commit 1b98342

4 files changed

Lines changed: 182 additions & 18 deletions

File tree

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.107"
7+
Version = "dev0.1.109"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/proxy/models_list.go

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,15 @@ import (
1010
"github.com/clovapi/switcher/internal/provider"
1111
)
1212

13+
const (
14+
claudeDesktopGatewayAPIKey = "clovapi-local"
15+
claudeDesktopModelCreated = "2024-01-01T00:00:00Z"
16+
)
17+
1318
func (s *Server) serveIngressModels(w http.ResponseWriter, r *http.Request, trace *requestTrace, ingress provider.Ingress, store *profile.Store) {
14-
body := buildStaticModelsListBody(ingress.APIStyle, ingress.ProviderID, ingress.ModelID, store)
19+
claudeDesktop := isClaudeDesktopGatewayRequest(r) ||
20+
strings.EqualFold(strings.TrimSpace(ingress.APIStyle), string(apistyle.Claude))
21+
body := buildModelsListBody(ingress.ProviderID, ingress.ModelID, store, claudeDesktop)
1522
trace.setUpstreamResponse(http.StatusOK, http.Header{"Content-Type": []string{"application/json"}}, []byte(body))
1623
w.Header().Set("content-type", "application/json")
1724
w.WriteHeader(http.StatusOK)
@@ -20,15 +27,26 @@ func (s *Server) serveIngressModels(w http.ResponseWriter, r *http.Request, trac
2027
}
2128
}
2229

23-
func buildStaticModelsListBody(apiStyle, providerID, modelID string, store *profile.Store) string {
30+
func isClaudeDesktopGatewayRequest(r *http.Request) bool {
31+
if r == nil {
32+
return false
33+
}
34+
auth := strings.TrimSpace(r.Header.Get("Authorization"))
35+
if auth == "" {
36+
return false
37+
}
38+
const prefix = "Bearer "
39+
if !strings.HasPrefix(auth, prefix) && !strings.HasPrefix(auth, "bearer ") {
40+
return false
41+
}
42+
token := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(auth, "Bearer "), "bearer "))
43+
return token == claudeDesktopGatewayAPIKey
44+
}
45+
46+
func buildModelsListBody(providerID, modelID string, store *profile.Store, claudeDesktop bool) string {
2447
ids := vendorModelIDs(providerID, modelID, store)
25-
if strings.ToLower(strings.TrimSpace(apiStyle)) == string(apistyle.Claude) {
26-
rows := make([]map[string]string, 0, len(ids))
27-
for _, id := range ids {
28-
rows = append(rows, map[string]string{"type": "model", "id": id, "display_name": id})
29-
}
30-
data, _ := json.Marshal(map[string]any{"data": rows})
31-
return string(data)
48+
if claudeDesktop {
49+
return buildClaudeDesktopModelsListBody(ids)
3250
}
3351
rows := make([]map[string]string, 0, len(ids))
3452
for _, id := range ids {
@@ -38,6 +56,29 @@ func buildStaticModelsListBody(apiStyle, providerID, modelID string, store *prof
3856
return string(data)
3957
}
4058

59+
func buildClaudeDesktopModelsListBody(ids []string) string {
60+
rows := make([]map[string]any, 0, len(ids))
61+
for _, id := range ids {
62+
rows = append(rows, map[string]any{
63+
"type": "model",
64+
"id": id,
65+
"created_at": claudeDesktopModelCreated,
66+
})
67+
}
68+
var firstID, lastID any
69+
if len(ids) > 0 {
70+
firstID = ids[0]
71+
lastID = ids[len(ids)-1]
72+
}
73+
data, _ := json.Marshal(map[string]any{
74+
"data": rows,
75+
"has_more": false,
76+
"first_id": firstID,
77+
"last_id": lastID,
78+
})
79+
return string(data)
80+
}
81+
4182
func vendorModelIDs(providerID, modelID string, store *profile.Store) []string {
4283
seen := map[string]bool{}
4384
var ids []string
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package proxy
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/clovapi/switcher/internal/profile"
10+
"github.com/clovapi/switcher/internal/provider"
11+
)
12+
13+
func TestClaudeDesktopGatewayModelsListFormat(t *testing.T) {
14+
store := &profile.Store{
15+
Version: profile.StoreVersion,
16+
List: []profile.Profile{{
17+
Name: provider.CodexVendorName,
18+
Kind: "subscription",
19+
SubscriptionProviderID: provider.CodexProviderID,
20+
Models: []profile.Model{
21+
{ID: "gpt-5.5", Model: "gpt-5.5"},
22+
{ID: "gpt-5.4", Model: "gpt-5.4"},
23+
},
24+
}},
25+
}
26+
s := NewServer(profile.ProxyConfig{Host: "127.0.0.1", Port: 27483})
27+
s.ProfileLoader = func() (*profile.Store, error) { return store, nil }
28+
ts := httptest.NewServer(s.Server.Handler)
29+
defer ts.Close()
30+
31+
req, err := http.NewRequest(http.MethodGet, ts.URL+"/codex/v1/models", nil)
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
req.Header.Set("Authorization", "Bearer clovapi-local")
36+
resp, err := http.DefaultClient.Do(req)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
defer resp.Body.Close()
41+
if resp.StatusCode != http.StatusOK {
42+
t.Fatalf("status = %d", resp.StatusCode)
43+
}
44+
var body map[string]any
45+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
46+
t.Fatal(err)
47+
}
48+
if body["has_more"] != false {
49+
t.Fatalf("has_more = %#v", body["has_more"])
50+
}
51+
if body["first_id"] != "gpt-5.5" || body["last_id"] != "gpt-5.4" {
52+
t.Fatalf("first/last id = %#v / %#v", body["first_id"], body["last_id"])
53+
}
54+
data, ok := body["data"].([]any)
55+
if !ok || len(data) != 2 {
56+
t.Fatalf("data = %#v", body["data"])
57+
}
58+
item, _ := data[0].(map[string]any)
59+
if item["type"] != "model" || item["id"] != "gpt-5.5" {
60+
t.Fatalf("item = %#v", item)
61+
}
62+
}
63+
64+
func TestCodexModelsListWithoutDesktopBearerStaysOpenAIFormat(t *testing.T) {
65+
store := &profile.Store{
66+
Version: profile.StoreVersion,
67+
List: []profile.Profile{{
68+
Name: provider.CodexVendorName,
69+
Kind: "subscription",
70+
Models: []profile.Model{
71+
{ID: "gpt-5.5", Model: "gpt-5.5"},
72+
},
73+
}},
74+
}
75+
s := NewServer(profile.ProxyConfig{Host: "127.0.0.1", Port: 27483})
76+
s.ProfileLoader = func() (*profile.Store, error) { return store, nil }
77+
ts := httptest.NewServer(s.Server.Handler)
78+
defer ts.Close()
79+
80+
resp, err := http.Get(ts.URL + "/codex/v1/models")
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
defer resp.Body.Close()
85+
var body map[string]any
86+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
87+
t.Fatal(err)
88+
}
89+
if body["object"] != "list" {
90+
t.Fatalf("object = %#v", body["object"])
91+
}
92+
if _, ok := body["has_more"]; ok {
93+
t.Fatalf("unexpected Claude Desktop fields: %#v", body)
94+
}
95+
}
96+
97+
func TestIsClaudeDesktopGatewayRequest(t *testing.T) {
98+
req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:27483/codex/v1/models", nil)
99+
req.Header.Set("Authorization", "Bearer clovapi-local")
100+
if !isClaudeDesktopGatewayRequest(req) {
101+
t.Fatal("expected desktop gateway request")
102+
}
103+
req.Header.Set("Authorization", "Bearer sk-other")
104+
if isClaudeDesktopGatewayRequest(req) {
105+
t.Fatal("unexpected desktop gateway match")
106+
}
107+
}

core/internal/proxy/server_test.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,26 @@ func TestServerHealthAndModelsList(t *testing.T) {
5353
}
5454
var body struct {
5555
Data []struct {
56-
ID string `json:"id"`
56+
ID string `json:"id"`
57+
Type string `json:"type"`
58+
DisplayName string `json:"display_name"`
5759
} `json:"data"`
60+
HasMore bool `json:"has_more"`
61+
FirstID string `json:"first_id"`
62+
LastID string `json:"last_id"`
5863
}
5964
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
6065
t.Fatal(err)
6166
}
62-
if len(body.Data) != 1 || body.Data[0].ID != "claude-opus-4" {
67+
if len(body.Data) != 1 || body.Data[0].ID != "claude-opus-4" || body.Data[0].Type != "model" {
6368
t.Fatalf("models body = %+v", body)
6469
}
70+
if body.HasMore {
71+
t.Fatalf("has_more = true")
72+
}
73+
if body.FirstID != "claude-opus-4" || body.LastID != "claude-opus-4" {
74+
t.Fatalf("pagination fields = %+v", body)
75+
}
6576

6677
resp, err = http.Get(ts.URL + "/claude-code/claude%20opus%2F4/claude/v1/models")
6778
if err != nil {
@@ -71,16 +82,21 @@ func TestServerHealthAndModelsList(t *testing.T) {
7182
if resp.StatusCode != http.StatusOK {
7283
t.Fatalf("encoded slash models status = %d", resp.StatusCode)
7384
}
74-
body = struct {
85+
var encodedBody struct {
7586
Data []struct {
76-
ID string `json:"id"`
87+
ID string `json:"id"`
88+
Type string `json:"type"`
89+
DisplayName string `json:"display_name"`
7790
} `json:"data"`
78-
}{}
79-
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
91+
HasMore bool `json:"has_more"`
92+
FirstID string `json:"first_id"`
93+
LastID string `json:"last_id"`
94+
}
95+
if err := json.NewDecoder(resp.Body).Decode(&encodedBody); err != nil {
8096
t.Fatal(err)
8197
}
82-
if len(body.Data) != 1 || body.Data[0].ID != "claude opus/4" {
83-
t.Fatalf("encoded slash models body = %+v", body)
98+
if len(encodedBody.Data) != 1 || encodedBody.Data[0].ID != "claude opus/4" {
99+
t.Fatalf("encoded slash models body = %+v", encodedBody)
84100
}
85101
}
86102

0 commit comments

Comments
 (0)