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:
2 files changed
+72
-4
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user