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