From 4660fd69f502eb14353029b138b00285e79087a9 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 07:55:24 +0800 Subject: [PATCH] =?UTF-8?q?provider:=20Copilot's=20Auto=20pick=20is=20serv?= =?UTF-8?q?ed=20to=20a=20Student=20plan,=20its=20session=20read=20as=20VS?= =?UTF-8?q?=20Code's=20is=20(infinitr0us,=20#256)=20=E2=80=94=20/auto=20pi?= =?UTF-8?q?cked=20gpt-5.6-luna=20and=20Copilot=20refused=20magpie=20the=20?= =?UTF-8?q?request=20(The=20requested=20model=20is=20not=20supported.)=20w?= =?UTF-8?q?hile=20VS=20Code=201.140's=20Copilot=20Chat=200.68=20was=20serv?= =?UTF-8?q?ed=20it=20on=20the=20same=20account.=20magpie=20sent=20Auto's?= =?UTF-8?q?=20Copilot-Session-Token=20with=20no=20X-GitHub-Api-Version,=20?= =?UTF-8?q?which=20Copilot=20Chat=20sends=20on=20every=20request=20(2026-0?= =?UTF-8?q?1-09),=20and=20Copilot=20reads=20the=20session=20only=20with=20?= =?UTF-8?q?one:=20without=20it=20the=20request=20is=20a=20model=20picked?= =?UTF-8?q?=20by=20hand,=20which=20the=20plan=20is=20refused=20for=20the?= =?UTF-8?q?=20pick.=20The=20editors'=20sign-in=20now=20sends=20Copilot's?= =?UTF-8?q?=20API=20that=20version=20and=20Copilot=20Chat=200.68=20/=20VS?= =?UTF-8?q?=20Code=201.140.0=20as=20its=20client=20(copilotAPIHeaders;=20G?= =?UTF-8?q?itHub's=20own=20API=20keeps=20no=20version);=20/models/session'?= =?UTF-8?q?s=202025-10-01=20is=20set=20after=20them.=20Copilot=20then=20ho?= =?UTF-8?q?lds=20the=20session=20to=20its=20pick=20(Requested=20model=20no?= =?UTF-8?q?t=20available=20for=20session=20for=20another=20model,=20or=20a?= =?UTF-8?q?=20session=20let=20go):=20such=20a=20session=20is=20let=20go=20?= =?UTF-8?q?and=20the=20request=20sent=20with=20one=20asked=20anew,=20not?= =?UTF-8?q?=20noted=20as=20a=20refusal.=20With=20the=20owner's=20real=20Co?= =?UTF-8?q?pilot=20account=20(Individual):=20without=20the=20version,=20th?= =?UTF-8?q?e=20session=20token=20was=20ignored=20(any=20model=20served=20w?= =?UTF-8?q?ith=20it);=20with=20it,=20/auto's=20gpt-6-luna=20was=20served?= =?UTF-8?q?=20on=20/responses=20with=20its=20session=20through=20ResolveAu?= =?UTF-8?q?to=20and=20Sign,=20and=20another=20model=20with=20that=20sessio?= =?UTF-8?q?n=20answered=20400=20not=20available=20for=20session;=20claude-?= =?UTF-8?q?haiku-4.5=20on=20chat=20and=20messages,=20gpt-4.1=20streamed,?= =?UTF-8?q?=20gemini-3.8-flash=20and=20gpt-5.4-mini=20on=20responses=20ans?= =?UTF-8?q?wered=20200=20with=20the=20new=20headers,=20and=20/models=20lis?= =?UTF-8?q?ts=20the=20same=20models.=20TestCopilotAutoPickServed=20fails?= =?UTF-8?q?=20on=20the=20old=20code=20(served=20gpt-4.1,=20the=20fallback)?= =?UTF-8?q?.=20go=20vet,=20builds=20(darwin,=20GOOS=3Dlinux=20nogui,=20GOO?= =?UTF-8?q?S=3Dwindows)=20and=20go=20test=20-tags=20nogui=20-count=3D1=20.?= =?UTF-8?q?/...=20under=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/account.go | 22 ++- internal/provider/copilot_api_version_test.go | 152 ++++++++++++++++++ internal/provider/copilot_auto.go | 21 ++- internal/provider/copilot_refused.go | 17 +- 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 internal/provider/copilot_api_version_test.go diff --git a/internal/provider/account.go b/internal/provider/account.go index fdd401b3..f475f666 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -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 ( diff --git a/internal/provider/copilot_api_version_test.go b/internal/provider/copilot_api_version_test.go new file mode 100644 index 00000000..c604b17f --- /dev/null +++ b/internal/provider/copilot_api_version_test.go @@ -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") + } +} diff --git a/internal/provider/copilot_auto.go b/internal/provider/copilot_auto.go index d942473c..6c99ba56 100644 --- a/internal/provider/copilot_auto.go +++ b/internal/provider/copilot_auto.go @@ -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. diff --git a/internal/provider/copilot_refused.go b/internal/provider/copilot_refused.go index 93817db4..f9d11e73 100644 --- a/internal/provider/copilot_refused.go +++ b/internal/provider/copilot_refused.go @@ -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