gateway: Claude Desktop's third-party gateway reaches any of magpie's models — it takes only ids that say claude, sonnet, opus, haiku or anthropic, so a model asked for as anthropic/<its id> is served as <its id> when nothing answers to the id as it stands; count_tokens and /v1/models/<id> read it the same, an id with a slash in it is found at /v1/models/<id>, and GET/HEAD /api/hello answers the gateway's probe

This commit is contained in:
yetone committed 2026-09-28 10:17:05 +08:00
1 parent ddf2ef83f5
commit e1f784430b
2 files changed
+72 -4

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
package gateway
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// Claude Desktop asks for anthropic/<id>: served by the model with that id
func TestAnthropicPrefixedModel(t *testing.T) {
f := &fake{reply: sse(
`event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"m1","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}`,
`event: message_stop`+"\n"+`data: {"type":"message_stop"}`)}
setup(t, provider.Anthropic, f)
code, body := post(t, "/v1/messages", `{"model":"anthropic/fake/m1","max_tokens":10,"stream":true,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || f.calls != 1 {
t.Fatalf("anthropic/fake/m1: %d %s, %d calls", code, body, f.calls)
}
var sent struct{ Model string }
if json.Unmarshal(f.got, &sent) != nil || sent.Model != "m1" {
t.Fatalf("sent upstream as %q", f.got)
}
if code, body := post(t, "/v1/messages", `{"model":"anthropic/nobody/m1","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}`); code != 404 {
t.Fatalf("unknown: %d %s", code, body)
}
for _, id := range []string{"fake/m1", "anthropic/fake/m1"} {
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models/"+id, nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"fake/m1"`) {
t.Fatalf("GET /v1/models/%s: %d %s", id, rec.Code, rec.Body)
}
}
rec := httptest.NewRecorder()
New().Handler().ServeHTTP(rec, httptest.NewRequest("HEAD", "/api/hello", nil))
if rec.Code != 200 {
t.Fatalf("HEAD /api/hello: %d", rec.Code)
}
if got := unprefixed("claude-sonnet-4-5"); got != "claude-sonnet-4-5" {
t.Fatalf("unprefixed changed %q", got)
}
}
+28 -4
View File
@@ -229,7 +229,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /{$}", s.info)
mux.HandleFunc("GET /v1/models", s.models)
mux.HandleFunc("GET /models", s.models)
mux.HandleFunc("GET /v1/models/{id}", s.model)
mux.HandleFunc("GET /v1/models/{id...}", s.model)
// Claude Desktop's third-party gateway looks for one here before it
// takes the address
mux.HandleFunc("GET /api/hello", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version})
})
mux.HandleFunc("GET /v1/magpie/quotas", s.quotas)
mux.HandleFunc("POST /v1/chat/completions", s.handle(provider.Chat))
mux.HandleFunc("POST /chat/completions", s.handle(provider.Chat))
@@ -308,7 +313,7 @@ func (s *Server) models(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) model(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
id := unprefixed(r.PathValue("id"))
for _, e := range provider.Catalog() {
if e.ID == id {
writeJSON(w, 200, modelObject(e))
@@ -318,6 +323,25 @@ func (s *Server) model(w http.ResponseWriter, r *http.Request) {
writeError(w, provider.Chat, 404, "unknown model "+id)
}
// unprefixed is the model magpie serves by an id with "anthropic/" put in
// front of it. Claude Desktop, pointed at a third-party gateway, takes only
// models whose id says claude, sonnet, opus, haiku or anthropic, so any of
// magpie's is asked for there as anthropic/<its id>. An id that is magpie's
// as it stands (a provider named anthropic) is left alone.
func unprefixed(id string) string {
rest, ok := strings.CutPrefix(id, "anthropic/")
if !ok || rest == "" {
return id
}
if _, _, ok := provider.Resolve(id); ok {
return id
}
if _, ok := provider.GroupFor(id); ok {
return id
}
return rest
}
// countTokens answers Anthropic's count_tokens: through the provider when
// it implements counting, else a rough estimate. A failed connection or
// limited key yields to the next key; other failures reach the client.
@@ -336,7 +360,7 @@ func (s *Server) countTokens(w http.ResponseWriter, r *http.Request) {
// Count the same masked prompt that generation sends to the vendor.
w, body, unmask := redacted(w, body)
defer unmask()
p, model, ok := provider.Resolve(model)
p, model, ok := provider.Resolve(unprefixed(model))
// Claude Subscription generations run through the Claude Code binary. Its
// OAuth token must not take a direct HTTP side path just for token counting.
if ok && p.Account != nil && (p.Account.Agent == "claude" || p.Account.Agent == "cursor" || p.Account.Agent == "grok" || p.Account.Agent == "devin" || p.Account.Agent == "kiro" || p.Account.Agent == "gemini" || p.Account.Agent == "antigravity") {
@@ -532,7 +556,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
requestBody, requestTruncated := captureRequestBody(body)
capture := &captureResponseWriter{ResponseWriter: w}
w = capture
call := Call{Time: start, From: from, Model: modelOf(body), Agent: agentOf(r),
call := Call{Time: start, From: from, Model: unprefixed(modelOf(body)), Agent: agentOf(r),
RequestBody: requestBody, RequestTruncated: requestTruncated}
usage.Saw(call.Agent)
finishCapture := func() {