provider(copilot) + gateway: each request goes straight to an API its model is served on, as the provider's model list says, rather than being learned from a refusal — Copilot's list gives every model its endpoints (GPT-6 and 5.5 on /responses alone, Claude on /v1/messages and /chat/completions, Gemini and Kimi on /chat/completions), they are kept with the cached list, and the gateway picks among them, relaying the agent's own API when it is one and translating otherwise; a refusal is still caught for a provider that doesn't say. Copilot's Anthropic endpoint is used too, so Claude Code reaches Copilot's Claude models without translation; a turn that only returns tool results is counted as the agent's (X-Initiator) in Anthropic's format as in the others, and images in Anthropic and Responses requests are flagged as Copilot asks. The provider test asks each endpoint with a model served there

This commit is contained in:
yetone committed 2026-09-25 05:22:52 +08:00
1 parent a986ac3855
commit 6f19f60b08
6 files changed
+160 -22

No files matched your search

+3
View File
@@ -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.
+10 -7
View File
@@ -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)
}
}
+59
View File
@@ -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())
}
}
}
+66 -12
View File
@@ -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")
+15 -2
View File
@@ -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")
}
}
+7 -1
View File
@@ -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 {