Skip to content

Commit 145d684

Browse files
joocursoragent
andcommitted
feat(core): agent ingress tokens and Claude Desktop route mapping
Use clovapi--{agent} bearer tokens to identify callers, map Desktop gateway model aliases to wire models, tag call logs with agentKind, and isolate proxy tests from the shared call-log database. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1b98342 commit 145d684

19 files changed

Lines changed: 555 additions & 71 deletions

core/cmd/switch_cmd.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/clovapi/switcher/internal/apply"
1414
"github.com/clovapi/switcher/internal/cliswitch"
1515
"github.com/clovapi/switcher/internal/desktop"
16+
"github.com/clovapi/switcher/internal/ingresstoken"
1617
"github.com/clovapi/switcher/internal/profile"
1718
"github.com/clovapi/switcher/internal/syslog"
1819
)
@@ -314,7 +315,7 @@ func applyDirectToCLI(kind agentkind.Kind, baseURL, apiKey, model, styleStr stri
314315
}
315316
key := strings.TrimSpace(apiKey)
316317
if key == "" {
317-
key = "clovapi-local"
318+
key = ingresstoken.ForAgent(kind)
318319
}
319320
p := profile.Profile{
320321
Name: "__direct__",

core/internal/apply/target_claude_desktop.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (claudeDesktopTarget) Apply(p profile.Profile) error {
6262
return err
6363
}
6464
return withClaudeDesktopRollback(paths, func() error {
65-
profileJSON := claudeDesktopGatewayProfile(ensureAnthropicWireBaseURL(p.BaseURL), p.APIKey, p.Model)
65+
profileJSON := claudeDesktopGatewayProfile(ensureAnthropicWireBaseURL(p.BaseURL), p.APIKey, profile.ClaudeDesktopRouteName(p.Model, 0))
6666
if err := writeClaudeDesktopDeploymentMode(paths.normalConfigPath, "3p"); err != nil {
6767
return err
6868
}

core/internal/apply/target_claude_desktop_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/clovapi/switcher/internal/agentkind"
1010
"github.com/clovapi/switcher/internal/apistyle"
11+
"github.com/clovapi/switcher/internal/ingresstoken"
1112
"github.com/clovapi/switcher/internal/profile"
1213
)
1314

@@ -29,7 +30,7 @@ func TestClaudeDesktopApplyWritesThreePProfile(t *testing.T) {
2930
CLI: agentkind.ClaudeDesktop,
3031
APIStyle: apistyle.Claude,
3132
BaseURL: "http://127.0.0.1:27483/custom-api/v1",
32-
APIKey: "clovapi-local",
33+
APIKey: ingresstoken.ForAgent(agentkind.ClaudeDesktop),
3334
Model: "gpt-5.5/pro",
3435
}
3536
if err := (claudeDesktopTarget{}).Apply(p); err != nil {
@@ -51,16 +52,16 @@ func TestClaudeDesktopApplyWritesThreePProfile(t *testing.T) {
5152
if prof["inferenceGatewayBaseUrl"] != wantBase {
5253
t.Fatalf("base url = %q want %q", prof["inferenceGatewayBaseUrl"], wantBase)
5354
}
54-
if prof["inferenceGatewayApiKey"] != "clovapi-local" || prof["inferenceGatewayAuthScheme"] != "bearer" || prof["inferenceProvider"] != "gateway" {
55+
if prof["inferenceGatewayApiKey"] != "clovapi--claude-desktop" || prof["inferenceGatewayAuthScheme"] != "bearer" || prof["inferenceProvider"] != "gateway" {
5556
t.Fatalf("profile auth/provider fields = %#v", prof)
5657
}
5758
models, ok := prof["inferenceModels"].([]any)
5859
if !ok || len(models) != 1 {
5960
t.Fatalf("inferenceModels = %#v", prof["inferenceModels"])
6061
}
6162
model, _ := models[0].(map[string]any)
62-
if model["name"] != "gpt-5.5/pro" {
63-
t.Fatalf("model route = %#v", model)
63+
if model["name"] != "claude-sonnet-4-6" {
64+
t.Fatalf("model route = %#v want claude-sonnet-4-6 alias for gpt-5.5/pro", model)
6465
}
6566
if meta["appliedId"] != claudeDesktopProfileID {
6667
t.Fatalf("meta = %#v", meta)
@@ -74,7 +75,7 @@ func TestClaudeDesktopResetRestoresOfficialMode(t *testing.T) {
7475
CLI: agentkind.ClaudeDesktop,
7576
APIStyle: apistyle.Claude,
7677
BaseURL: "http://127.0.0.1:27483/custom-api/v1",
77-
APIKey: "clovapi-local",
78+
APIKey: ingresstoken.ForAgent(agentkind.ClaudeDesktop),
7879
Model: "gpt-5.5",
7980
}
8081
if err := (claudeDesktopTarget{}).Apply(p); err != nil {

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.109"
7+
Version = "dev0.1.113"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/desktop/switch.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/clovapi/switcher/internal/agentkind"
88
"github.com/clovapi/switcher/internal/apply"
9+
"github.com/clovapi/switcher/internal/ingresstoken"
910
"github.com/clovapi/switcher/internal/profile"
1011
"github.com/clovapi/switcher/internal/provider"
1112
)
@@ -45,7 +46,7 @@ func ApplyProviderModel(kind agentkind.Kind, providerID, modelID string) error {
4546
Kind: hit.Vendor.Kind,
4647
SubscriptionProviderID: hit.Vendor.SubscriptionProviderID,
4748
BaseURL: baseURL,
48-
APIKey: "clovapi-local",
49+
APIKey: ingresstoken.ForAgent(kind),
4950
Model: modelWire,
5051
Models: hit.Vendor.Models,
5152
APIStyle: ingressStyle,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package ingresstoken
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
7+
"github.com/clovapi/switcher/internal/agentkind"
8+
)
9+
10+
const (
11+
Prefix = "clovapi--"
12+
Legacy = "clovapi-local"
13+
)
14+
15+
// Auth carries a parsed local proxy ingress bearer token.
16+
type Auth struct {
17+
Token string
18+
Agent agentkind.Kind
19+
}
20+
21+
// ForAgent returns the bearer token written into agent configs for local proxy ingress.
22+
func ForAgent(kind agentkind.Kind) string {
23+
if slug := agentSlug(kind); slug != "" {
24+
return Prefix + slug
25+
}
26+
return Legacy
27+
}
28+
29+
func agentSlug(kind agentkind.Kind) string {
30+
switch kind {
31+
case agentkind.ClaudeCode:
32+
return "claude-code"
33+
case agentkind.ClaudeDesktop:
34+
return "claude-desktop"
35+
case agentkind.Codex:
36+
return "codex"
37+
case agentkind.OpenCode:
38+
return "opencode"
39+
case agentkind.OpenClaw:
40+
return "openclaw"
41+
case agentkind.Hermes:
42+
return "hermes"
43+
case agentkind.KimiCode:
44+
return "kimi-code"
45+
default:
46+
return ""
47+
}
48+
}
49+
50+
// FromHTTPRequest parses Authorization: Bearer … from an inbound proxy request.
51+
func FromHTTPRequest(r *http.Request) Auth {
52+
if r == nil {
53+
return Auth{}
54+
}
55+
return ParseBearer(r.Header.Get("Authorization"))
56+
}
57+
58+
// ParseBearer extracts a clovapi ingress token from an Authorization header value.
59+
func ParseBearer(authHeader string) Auth {
60+
auth := strings.TrimSpace(authHeader)
61+
if auth == "" {
62+
return Auth{}
63+
}
64+
const prefix = "Bearer "
65+
if !strings.HasPrefix(auth, prefix) && !strings.HasPrefix(auth, "bearer ") {
66+
return Auth{}
67+
}
68+
token := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(auth, "Bearer "), "bearer "))
69+
return ParseToken(token)
70+
}
71+
72+
// ParseToken maps clovapi ingress tokens to agent kinds.
73+
func ParseToken(token string) Auth {
74+
token = strings.TrimSpace(token)
75+
if token == "" {
76+
return Auth{}
77+
}
78+
if token == Legacy {
79+
return Auth{Token: token}
80+
}
81+
if !strings.HasPrefix(token, Prefix) {
82+
return Auth{}
83+
}
84+
slug := strings.TrimSpace(strings.TrimPrefix(token, Prefix))
85+
if slug == "" {
86+
return Auth{Token: token}
87+
}
88+
kind, err := agentkind.Parse(slug)
89+
if err != nil {
90+
return Auth{Token: token}
91+
}
92+
return Auth{Token: token, Agent: kind}
93+
}
94+
95+
// IsClovapiToken reports whether token is a recognized local proxy ingress bearer value.
96+
func IsClovapiToken(token string) bool {
97+
token = strings.TrimSpace(token)
98+
return token == Legacy || strings.HasPrefix(token, Prefix)
99+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package ingresstoken
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/clovapi/switcher/internal/agentkind"
8+
)
9+
10+
func TestForAgentUsesAgentSlug(t *testing.T) {
11+
if got := ForAgent(agentkind.ClaudeDesktop); got != "clovapi--claude-desktop" {
12+
t.Fatalf("token = %q", got)
13+
}
14+
if got := ForAgent(agentkind.Codex); got != "clovapi--codex" {
15+
t.Fatalf("token = %q", got)
16+
}
17+
}
18+
19+
func TestParseTokenRecognizesAgentAndLegacy(t *testing.T) {
20+
auth := ParseBearer("Bearer clovapi--claude-desktop")
21+
if auth.Token != "clovapi--claude-desktop" || auth.Agent != agentkind.ClaudeDesktop {
22+
t.Fatalf("auth = %+v", auth)
23+
}
24+
auth = ParseToken(Legacy)
25+
if auth.Token != Legacy || auth.Agent != "" {
26+
t.Fatalf("legacy = %+v", auth)
27+
}
28+
}
29+
30+
func TestFromHTTPRequest(t *testing.T) {
31+
req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:27483/codex/v1/models", nil)
32+
req.Header.Set("Authorization", "Bearer clovapi--hermes")
33+
auth := FromHTTPRequest(req)
34+
if auth.Agent != agentkind.Hermes {
35+
t.Fatalf("auth = %+v", auth)
36+
}
37+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package profile
2+
3+
import (
4+
"strings"
5+
6+
"github.com/clovapi/switcher/internal/agentkind"
7+
)
8+
9+
var claudeDesktopFallbackRoutes = []string{
10+
"claude-sonnet-4-6",
11+
"claude-sonnet-4-5",
12+
"claude-opus-4-6",
13+
"claude-haiku-4-5",
14+
}
15+
16+
var claudeDesktopRouteKeywords = []string{"claude", "sonnet", "opus", "haiku", "anthropic"}
17+
18+
// IsAnthropicGatewayRouteName reports whether name satisfies Claude Desktop gateway validation.
19+
func IsAnthropicGatewayRouteName(name string) bool {
20+
lower := strings.ToLower(strings.TrimSpace(name))
21+
if lower == "" {
22+
return false
23+
}
24+
for _, kw := range claudeDesktopRouteKeywords {
25+
if strings.Contains(lower, kw) {
26+
return true
27+
}
28+
}
29+
return false
30+
}
31+
32+
// ClaudeDesktopRouteName returns the model route Claude Desktop accepts for inferenceModels.
33+
// Non-Anthropic wire names are mapped to stable Claude-style aliases; the proxy resolves them back.
34+
func ClaudeDesktopRouteName(wireModel string, index int) string {
35+
wireModel = strings.TrimSpace(wireModel)
36+
if IsAnthropicGatewayRouteName(wireModel) {
37+
return wireModel
38+
}
39+
if index < 0 {
40+
index = 0
41+
}
42+
return claudeDesktopFallbackRoutes[index%len(claudeDesktopFallbackRoutes)]
43+
}
44+
45+
// ResolveModelIDFromClaudeDesktopRoute maps a Desktop gateway route back to a persisted model id.
46+
func ResolveModelIDFromClaudeDesktopRoute(store *Store, providerID, routeName string) (string, bool) {
47+
routeName = strings.TrimSpace(routeName)
48+
if store == nil || routeName == "" {
49+
return "", false
50+
}
51+
providerID = strings.TrimSpace(providerID)
52+
for _, prof := range store.List {
53+
if ProviderIDFromStoreProfile(prof) != providerID {
54+
continue
55+
}
56+
for i, raw := range prof.Models {
57+
m := NormalizeModelEntry(raw, i)
58+
wire := strings.TrimSpace(firstNonEmpty(m.Model, m.ID))
59+
route := ClaudeDesktopRouteName(wire, i)
60+
if routeName == route || routeName == m.ID || routeName == wire {
61+
id := strings.TrimSpace(m.ID)
62+
if id == "" {
63+
id = wire
64+
}
65+
return id, id != ""
66+
}
67+
}
68+
break
69+
}
70+
return "", false
71+
}
72+
73+
// ClaudeDesktopRouteIDs lists gateway route ids for models under providerID.
74+
func ClaudeDesktopRouteIDs(store *Store, providerID string) []string {
75+
if store == nil {
76+
return nil
77+
}
78+
providerID = strings.TrimSpace(providerID)
79+
seen := map[string]bool{}
80+
var routes []string
81+
add := func(route string) {
82+
route = strings.TrimSpace(route)
83+
if route == "" || seen[route] {
84+
return
85+
}
86+
seen[route] = true
87+
routes = append(routes, route)
88+
}
89+
for _, prof := range store.List {
90+
if ProviderIDFromStoreProfile(prof) != providerID {
91+
continue
92+
}
93+
if len(prof.Models) == 0 {
94+
add(ClaudeDesktopRouteName(prof.Model, 0))
95+
break
96+
}
97+
for i, raw := range prof.Models {
98+
m := NormalizeModelEntry(raw, i)
99+
wire := strings.TrimSpace(firstNonEmpty(m.Model, m.ID))
100+
add(ClaudeDesktopRouteName(wire, i))
101+
}
102+
break
103+
}
104+
return routes
105+
}
106+
107+
// ResolveIngressModelID maps Claude Desktop gateway routes back to persisted model ids.
108+
func ResolveIngressModelID(store *Store, providerID, modelID string) string {
109+
modelID = strings.TrimSpace(modelID)
110+
if modelID == "" || store == nil {
111+
return modelID
112+
}
113+
if wire, ok := ResolveModelIDFromClaudeDesktopRoute(store, providerID, modelID); ok {
114+
return wire
115+
}
116+
if !IsAnthropicGatewayRouteName(modelID) {
117+
return modelID
118+
}
119+
sel, ok := store.Active[string(agentkind.ClaudeDesktop)]
120+
if !ok {
121+
return modelID
122+
}
123+
if strings.TrimSpace(sel.ProviderID) != strings.TrimSpace(providerID) {
124+
return modelID
125+
}
126+
if wire := strings.TrimSpace(sel.ModelID); wire != "" {
127+
return wire
128+
}
129+
return modelID
130+
}

0 commit comments

Comments
 (0)