provider: Copilot's Auto pick is served to a Student plan, its session read as VS Code's is (infinitr0us, #256) — /auto picked gpt-5.6-luna and Copilot refused magpie the request (The requested model is not supported.) while VS Code 1.140's Copilot Chat 0.68 was served it on the same account. magpie sent Auto's Copilot-Session-Token with no X-GitHub-Api-Version, which Copilot Chat sends on every request (2026-01-09), and Copilot reads the session only with one: without it the request is a model picked by hand, which the plan is refused for the pick. The editors' sign-in now sends Copilot's API that version and Copilot Chat 0.68 / VS Code 1.140.0 as its client (copilotAPIHeaders; GitHub's own API keeps no version); /models/session's 2025-10-01 is set after them. Copilot then holds the session to its pick (Requested model not available for session for another model, or a session let go): such a session is let go and the request sent with one asked anew, not noted as a refusal. With the owner's real Copilot account (Individual): without the version, the session token was ignored (any model served with it); with it, /auto's gpt-6-luna was served on /responses with its session through ResolveAuto and Sign, and another model with that session answered 400 not available for session; claude-haiku-4.5 on chat and messages, gpt-4.1 streamed, gemini-3.8-flash and gpt-5.4-mini on responses answered 200 with the new headers, and /models lists the same models. TestCopilotAutoPickServed fails on the old code (served gpt-4.1, the fallback). 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 07:55:27 +08:00
1 parent 73d1466ff7
commit 4660fd69f5
4 files changed
+205 -7

No files matched your search

+18 -4
View File
@@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"os"
@@ -914,12 +915,25 @@ var CopilotTokenURL = "https://api.github.com/copilot_internal/v2/token"
const copilotBase = "https://api.githubcopilot.com"
var copilotHeaders = map[string]string{
"Editor-Version": "vscode/1.104.0",
"Editor-Plugin-Version": "copilot-chat/0.31.0",
"Editor-Version": "vscode/1.140.0",
"Editor-Plugin-Version": "copilot-chat/0.68.0",
"Copilot-Integration-Id": "vscode-chat",
"User-Agent": "GitHubCopilotChat/0.31.0",
"User-Agent": "GitHubCopilotChat/0.68.0",
}
// copilotAPIHeaders are what the editors' sign-in sends Copilot's API
// (api.githubcopilot.com, not GitHub's): Copilot Chat's API version, which
// it sends on every chat request (networking.ts). Without one Copilot
// leaves Auto's Copilot-Session-Token unread and takes the request as a
// model picked by hand, which a Student plan is refused for Auto's pick
// (#256: gpt-5.6-luna, "The requested model is not supported.", served to
// VS Code on the same account); with it the session is held to its pick.
var copilotAPIHeaders = func() map[string]string {
h := maps.Clone(copilotHeaders)
h["X-GitHub-Api-Version"] = "2026-01-09"
return h
}()
type copilotSession struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
@@ -934,7 +948,7 @@ func (s copilotSession) headers() map[string]string {
if s.direct {
return copilotCLIHeaders
}
return copilotHeaders
return copilotAPIHeaders
}
var (
@@ -0,0 +1,152 @@
package provider
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// studentAuto stands in for Copilot's API as a Student plan sees it on
// Auto, as the owner's account showed it: POST /auto picks gpt-5.6-luna,
// which the plan may not pick by hand. A request without an API version
// has its Copilot-Session-Token left unread, so it is taken as picked by
// hand and refused; with one, the session is held to its pick ("Requested
// model not available for session" for another, or once let go).
type studentAuto struct {
mu sync.Mutex
autos int
gone string // a session token Copilot no longer takes
asked []http.Header
answers []int
}
func (c *studentAuto) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
c.mu.Lock()
defer c.mu.Unlock()
switch r.URL.Path {
case "/models":
io.WriteString(w, `{"data":[
{"id":"gpt-4.1","name":"GPT-4.1","model_picker_enabled":true,"policy":{"state":"enabled"},"supported_endpoints":["/chat/completions"],"capabilities":{"type":"chat"}},
{"id":"gpt-5.6-luna","name":"GPT-5.6 Luna","model_picker_enabled":true,"policy":{"state":"enabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}}]}`)
return
case "/auto":
c.autos++
json.NewEncoder(w).Encode(map[string]any{
"session_token": "auto-" + string(rune('0'+c.autos)),
"selected_model": map[string]any{"id": "gpt-5.6-luna", "supported_endpoints": []string{"/responses"}},
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
})
return
}
c.asked = append(c.asked, r.Header.Clone())
sess, model := r.Header.Get("Copilot-Session-Token"), bodyModel(b)
code, answer := 200, `{"id":"r","model":"`+model+`","output":[]}`
switch {
case r.Header.Get("X-GitHub-Api-Version") == "" || sess == "":
if model != "gpt-4.1" {
code, answer = 400, `{"error":{"message":"The requested model is not supported.","code":"model_not_supported"}}`
}
case sess == c.gone || model != "gpt-5.6-luna":
code, answer = 400, `{"error":{"message":"Requested model not available for session","code":""}}`
}
c.answers = append(c.answers, code)
w.WriteHeader(code)
io.WriteString(w, answer)
}
// #256: a Student account's Auto picked gpt-5.6-luna, and Copilot refused
// magpie the request ("The requested model is not supported.") while it
// served VS Code the same pick on the same account: magpie sent Auto's
// session token with no X-GitHub-Api-Version, which Copilot Chat sends on
// every request, and Copilot reads the session only with one. The pick is
// served now, with Copilot Chat 0.68's headers; a session Copilot no
// longer takes is asked anew.
func TestCopilotAutoPickServed(t *testing.T) {
signIn(t)
up := &studentAuto{}
api := httptest.NewServer(up)
defer api.Close()
tokens := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"token": "sess", "expires_at": time.Now().Add(time.Hour).Unix(), "endpoints": map[string]string{"api": api.URL}})
}))
defer tokens.Close()
old := CopilotTokenURL
CopilotTokenURL = tokens.URL
defer func() { CopilotTokenURL = old }()
copilotSessions = map[string]copilotSession{}
copilotAutoSessions = map[string]copilotAutoSession{}
copilotTerms = map[string]map[string]bool{}
copilotPicks = map[string][]string{}
copilotRefusedAt = map[string]map[string]time.Time{}
copilotSeen = map[string][]string{}
p, _ := find(All(), "copilot")
if _, err := p.Fetch(context.Background()); err != nil {
t.Fatal(err)
}
// the gateway's way (forward): resolved, signed, sent, and sent again
// while the account says it is worth it
send := func() (int, string) {
t.Helper()
ctx, model, err := p.ResolveAuto(context.Background(), CopilotAuto)
if err != nil || model != "gpt-5.6-luna" {
t.Fatalf("resolve: %q %v", model, err)
}
body := `{"model":"` + model + `","input":"Write a Go function that reverses a string."}`
for range 4 {
req, _ := http.NewRequest("POST", p.Responses+"/responses", strings.NewReader(body))
if err := p.Sign(ctx, req, Responses, []byte(body)); err != nil {
t.Fatal(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != 400 || !p.Retry(ctx, []byte(body), res.StatusCode, b) {
return res.StatusCode, string(b)
}
}
return 0, ""
}
if code, b := send(); code != 200 || !strings.Contains(b, "gpt-5.6-luna") {
t.Fatalf("Auto's pick: %d %s", code, b)
}
h := up.asked[0]
for k, v := range map[string]string{
"X-GitHub-Api-Version": "2026-01-09",
"Copilot-Session-Token": "auto-1",
"Editor-Version": "vscode/1.140.0",
"Editor-Plugin-Version": "copilot-chat/0.68.0",
"User-Agent": "GitHubCopilotChat/0.68.0",
} {
if h.Get(k) != v {
t.Errorf("%s: %q, want %q", k, h.Get(k), v)
}
}
if copilotRefuses("gho_x", "gpt-5.6-luna") {
t.Fatal("gpt-5.6-luna noted as refused")
}
// a session Copilot lets go of is asked anew, and the pick served
up.mu.Lock()
up.gone, up.asked, up.answers = "auto-1", nil, nil
up.mu.Unlock()
if code, b := send(); code != 200 {
t.Fatalf("after the session went: %d %s", code, b)
}
if len(up.asked) != 2 || up.asked[1].Get("Copilot-Session-Token") != "auto-2" || up.autos != 2 {
t.Fatalf("sent with %d tries (%v), %d sessions asked; last token %q", len(up.asked), up.answers, up.autos, up.asked[len(up.asked)-1].Get("Copilot-Session-Token"))
}
if copilotRefuses("gho_x", "gpt-5.6-luna") {
t.Fatal("a session let go noted as a refusal")
}
}
+19 -2
View File
@@ -201,10 +201,10 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
}
req.Header.Set("Authorization", "Bearer "+s.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-GitHub-Api-Version", copilotAutoVersion)
for k, v := range s.headers() {
req.Header.Set(k, v)
}
req.Header.Set("X-GitHub-Api-Version", copilotAutoVersion)
res, err := http.DefaultClient.Do(req)
if err != nil {
return copilotAutoSession{}, errors.New("Copilot Auto: " + err.Error())
@@ -385,7 +385,7 @@ func copilotAutoSign(ctx context.Context, app copilotApp, req *http.Request, bod
model = bodyModel(body)
a, auto := ctx.Value(copilotAutoKey{}).(copilotAutoSession)
auto = auto && a.Model == model
if auto && !copilotRefuses(app.Token, model) {
if auto && !copilotRefuses(app.Token, model) && copilotAutoHeld(app.Token, &a) {
if a.Token != "" {
req.Header.Set("Copilot-Session-Token", a.Token)
}
@@ -417,6 +417,23 @@ func copilotAutoSign(ctx context.Context, app copilotApp, req *http.Request, bod
return a.Model, nil
}
// copilotAutoHeld reports whether a's pick is still the account's Auto
// session's, a then being that session (asked anew after a was let go): one
// Copilot no longer took for its pick (copilotSessionGone) was let go, and
// the request goes with a session asked anew.
func copilotAutoHeld(token string, a *copilotAutoSession) bool {
if a.Token == "" {
return true
}
copilotAutoMu.Lock()
defer copilotAutoMu.Unlock()
cur, ok := copilotAutoSessions[token]
if ok && cur.Model == a.Model {
*a = cur
}
return ok && cur.Model == a.Model
}
// copilotSeen keeps the endpoints of every chat model Copilot's list
// named, listed by magpie or not: the model Auto picks may be one the
// account can't pick by hand, and is served on its own APIs all the same.
+16 -1
View File
@@ -31,14 +31,29 @@ var (
// gateway takes to another of the model's endpoints.
var copilotNotServed = regexp.MustCompile(`(?i)"model_not_supported"|requested model is not supported`)
// copilotSessionGone is Copilot saying the request's Copilot-Session-Token
// doesn't hold its model ("Requested model not available for session"),
// which it says once the session is read (copilotAPIHeaders).
var copilotSessionGone = regexp.MustCompile(`(?i)not available for session`)
// copilotRefused notes a refusal of model for the account, and says whether
// the request is worth sending again: when Auto picked the model, as Auto
// then picks another.
func copilotRefused(ctx context.Context, app copilotApp, model string, status int, body []byte) bool {
a, ok := ctx.Value(copilotAutoKey{}).(copilotAutoSession)
if status == 400 && copilotSessionGone.Match(body) && ok && a.Model == model && a.Token != "" {
// Auto's session isn't taken for its pick any more: it is let
// go, and the request sent with one asked anew
copilotAutoMu.Lock()
if cur, held := copilotAutoSessions[app.Token]; held && cur.Token == a.Token {
delete(copilotAutoSessions, app.Token)
}
copilotAutoMu.Unlock()
return true
}
if status != 400 || !copilotNotServed.Match(body) {
return false
}
a, ok := ctx.Value(copilotAutoKey{}).(copilotAutoSession)
// Auto's: the request the gateway resolved for it, or a model test
// asking for "auto" itself
auto := ok && a.Model == model || model == CopilotAuto