diff --git a/internal/gateway/copilot_auto_test.go b/internal/gateway/copilot_auto_test.go index 48dd1c6b..08e69a9f 100644 --- a/internal/gateway/copilot_auto_test.go +++ b/internal/gateway/copilot_auto_test.go @@ -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":`+ diff --git a/internal/gateway/copilot_refused_test.go b/internal/gateway/copilot_refused_test.go index ac9a249a..ebb363d0 100644 --- a/internal/gateway/copilot_refused_test.go +++ b/internal/gateway/copilot_refused_test.go @@ -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())+`}`) diff --git a/internal/gateway/copilot_repick_test.go b/internal/gateway/copilot_repick_test.go new file mode 100644 index 00000000..1671095e --- /dev/null +++ b/internal/gateway/copilot_repick_test.go @@ -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 // " " + 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) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 7cfd778a..51442090 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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 diff --git a/internal/provider/copilot_auto.go b/internal/provider/copilot_auto.go index 2a4b9712..126c3fe8 100644 --- a/internal/provider/copilot_auto.go +++ b/internal/provider/copilot_auto.go @@ -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 diff --git a/internal/provider/copilot_auto_test.go b/internal/provider/copilot_auto_test.go index 1c70a67f..eba98470 100644 --- a/internal/provider/copilot_auto_test.go +++ b/internal/provider/copilot_auto_test.go @@ -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) diff --git a/internal/provider/copilot_refused_test.go b/internal/provider/copilot_refused_test.go index 7ab3c9d5..d8c97e74 100644 --- a/internal/provider/copilot_refused_test.go +++ b/internal/provider/copilot_refused_test.go @@ -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)