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:
6 files changed
+160
-22
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in new issue
Block a user