From 6f19f60b083abc88dd50a4073bdc6e81dfd23d6d Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 25 Sep 2026 05:22:52 +0800 Subject: [PATCH] =?UTF-8?q?provider(copilot)=20+=20gateway:=20each=20reque?= =?UTF-8?q?st=20goes=20straight=20to=20an=20API=20its=20model=20is=20serve?= =?UTF-8?q?d=20on,=20as=20the=20provider's=20model=20list=20says,=20rather?= =?UTF-8?q?=20than=20being=20learned=20from=20a=20refusal=20=E2=80=94=20Co?= =?UTF-8?q?pilot's=20list=20gives=20every=20model=20its=20endpoints=20(GPT?= =?UTF-8?q?-6=20and=205.5=20on=20/responses=20alone,=20Claude=20on=20/v1/m?= =?UTF-8?q?essages=20and=20/chat/completions,=20Gemini=20and=20Kimi=20on?= =?UTF-8?q?=20/chat/completions),=20they=20are=20kept=20with=20the=20cache?= =?UTF-8?q?d=20list,=20and=20the=20gateway=20picks=20among=20them,=20relay?= =?UTF-8?q?ing=20the=20agent's=20own=20API=20when=20it=20is=20one=20and=20?= =?UTF-8?q?translating=20otherwise;=20a=20refusal=20is=20still=20caught=20?= =?UTF-8?q?for=20a=20provider=20that=20doesn't=20say.=20Copilot's=20Anthro?= =?UTF-8?q?pic=20endpoint=20is=20used=20too,=20so=20Claude=20Code=20reache?= =?UTF-8?q?s=20Copilot's=20Claude=20models=20without=20translation;=20a=20?= =?UTF-8?q?turn=20that=20only=20returns=20tool=20results=20is=20counted=20?= =?UTF-8?q?as=20the=20agent's=20(X-Initiator)=20in=20Anthropic's=20format?= =?UTF-8?q?=20as=20in=20the=20others,=20and=20images=20in=20Anthropic=20an?= =?UTF-8?q?d=20Responses=20requests=20are=20flagged=20as=20Copilot=20asks.?= =?UTF-8?q?=20The=20provider=20test=20asks=20each=20endpoint=20with=20a=20?= =?UTF-8?q?model=20served=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/catalog/catalog.go | 3 ++ internal/gateway/gateway.go | 17 ++++--- internal/gateway/gateway_test.go | 59 +++++++++++++++++++++++ internal/provider/account.go | 78 ++++++++++++++++++++++++++----- internal/provider/account_test.go | 17 ++++++- internal/provider/test.go | 8 +++- 6 files changed, 160 insertions(+), 22 deletions(-) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index ddf5c918..732c168d 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -29,6 +29,9 @@ type Model struct { // Keys, for a vendor whose keys each see models of their own, are the // keys (by fingerprint) whose list has this one; empty is every key. Keys []string `json:",omitempty"` + // APIs, when the vendor says, are the APIs the model is served on + // ("chat", "responses", "anthropic"); empty is not known. + APIs []string `json:",omitempty"` } // Price is what a model costs, in USD per million tokens. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 67ce346a..afbec564 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "os" + "slices" "sort" "strings" "sync" @@ -237,7 +238,7 @@ func (s *Server) countTokens(w http.ResponseWriter, r *http.Request) { } p = q } - if ok && p.Anthropic != "" { + if ok && p.Anthropic != "" && slices.Contains(s.usable(p, model), provider.Anthropic) { res, err := s.forward(r.Context(), p, provider.Anthropic, "/v1/messages/count_tokens", rewriteModel(body, model), r.Header) if err == nil { defer res.Body.Close() @@ -473,7 +474,7 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P } // a backend that only streams gets a non-streaming request translated // (the provider is always streamed on that path) rather than relayed - relay := p.Base(from) != "" && s.fits(p.ID, model, from) && (p.Account == nil || !p.Account.Stream || streamOf(body)) + relay := slices.Contains(s.usable(p, model), from) && (p.Account == nil || !p.Account.Stream || streamOf(body)) if relay { call.To = from if status, msg, done := s.passthrough(w, r, p, from, model, body, &call.Usage); done { @@ -616,14 +617,16 @@ func (s *Server) markUnfit(providerID, model string, proto provider.Protocol) { s.mu.Unlock() } -// usable lists the protocols p speaks that model hasn't been turned away -// from, preferred first: Chat Completions, which every OpenAI-compatible -// vendor serves alike, except for OpenAI's own models where their makers -// serve them, whose newest are Responses-first (and some Responses-only). +// usable lists the protocols p speaks that model is served on, as far as +// the provider says and hasn't turned it away, preferred first: Chat +// Completions, which every OpenAI-compatible vendor serves alike, except +// for OpenAI's own models where their makers serve them, whose newest are +// Responses-first (and some Responses-only). func (s *Server) usable(p provider.Provider, model string) []provider.Protocol { + apis := p.APIs(model) var out []provider.Protocol for _, proto := range p.Speaks() { - if s.fits(p.ID, model, proto) { + if s.fits(p.ID, model, proto) && (apis == nil || slices.Contains(apis, proto)) { out = append(out, proto) } } diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 4cf15062..0b35b894 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/provider" ) @@ -768,3 +769,61 @@ func TestUsableOrder(t *testing.T) { t.Errorf("after /responses turned it away: %v", got) } } + +// A provider whose model list says which APIs each model is served on +// (Copilot's does) has each request sent straight to one of them: nothing +// is tried on an API the model isn't served on. +func TestModelListSaysWhichAPI(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + paths := map[string]int{} + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.ReadAll(r.Body) + paths[r.URL.Path]++ + w.Header().Set("Content-Type", "text/event-stream") + switch r.URL.Path { + case "/v1/responses": + io.WriteString(w, sse( + `event: response.created`+"\n"+`data: {"type":"response.created","response":{"id":"r1","model":"gpt-x","usage":{"input_tokens":0,"output_tokens":0}}}`, + `event: response.output_text.delta`+"\n"+`data: {"type":"response.output_text.delta","delta":"ok"}`, + `event: response.completed`+"\n"+`data: {"type":"response.completed","response":{"id":"r1","model":"gpt-x","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}`)) + case "/v1/chat/completions": + io.WriteString(w, sse( + `data: {"id":"c1","model":"claude-x","choices":[{"delta":{"role":"assistant","content":"ok"}}]}`, + `data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}]}`, + `data: [DONE]`)) + default: + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":{"message":"unexpected"}}`) + } + })) + t.Cleanup(up.Close) + if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Models: []string{"gpt-x", "claude-x"}, + Chat: up.URL + "/v1", Responses: up.URL + "/v1", Anthropic: up.URL}); err != nil { + t.Fatal(err) + } + if err := catalog.SaveLive("fake", up.URL+"/v1", []catalog.Model{ + {ID: "gpt-x", APIs: []string{"responses"}}, + {ID: "claude-x", APIs: []string{"chat"}}, + }); err != nil { + t.Fatal(err) + } + handler := New().Handler() + for _, tc := range []struct{ path, body, want string }{ + // an Anthropic client, and the model on Responses alone + {"/v1/messages", `{"model":"gpt-x","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}`, "/v1/responses"}, + // a Chat client, likewise: not relayed + {"/v1/chat/completions", `{"model":"gpt-x","messages":[{"role":"user","content":"hi"}]}`, "/v1/responses"}, + // a Responses client, and the model on Chat alone + {"/v1/responses", `{"model":"claude-x","input":"hi","stream":true}`, "/v1/chat/completions"}, + // an Anthropic client the provider speaks to, but not for this model + {"/v1/messages", `{"model":"claude-x","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}`, "/v1/chat/completions"}, + } { + clear(paths) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest("POST", tc.path, strings.NewReader(tc.body))) + if rec.Code != 200 || len(paths) != 1 || paths[tc.want] != 1 { + t.Errorf("%s %s: status %d, upstream %v, want only %s: %s", tc.path, tc.body, rec.Code, paths, tc.want, rec.Body.String()) + } + } +} diff --git a/internal/provider/account.go b/internal/provider/account.go index 8f8bbe01..d1584020 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -55,6 +55,24 @@ type Account struct { fetch func(ctx context.Context) ([]catalog.Model, error) } +// APIs lists the APIs model is served on, as the provider's last model +// list said: Copilot serves its GPT models on Responses alone and its +// Claude models on Chat and Anthropic's. nil is not known, and every API +// the provider speaks may be tried. +func (p Provider) APIs(model string) []Protocol { + ms, _, _ := catalog.Live(p.ID) + for _, m := range ms { + if m.ID == model && len(m.APIs) > 0 { + out := make([]Protocol, len(m.APIs)) + for i, a := range m.APIs { + out[i] = Protocol(a) + } + return out + } + } + return nil +} + // Sign authenticates a request to the provider, refreshing what needs it. // Plain providers get their key; accounts get the agent's tokens. func (p Provider) Sign(ctx context.Context, req *http.Request, proto Protocol, body []byte) error { @@ -877,7 +895,7 @@ func copilotProvider(app copilotApp, plan string) Provider { if lastRole(body) == "user" { req.Header.Set("X-Initiator", "user") } - if bytes.Contains(body, []byte(`"image_url"`)) { + if bytes.Contains(body, []byte(`"image_url"`)) || bytes.Contains(body, []byte(`"input_image"`)) || bytes.Contains(body, []byte(`"type":"image"`)) { req.Header.Set("Copilot-Vision-Request", "true") } return nil @@ -889,17 +907,20 @@ func copilotProvider(app copilotApp, plan string) Provider { } return ms, catalog.SaveLive("copilot", copilotBase, ms) } - // the newest GPT models are served only on /responses and Claude's only - // on /chat/completions; the gateway learns which from Copilot's answer - return Provider{ID: "copilot", Name: "Copilot", Icon: "githubcopilot", Chat: copilotBase, Responses: copilotBase, Website: "https://github.com/features/copilot", Account: acct} + // each model is served on some of these: the newest GPT models on + // /responses alone, Claude's on /v1/messages and /chat/completions (its + // model list says; see Provider.APIs) + return Provider{ID: "copilot", Name: "Copilot", Icon: "githubcopilot", Chat: copilotBase, Responses: copilotBase, Anthropic: copilotBase, Website: "https://github.com/features/copilot", Account: acct} } -// lastRole is the role of the last message in a chat or Responses request; -// a Responses tool result has none. +// lastRole is the role of the last message in a chat, Anthropic or +// Responses request. Tool results are "tool" in Anthropic's, where they +// ride in a user message, and have none in Responses. func lastRole(body []byte) string { var v struct { Messages []struct { - Role string `json:"role"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` } `json:"messages"` Input json.RawMessage `json:"input"` } @@ -907,7 +928,22 @@ func lastRole(body []byte) string { return "" } if len(v.Messages) > 0 { - return v.Messages[len(v.Messages)-1].Role + last := v.Messages[len(v.Messages)-1] + var blocks []struct { + Type string `json:"type"` + } + if last.Role == "user" && json.Unmarshal(last.Content, &blocks) == nil && len(blocks) > 0 { + results := 0 + for _, b := range blocks { + if b.Type == "tool_result" { + results++ + } + } + if results == len(blocks) { + return "tool" + } + } + return last.Role } var text string if json.Unmarshal(v.Input, &text) == nil { @@ -951,6 +987,23 @@ func copilotToken(ctx context.Context, github string) (copilotSession, error) { return s, nil } +// copilotAPIs names the APIs of Copilot's supported_endpoints; the +// websocket one is left out, as is anything magpie doesn't speak. +func copilotAPIs(endpoints []string) []string { + var out []string + for _, e := range endpoints { + switch e { + case "/chat/completions": + out = append(out, string(Chat)) + case "/responses": + out = append(out, string(Responses)) + case "/v1/messages": + out = append(out, string(Anthropic)) + } + } + return out +} + // internal is a Copilot model id nobody picks by hand. var copilotInternal = regexp.MustCompile(`^(copilot-search|exec-agent|trajectory)|-(secondary|tertiary|4th|free-auto)$`) @@ -980,9 +1033,10 @@ func copilotModels(ctx context.Context, app copilotApp) ([]catalog.Model, error) b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) var v struct { Data []struct { - ID string `json:"id"` - Name string `json:"name"` - Picker bool `json:"model_picker_enabled"` + ID string `json:"id"` + Name string `json:"name"` + Picker bool `json:"model_picker_enabled"` + Endpoints []string `json:"supported_endpoints"` Capabilities struct { Type string `json:"type"` Supports struct { @@ -1006,7 +1060,7 @@ func copilotModels(ctx context.Context, app copilotApp) ([]catalog.Model, error) if !m.Picker && (m.Policy == nil || m.Policy.State != "enabled") { continue } - out = append(out, catalog.Model{ID: m.ID, Name: m.Name, Efforts: m.Capabilities.Supports.Efforts}) + out = append(out, catalog.Model{ID: m.ID, Name: m.Name, Efforts: m.Capabilities.Supports.Efforts, APIs: copilotAPIs(m.Endpoints)}) } if len(out) == 0 { return nil, errors.New("Copilot lists no chat model for this account") diff --git a/internal/provider/account_test.go b/internal/provider/account_test.go index 7b23f779..37c66e39 100644 --- a/internal/provider/account_test.go +++ b/internal/provider/account_test.go @@ -501,8 +501,11 @@ func TestLastRole(t *testing.T) { `{"messages":[{"role":"system"},{"role":"user"}]}`: "user", `{"messages":[{"role":"user"},{"role":"tool"}]}`: "tool", `{"input":"hi"}`: "user", - `{"input":[{"role":"user","content":"hi"}]}`: "user", - `{"input":[{"role":"user"},{"type":"function_call_output","call_id":"c","output":"x"}]}`: "", + `{"input":[{"role":"user","content":"hi"}]}`: "user", + `{"input":[{"role":"user"},{"type":"function_call_output","call_id":"c","output":"x"}]}`: "", + `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":"x"}]}]}`: "tool", + `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t"},{"type":"text","text":"and"}]}]}`: "user", + `{"messages":[{"role":"user","content":"hi"}]}`: "user", `not json`: "", } { if got := lastRole([]byte(body)); got != want { @@ -510,3 +513,13 @@ func TestLastRole(t *testing.T) { } } } + +func TestCopilotAPIs(t *testing.T) { + got := copilotAPIs([]string{"/responses", "ws:/responses", "/v1/messages", "/chat/completions", "/embeddings"}) + if strings.Join(got, " ") != "responses anthropic chat" { + t.Errorf("copilotAPIs = %v", got) + } + if copilotAPIs(nil) != nil { + t.Error("no endpoints should be not known") + } +} diff --git a/internal/provider/test.go b/internal/provider/test.go index c0438cc4..6b270352 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -81,7 +82,12 @@ func (p Provider) testModel(q Provider, proto Protocol) string { } pools = append(pools, p.Available()) for _, want := range []func(string) bool{ - func(id string) bool { return proto != Anthropic || isClaude(id) }, + func(id string) bool { + if apis := p.APIs(id); apis != nil { + return slices.Contains(apis, proto) + } + return proto != Anthropic || isClaude(id) + }, func(string) bool { return true }, } { for _, pool := range pools {