gateway, copilot: Auto's pick is asked as Copilot Chat asks it, and a refused pick is made again on its own API (infinitr0us, #256) — on a Student plan copilot/auto's pick from POST /models/session (gpt-5.4-mini, gpt-5.3-codex) was refused 400 "The requested model is not supported.", and the retry with the next pick, gpt-4.1, went to the same /responses and got "model gpt-4.1 is not supported via Responses API." (1) Copilot Chat 0.69 (now in microsoft/vscode extensions/copilot, automodeService.ts / AutoV2Fetcher) no longer asks /models/session: it POSTs {capiBase}/auto with the prompt at X-GitHub-Api-Version 2026-08-01 and gets session_token, expires_at and selected_model with its supported_endpoints; /models/session answers a fixed, stale list (asked with the owner's account it still offers gpt-5.3-codex/gpt-5.4-mini while /auto picks gpt-6-luna). magpie now asks /auto first, with the last user message as the prompt, takes the pick's endpoints for its API, keeps the session at most an hour, and falls back to /models/session and then the hand-picked model as before. (2) After a refusal the retry with Auto's next pick breaks out of forward's same-API loop when that model isn't served on it, and the candidates' loop makes the request again (at most twice) on the new model's own API; a held refusal is passed on only when no retry is left. TestCopilotAutoRepickTakesItsAPI (gpt-5.3-codex refused on /responses, gpt-4.1 refused there and served on chat) fails on the old code with infinitr0us's exact error; TestCopilotAutoAsksAuto (version, prompt, token, /models/session not asked) fails with a 502. With the owner's real Copilot account in a sandbox gateway, copilot/auto on /v1/chat/completions came back 200 from gpt-6-luna, picked by /auto and sent on /responses. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 04:07:20 +08:00
1 parent 98b3ed5aad
commit 719a6820df
7 files changed
+307 -2

No files matched your search

+2
View File
@@ -39,6 +39,8 @@ func TestCopilotAutoThroughGateway(t *testing.T) {
case "/models":
io.WriteString(w, `{"data":[
{"id":"gpt-5-mini","name":"GPT-5 mini","model_picker_enabled":true,"policy":{"state":"disabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}}]}`)
case "/auto":
w.WriteHeader(404) // Auto v2 not offered: /models/session
case "/models/session":
sessions++
io.WriteString(w, `{"session_token":"auto-tok","selected_model":"gpt-5-mini","available_models":["gpt-5-mini"],"expires_at":`+
+2
View File
@@ -39,6 +39,8 @@ func TestCopilotAutoRefusedThroughGateway(t *testing.T) {
io.WriteString(w, `{"data":[
{"id":"claude-haiku-4.5","name":"Claude Haiku 4.5","model_picker_enabled":true,"policy":{"state":"enabled"},"supported_endpoints":["/chat/completions","/v1/messages"],"capabilities":{"type":"chat"}},
{"id":"gpt-4.1","name":"GPT-4.1","model_picker_enabled":true,"is_chat_default":true,"is_chat_fallback":true,"policy":{"state":"enabled"},"supported_endpoints":["/chat/completions"],"capabilities":{"type":"chat"}}]}`)
case "/auto":
w.WriteHeader(404) // Auto v2 not offered: /models/session
case "/models/session":
io.WriteString(w, `{"session_token":"auto-tok","selected_model":"claude-haiku-4.5","available_models":["claude-haiku-4.5","gpt-4.1"],"expires_at":`+
mustString(time.Now().Add(time.Hour).Unix())+`}`)
+141
View File
@@ -0,0 +1,141 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// copilotFake signs a Copilot account in (its GitHub token gh) to a fake
// Copilot API served by api.
func copilotFake(t *testing.T, gh string, api http.HandlerFunc) {
t.Helper()
fresh(t)
cfg := os.Getenv("XDG_CONFIG_HOME")
os.MkdirAll(filepath.Join(cfg, "github-copilot"), 0o755)
os.WriteFile(filepath.Join(cfg, "github-copilot", "apps.json"), mustJSON(map[string]any{
"github.com:Iv1.x": map[string]any{"user": "student", "oauth_token": gh},
}), 0o600)
up := httptest.NewServer(api)
t.Cleanup(up.Close)
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/token") {
json.NewEncoder(w).Encode(map[string]any{"token": "sess", "expires_at": time.Now().Add(time.Hour).Unix(), "endpoints": map[string]string{"api": up.URL}})
return
}
io.WriteString(w, `{"copilot_plan":"individual","access_type_sku":"free_educational_quota"}`)
}))
t.Cleanup(hub.Close)
oldTok, oldUser := provider.CopilotTokenURL, provider.CopilotUserURL
provider.CopilotTokenURL, provider.CopilotUserURL = hub.URL+"/token", hub.URL+"/user"
t.Cleanup(func() { provider.CopilotTokenURL, provider.CopilotUserURL = oldTok, oldUser })
}
const copilotStudentModels = `{"data":[
{"id":"gpt-5.3-codex","name":"GPT-5.3-Codex","model_picker_enabled":true,"policy":{"state":"enabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}},
{"id":"gpt-4.1","name":"GPT-4.1","model_picker_enabled":true,"is_chat_default":true,"policy":{"state":"enabled"},"capabilities":{"type":"chat"}}]}`
const copilotNotSupported = `{"error":{"message":"The requested model is not supported.","code":"model_not_supported","param":"model","type":"invalid_request_error"}}`
// #256 (infinitr0us, a real Student account): Auto picked gpt-5.3-codex,
// served on /responses alone, which Copilot refused the account; Auto then
// picked gpt-4.1, served on chat completions alone, and the request was
// sent again on /responses, to be refused "model gpt-4.1 is not supported
// via Responses API". The new pick is asked for on its own API.
func TestCopilotAutoRepickTakesItsAPI(t *testing.T) {
var mu sync.Mutex
var asked []string // "<path> <model>"
copilotFake(t, "gho_student256repick", func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/models":
io.WriteString(w, copilotStudentModels)
case "/auto":
w.WriteHeader(404) // Auto v2 not offered: /models/session
case "/models/session":
io.WriteString(w, `{"session_token":"auto-tok","selected_model":"gpt-5.3-codex","available_models":["gpt-5.3-codex","gpt-4.1"],"expires_at":`+
mustString(time.Now().Add(time.Hour).Unix())+`}`)
default:
m := modelOf(b)
asked = append(asked, r.URL.Path+" "+m)
switch {
case m == "gpt-5.3-codex":
w.WriteHeader(400)
io.WriteString(w, copilotNotSupported)
case r.URL.Path != "/chat/completions":
w.WriteHeader(400)
io.WriteString(w, `{"error":{"message":"model `+m+` is not supported via Responses API.","code":"unsupported_api_for_model"}}`)
default:
io.WriteString(w, `{"id":"c1","model":"gpt-4.1","choices":[{"index":0,"message":{"role":"assistant","content":"hello from gpt-4.1"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}`)
}
}
})
s := New()
code, body := postAs(t, s, "", `{"model":"copilot/auto","messages":[{"role":"user","content":"Reply with the single word: ok"}]}`)
if code != 200 || !strings.Contains(body, "hello from gpt-4.1") {
t.Fatalf("copilot/auto: %d %s", code, body)
}
mu.Lock()
defer mu.Unlock()
if strings.Join(asked, "|") != "/responses gpt-5.3-codex|/chat/completions gpt-4.1" {
t.Fatalf("Copilot was asked %q", asked)
}
}
// Copilot Chat 0.69 asks POST /auto, at API version 2026-08-01, with the
// turn's prompt: the model it picks, on the APIs it names, signed with its
// session; /models/session isn't asked.
func TestCopilotAutoAsksAuto(t *testing.T) {
var mu sync.Mutex
var asked []string
var auto struct {
version, prompt string
}
copilotFake(t, "gho_student256v2", func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/models":
io.WriteString(w, copilotStudentModels)
case "/auto":
var q struct {
Prompt string `json:"prompt"`
}
json.Unmarshal(b, &q)
auto.version, auto.prompt = r.Header.Get("X-GitHub-Api-Version"), q.Prompt
io.WriteString(w, `{"session_token":"v2-tok","selected_model":{"id":"gpt-4.1-mini","supported_endpoints":["/chat/completions"],"capabilities":{"type":"chat"}},"expires_at":`+
mustString(time.Now().Add(24*time.Hour).Unix())+`}`)
case "/models/session":
asked = append(asked, "/models/session")
w.WriteHeader(500)
default:
asked = append(asked, r.URL.Path+" "+modelOf(b)+" "+r.Header.Get("Copilot-Session-Token"))
io.WriteString(w, `{"id":"c1","model":"gpt-4.1-mini","choices":[{"index":0,"message":{"role":"assistant","content":"hello from auto"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}`)
}
})
s := New()
code, body := postAs(t, s, "", `{"model":"copilot/auto","messages":[{"role":"system","content":"be brief"},{"role":"user","content":"Refactor this function"}]}`)
if code != 200 || !strings.Contains(body, "hello from auto") {
t.Fatalf("copilot/auto: %d %s", code, body)
}
mu.Lock()
defer mu.Unlock()
if auto.version != "2026-08-01" || auto.prompt != "Refactor this function" {
t.Errorf("/auto was asked at %q with %q", auto.version, auto.prompt)
}
if strings.Join(asked, "|") != "/chat/completions gpt-4.1-mini v2-tok" {
t.Fatalf("Copilot was asked %q", asked)
}
}
+56 -2
View File
@@ -1402,6 +1402,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
where := "" // the last try's provider.Where, for the usage
again := 0 // times the last one left has been tried again
resealed := 0 // what of the conversation another account sealed was taken out: its reasoning, then its compaction
repicked := 0 // times Copilot's Auto was asked again for a model the account is served
floored := false // the reply's length raised to what the provider takes
plainFor := "" // the account and model asked again without effort updates (#617)
// the key or subscription account the last try went to (#557)
@@ -1422,7 +1423,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// for its allowance running out to be told as that one's error
// and once the agent has the stream's headers from an earlier try's
// keepalives, for a failure to be told as the stream's error
hw := newHoldWriter(w, !last || again < max(lastRetries, rateRetries) || other != nil || kept.sent)
hw := newHoldWriter(w, !last || again < max(lastRetries, rateRetries) || other != nil || kept.sent || autoPicks(c) && repicked < 2)
hw.thinkingShown = !refusesAfterThinking(c.model)
hw.ctx, hw.alive = r.Context(), kept
if isGroup && g.FirstToken > 0 && !last && streams {
@@ -1637,6 +1638,16 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
continue
}
telemetry.attempt(call, try, c.p.ID, sent, hw, capture)
if autoPicks(c) && repicked < 2 && !hw.passing && hw.code() == http.StatusBadRequest &&
(provider.CopilotRefusal(hw.errBody()) || wrongEndpoint(hw.code(), hw.errBody())) {
// Copilot refused the model its Auto picked, and Auto has
// picked another, which is asked for on the APIs it is
// served on (#256)
repicked++
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try })
i--
continue
}
if resealed < 2 && from == provider.Responses && !hw.passing && hw.code() >= 400 && foreignReasoning.Match(hw.errBody()) {
// the conversation moved here from another account or vendor,
// whose sealed reasoning this one can't read: asked again
@@ -2062,6 +2073,9 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P
// Copilot's Auto (all a Student plan may pick) is asked which model it
// picks, as Copilot's clients do, and the request goes to that model on
// the APIs it is served on, with the session's token
if model == provider.CopilotAuto && p.Account != nil && p.Account.Agent == "copilot" {
r = r.WithContext(provider.WithAutoPrompt(r.Context(), lastUserText(from, body)))
}
if ctx, m, err := p.ResolveAuto(r.Context(), model); err != nil {
msg := p.Name + ": " + err.Error()
return writeError(w, from, 502, msg), msg
@@ -2107,6 +2121,39 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P
return s.translate(w, r, p, from, to[0], model, body, &call.Usage)
}
// autoPicks is whether c is Copilot's Auto, which picks the model itself.
func autoPicks(c candidate) bool {
return c.model == provider.CopilotAuto && c.p.Account != nil && c.p.Account.Agent == "copilot"
}
// lastUserText is the text of the request's last user turn, which
// Copilot's Auto picks a model for.
func lastUserText(from provider.Protocol, body []byte) string {
req, err := parse(from, body)
if err != nil {
return ""
}
for i := len(req.Messages) - 1; i >= 0; i-- {
m := req.Messages[i]
if m.Role != "user" {
continue
}
var b strings.Builder
for _, p := range m.Parts {
if p.Kind == Text {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p.Text)
}
}
if b.Len() > 0 {
return b.String()
}
}
return ""
}
// markOpenRouterSharedPool keeps an upstream routing fact in the held attempt,
// rather than exposing it as a response header.
func markOpenRouterSharedPool(w http.ResponseWriter) {
@@ -2133,6 +2180,13 @@ func (s *Server) forward(ctx context.Context, p provider.Provider, to provider.P
if !p.Retry(ctx, body, res.StatusCode, b) {
break
}
if m := p.AutoNext(ctx); m != "" && !slices.Contains(s.usable(p, m), to) {
// Auto's next pick isn't served on this API (gpt-4.1 on
// chat alone, after gpt-5.3-codex on /responses, #256): the
// refusal is passed on, and the request made again for it
// on its own API (the candidates' loop)
break
}
if res, err = s.forwardOnce(ctx, p, to, path, body, in); err != nil || res.StatusCode != http.StatusForbidden && res.StatusCode != http.StatusBadRequest {
return res, err
}
@@ -2830,7 +2884,7 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to
r.Effort, req = "none", &r
continue
}
if req.CacheKey != "" && badRequest(res.StatusCode) && !wrongEndpoint(res.StatusCode, b) {
if req.CacheKey != "" && badRequest(res.StatusCode) && !wrongEndpoint(res.StatusCode, b) && !provider.CopilotRefusal(b) {
// a vendor that turns away fields it doesn't know is asked again
// without the cache key, and not sent it again once that works —
// at once when its error names the key; not every error does
+102
View File
@@ -16,6 +16,18 @@ package provider
// requests otherwise leave out: without it Copilot answers 404 (#256). Its
// answer has no selected_model: the client takes the first of
// available_models it knows.
//
// Copilot Chat 0.69 (now in microsoft/vscode, extensions/copilot:
// automodeService.ts, autoV2Fetcher.ts) asks neither: POST {api}/auto with
// the turn's prompt, {"prompt": …}, at X-GitHub-Api-Version 2026-08-01
// (another, or none, is 404 "invalid apiVersion"), which answers a
// session_token, the selected_model (its id, supported_endpoints and
// capabilities) and expires_at, a day on. Its pick is made among the
// models the account is served, where /models/session still names the
// same few for every plan (gpt-5.3-codex, gpt-5.4, gpt-5.4-mini,
// claude-haiku-4.5): a Student plan was refused each of those (#256), and
// an Individual one is picked gpt-6-luna. magpie asks /auto first, and
// /models/session when /auto has nothing it can use.
import (
"bytes"
@@ -39,6 +51,15 @@ const CopilotAuto = "auto"
// Auto session with; the Copilot CLI's headers name their own.
const copilotAutoVersion = "2025-10-01"
// copilotAutoV2Version is the one POST /auto takes, as Copilot Chat 0.69
// (@vscode/copilot-api 0.5) sends it.
const copilotAutoV2Version = "2026-08-01"
// copilotAutoReuse is how long an Auto session is used for: /auto's lasts a
// day, which Copilot Chat keeps for one conversation; magpie, one for the
// account, has Copilot pick again within the hour.
const copilotAutoReuse = time.Hour
// copilotAutoModel is Auto as magpie lists it.
var copilotAutoModel = catalog.Model{ID: CopilotAuto, Name: "Auto"}
@@ -55,6 +76,14 @@ var (
type copilotAutoKey struct{}
type copilotAutoPromptKey struct{}
// WithAutoPrompt carries the prompt of the request Copilot's Auto picks a
// model for: POST /auto picks for a prompt.
func WithAutoPrompt(ctx context.Context, prompt string) context.Context {
return context.WithValue(ctx, copilotAutoPromptKey{}, prompt)
}
// copilotAutoResolve is the account's Auto session, asked again a couple of
// minutes before it expires, when fresh is set or once the account was
// refused its model.
@@ -69,6 +98,10 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
return copilotAutoSession{}, err
}
base := s.apiBase(app.Host)
if a, ok := copilotAutoRoute(ctx, app, s, base); ok {
copilotAutoSessions[app.Token] = a
return a, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/models/session", strings.NewReader(`{"auto_mode":{"model_hints":["auto"]}}`))
if err != nil {
return copilotAutoSession{}, err
@@ -139,6 +172,57 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
return a, nil
}
// copilotAutoRoute asks POST /auto for the model, as Copilot Chat does: not
// ok when Copilot doesn't answer it (an older API, Auto v2 not offered the
// account) or picks one the account was refused.
func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, base string) (copilotAutoSession, bool) {
prompt, _ := ctx.Value(copilotAutoPromptKey{}).(string)
if strings.TrimSpace(prompt) == "" {
prompt = "hi" // a model test, or a turn of tool results alone
}
body, _ := json.Marshal(map[string]string{"prompt": prompt})
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/auto", bytes.NewReader(body))
if err != nil {
return copilotAutoSession{}, false
}
req.Header.Set("Authorization", "Bearer "+s.Token)
req.Header.Set("Content-Type", "application/json")
for k, v := range s.headers() {
req.Header.Set(k, v)
}
req.Header.Set("X-GitHub-Api-Version", copilotAutoV2Version)
res, err := http.DefaultClient.Do(req)
if err != nil {
return copilotAutoSession{}, false
}
defer res.Body.Close()
var v struct {
Token string `json:"session_token"`
Selected struct {
ID string `json:"id"`
Endpoints []string `json:"supported_endpoints"`
} `json:"selected_model"`
ExpiresAt int64 `json:"expires_at"`
}
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode/100 != 2 || json.Unmarshal(b, &v) != nil || v.Token == "" || v.Selected.ID == "" || copilotRefuses(app.Token, v.Selected.ID) {
return copilotAutoSession{}, false
}
// the pick's endpoints, which the account's list may not give
copilotSeenMu.Lock()
if _, ok := copilotSeen[v.Selected.ID]; !ok || len(v.Selected.Endpoints) > 0 {
copilotSeen[v.Selected.ID] = copilotAPIs(v.Selected.Endpoints)
}
copilotSeenMu.Unlock()
exp := time.Now().Add(copilotAutoReuse).Unix()
if v.ExpiresAt > 0 && v.ExpiresAt < exp {
exp = v.ExpiresAt
}
return copilotAutoSession{Token: v.Token, Model: v.Selected.ID, ExpiresAt: exp}, true
}
// copilotAutoFallback stands in for an Auto session Copilot has none of for
// the account (404): the model it may pick by hand likeliest served (see
// copilotModels) and not refused it, sent without a session, for a while;
@@ -176,6 +260,24 @@ func (p Provider) ResolveAuto(ctx context.Context, model string) (context.Contex
return context.WithValue(ctx, copilotAutoKey{}, a), a.Model, nil
}
// AutoNext is the model Copilot's Auto picks now for a request ctx resolved
// for it (ResolveAuto), "" for any other: once Retry has said a refused
// pick is worth sending again, the one it goes as.
func (p Provider) AutoNext(ctx context.Context) string {
if _, ok := ctx.Value(copilotAutoKey{}).(copilotAutoSession); !ok || p.Account == nil || p.Account.auto == nil {
return ""
}
a, err := p.Account.auto(p.Via(ctx))
if err != nil {
return ""
}
return a.Model
}
// CopilotRefusal is Copilot saying the account may not call the model it
// was asked for, which Auto, having picked it, picks another for.
func CopilotRefusal(body []byte) bool { return copilotNotServed.Match(body) }
// copilotAutoSign puts the Auto session on a request: the one ctx carries
// when the body names its model, or, for a body asking for "auto" itself
// (a test of the model), a session asked for here with the body's model
+2
View File
@@ -38,6 +38,8 @@ func (c *studentCopilot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, `{"data":[
{"id":"gpt-5-mini","name":"GPT-5 mini","model_picker_enabled":true,"policy":{"state":"disabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}},
{"id":"claude-sonnet-5","name":"Claude Sonnet 5","model_picker_category":"versatile","policy":{"state":"disabled"},"supported_endpoints":["/chat/completions","/v1/messages"],"capabilities":{"type":"chat"}}]}`)
case r.URL.Path == "/auto":
w.WriteHeader(404) // Auto v2 not offered: /models/session
case r.URL.Path == "/models/session" && r.Method == "POST":
c.sessions++
c.hints = string(b)
@@ -63,6 +63,8 @@ func (c *student371) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// terms accepted, as they are for any account: the model is no
// more served for it
io.WriteString(w, `{"state":"enabled"}`)
case "/auto":
w.WriteHeader(404) // Auto v2 not offered: /models/session
case "/models/session":
if !c.auto {
w.WriteHeader(404)