From ffa55fc277a169166c4ece700dc0420439d5bfc5 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 10:17:15 +0800 Subject: [PATCH] =?UTF-8?q?copilot:=20Auto=20=E2=80=94=20Copilot's=20own?= =?UTF-8?q?=20pick,=20the=20only=20choice=20a=20Student=20plan=20has=20?= =?UTF-8?q?=E2=80=94=20is=20listed=20for=20every=20Copilot=20account=20and?= =?UTF-8?q?=20works=20through=20magpie,=20as=20Copilot's=20clients=20do=20?= =?UTF-8?q?it=20(#256;=20Discord,=20CakeJskSUMM:=20Copilot=20Student=20can?= =?UTF-8?q?=20only=20use=20Auto)=20=E2=80=94=20Copilot's=20/models=20doesn?= =?UTF-8?q?'t=20list=20Auto;=20its=20clients=20add=20it=20themselves=20(Co?= =?UTF-8?q?pilot=20CLI=201.0.79:=20{id:"auto",name:"Auto"},=20AUTO=5FMODEL?= =?UTF-8?q?=5FID)=20and=20ask=20POST=20{api}/models/session=20with=20{"aut?= =?UTF-8?q?o=5Fmode":{"model=5Fhints":["auto"]}}=20for=20a=20session=5Ftok?= =?UTF-8?q?en,=20the=20selected=5Fmodel=20and=20expires=5Fat,=20each=20req?= =?UTF-8?q?uest=20then=20naming=20that=20model=20with=20the=20token=20in?= =?UTF-8?q?=20Copilot-Session-Token.=20magpie=20listed=20no=20Auto,=20fail?= =?UTF-8?q?ed=20an=20account=20whose=20list=20left=20nothing=20to=20pick?= =?UTF-8?q?=20by=20hand=20("Copilot=20lists=20no=20chat=20model=20for=20th?= =?UTF-8?q?is=20account"),=20and=20sent=20"auto"=20as=20a=20model.=20Now?= =?UTF-8?q?=20copilot=5Fauto.go=20keeps=20the=20session=20per=20GitHub=20a?= =?UTF-8?q?ccount,=20asked=20again=20two=20minutes=20before=20it=20expires?= =?UTF-8?q?;=20the=20gateway=20resolves=20Auto=20before=20choosing=20the?= =?UTF-8?q?=20API,=20so=20a=20picked=20model=20served=20only=20on=20/respo?= =?UTF-8?q?nses=20goes=20there;=20a=20body=20still=20naming=20"auto"=20(th?= =?UTF-8?q?e=20model=20test)=20is=20rewritten;=20the=20endpoints=20of=20ev?= =?UTF-8?q?ery=20model=20Copilot=20lists,=20hidden=20ones=20too,=20are=20k?= =?UTF-8?q?ept=20for=20the=20picked=20one.=20A=20reply=20to=20"auto"=20fro?= =?UTF-8?q?m=20another=20model=20isn't=20counted=20as=20swapped=20(Cursor'?= =?UTF-8?q?s=20auto=20too);=20the=20Copilot=20CLI's=20picker,=20which=20of?= =?UTF-8?q?fers=20auto=20itself,=20leaves=20out=20the=20listed=20one.=20Te?= =?UTF-8?q?sted=20with=20fakes=20in=20a=20sandbox=20HOME:=20provider/copil?= =?UTF-8?q?ot=5Fauto=5Ftest.go=20(a=20Student-like=20account=20whose=20mod?= =?UTF-8?q?els=20are=20all=20disabled=20lists=20[auto],=20the=20session=20?= =?UTF-8?q?body=20sent=20as=20above,=20requests=20carry=20the=20picked=20g?= =?UTF-8?q?pt-5-mini=20and=20the=20token,=20one=20session=20reused,=20rene?= =?UTF-8?q?wed=20near=20expiry,=20an=20object-shaped=20selected=5Fmodel,?= =?UTF-8?q?=20a=20refusal=20reported)=20and=20gateway/copilot=5Fauto=5Ftes?= =?UTF-8?q?t.go=20(a=20chat=20request=20for=20copilot/auto=20goes=20to=20/?= =?UTF-8?q?responses=20as=20gpt-5-mini=20with=20the=20token,=20two=20turns?= =?UTF-8?q?=20one=20session;=20fails=20without=20the=20gateway=20change);?= =?UTF-8?q?=20served=5Ftest=20gains=20the=20auto=20cases.=20go=20vet=20./i?= =?UTF-8?q?nternal/...,=20go=20build=20-tags=20nogui=20(and=20GOOS=3Dlinux?= =?UTF-8?q?,=20GOOS=3Dwindows)=20and=20go=20test=20-tags=20nogui=20./...?= =?UTF-8?q?=20pass.=20Not=20tried=20with=20a=20real=20Student=20account:?= =?UTF-8?q?=20what=20its=20/models=20returns,=20whether=20the=20plain=20se?= =?UTF-8?q?ssion=20request=20suffices=20(Auto=20v2's=20/models/session/int?= =?UTF-8?q?ent=20is=20not=20done),=20and=20the=20body=20is=20from=20VS=20C?= =?UTF-8?q?ode's=20Copilot=20Chat,=20not=20captured=20traffic.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agents.go | 2 + internal/gateway/copilot_auto_test.go | 91 ++++++++++++++ internal/gateway/gateway.go | 9 ++ internal/gateway/served_test.go | 2 + internal/provider/account.go | 42 ++++++- internal/provider/account_test.go | 4 +- internal/provider/copilot_auto.go | 162 +++++++++++++++++++++++++ internal/provider/copilot_auto_test.go | 161 ++++++++++++++++++++++++ internal/provider/copilot_cli_test.go | 2 +- internal/usage/served.go | 5 +- 10 files changed, 470 insertions(+), 10 deletions(-) create mode 100644 internal/gateway/copilot_auto_test.go create mode 100644 internal/provider/copilot_auto.go create mode 100644 internal/provider/copilot_auto_test.go diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 1933c216..ed886fa7 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -666,6 +666,8 @@ func copilot(home string) *Agent { // come from the list magpie last fetched from Copilot. out := []Option{{Value: "auto", Note: "let Copilot pick", Icon: "githubcopilot"}} if live, _, ok := catalog.Live("copilot"); ok { + // magpie's list has Auto too, offered above + live = slices.DeleteFunc(slices.Clone(live), func(m catalog.Model) bool { return m.ID == "auto" }) out = append(out, options(live, "")...) } return out diff --git a/internal/gateway/copilot_auto_test.go b/internal/gateway/copilot_auto_test.go new file mode 100644 index 00000000..48dd1c6b --- /dev/null +++ b/internal/gateway/copilot_auto_test.go @@ -0,0 +1,91 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// A Copilot Student plan may pick only Auto (Discord: 请问有对copilot学生 +// 套餐的支持吗?就是只能自动路由模型的那个). A chat request for copilot/auto +// is sent as Copilot's clients send it: the model Copilot's /models/session +// picked, on the API that model is served on (here /responses alone, so the +// Chat request is translated), with the session's Copilot-Session-Token. +func TestCopilotAutoThroughGateway(t *testing.T) { + 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": "gho_student"}, + }), 0o600) + + var mu sync.Mutex + var sessions int + var asked []string // " " + api := httptest.NewServer(http.HandlerFunc(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, `{"data":[ + {"id":"gpt-5-mini","name":"GPT-5 mini","model_picker_enabled":true,"policy":{"state":"disabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}}]}`) + case "/models/session": + sessions++ + io.WriteString(w, `{"session_token":"auto-tok","selected_model":"gpt-5-mini","available_models":["gpt-5-mini"],"expires_at":`+ + mustString(time.Now().Add(time.Hour).Unix())+`}`) + default: + asked = append(asked, r.URL.Path+" "+modelOf(b)+" "+r.Header.Get("Copilot-Session-Token")) + if r.URL.Path != "/responses" { + w.WriteHeader(400) + io.WriteString(w, `{"error":{"message":"model gpt-5-mini is not supported via /chat/completions"}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, sse( + `data: {"type":"response.created","response":{"id":"r1","model":"gpt-5-mini"}}`, + `data: {"type":"response.output_text.delta","delta":"hello from auto"}`, + `data: {"type":"response.completed","response":{"id":"r1","model":"gpt-5-mini","usage":{"input_tokens":3,"output_tokens":3}}}`)) + } + })) + defer api.Close() + gh := 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": api.URL}}) + return + } + io.WriteString(w, `{"copilot_plan":"individual","access_type_sku":"free_educational_quota"}`) + })) + defer gh.Close() + oldTok, oldUser := provider.CopilotTokenURL, provider.CopilotUserURL + provider.CopilotTokenURL, provider.CopilotUserURL = gh.URL+"/token", gh.URL+"/user" + defer func() { provider.CopilotTokenURL, provider.CopilotUserURL = oldTok, oldUser }() + + s := New() + for range 2 { + code, body := postAs(t, s, "", `{"model":"copilot/auto","messages":[{"role":"user","content":"hi"}]}`) + if code != 200 || !strings.Contains(body, "hello from auto") { + t.Fatalf("copilot/auto: %d %s", code, body) + } + } + mu.Lock() + defer mu.Unlock() + if strings.Join(asked, "|") != "/responses gpt-5-mini auto-tok|/responses gpt-5-mini auto-tok" || sessions != 1 { + t.Fatalf("Copilot was asked %q in %d sessions", asked, sessions) + } + c := s.Recent()[0] + if c.Provider != "copilot" || c.Model != "copilot/auto" && c.Model != "auto" || c.Usage.Served != "gpt-5-mini" { + t.Fatalf("call: %+v", c) + } +} + +func mustString(n int64) string { b, _ := json.Marshal(n); return string(b) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 58425520..5d119bec 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1163,6 +1163,15 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P return s.serveCommandCode(w, r, from, p, api, key, model, body, &call.Usage) } } + // 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 ctx, m, err := p.ResolveAuto(r.Context(), model); err != nil { + msg := p.Name + ": " + err.Error() + return writeError(w, from, 502, msg), msg + } else if m != model { + r, model = r.WithContext(ctx), m + } // a backend that only streams gets a non-streaming request translated // (the provider is always streamed on that path) rather than relayed relay := slices.Contains(s.usable(p, model), from) && (p.Account == nil || !p.Account.Stream || streamOf(body)) diff --git a/internal/gateway/served_test.go b/internal/gateway/served_test.go index 9d3774b2..9c620381 100644 --- a/internal/gateway/served_test.go +++ b/internal/gateway/served_test.go @@ -201,6 +201,8 @@ func TestSwappedModel(t *testing.T) { {"gemini-2.5-pro", "gemini-2.5-pro-preview-06-05", false}, {"openai/gpt-5", "gpt-5-2025-08-07", false}, {"us.anthropic.claude-sonnet-4-20250514-v1:0", "claude-sonnet-4-20250514", false}, + {"auto", "gpt-5-mini", false}, // Copilot's Auto, Cursor's: the vendor was asked to pick + {"copilot/auto", "claude-sonnet-5", false}, {"sol", "", false}, {"", "luna", false}, } { diff --git a/internal/provider/account.go b/internal/provider/account.go index 5c6663df..f3e6a7f3 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -67,6 +67,10 @@ type Account struct { models func() []catalog.Model fetch func(ctx context.Context) ([]catalog.Model, error) + // auto is set on a Copilot account: the session of Copilot's Auto, + // the model it picks for the account (copilot_auto.go). + auto func(ctx context.Context) (copilotAutoSession, error) + // retry is asked about a refusal the backend answered: true when the // account has put right what it names and the request is worth // sending once more (a Factory org the server can't reach, factory.go). @@ -88,6 +92,12 @@ func (p Provider) APIs(model string) []Protocol { return out } } + // the model Copilot's Auto picked may be one it lists for no picker + if p.ID == "copilot" { + if apis := copilotSeenAPIs(model); len(apis) > 0 { + return apis + } + } // Factory serves each model on the one API droid sends it on if p.ID == "factory" && p.Account != nil { return factoryAPIs(model) @@ -1061,7 +1071,11 @@ func copilotProvider(app copilotApp, plan string) Provider { req.URL, req.Host = u, u.Host } } - copilotAccept(ctx, app, s, bodyModel(body)) + model, err := copilotAutoSign(ctx, app, req, body) + if err != nil { + return err + } + copilotAccept(ctx, app, s, model) req.Header.Set("Authorization", "Bearer "+s.Token) for k, v := range s.headers() { req.Header.Set(k, v) @@ -1077,6 +1091,16 @@ func copilotProvider(app copilotApp, plan string) Provider { } return nil } + acct.auto = func(ctx context.Context) (copilotAutoSession, error) { + // the list says which APIs the picked model is served on + copilotTermsMu.Lock() + _, known := copilotTerms[app.Token] + copilotTermsMu.Unlock() + if !known { + copilotModels(ctx, app) + } + return copilotAutoResolve(ctx, app, false) + } acct.fetch = func(ctx context.Context) ([]catalog.Model, error) { ms, err := copilotModels(ctx, app) if err != nil { @@ -1191,7 +1215,7 @@ var ( // copilotAccept enables model for the account when its terms still wait. // A failure is left to the request, whose answer then says why. func copilotAccept(ctx context.Context, app copilotApp, s copilotSession, model string) { - if model == "" { + if model == "" || model == CopilotAuto { return } copilotTermsMu.Lock() @@ -1283,6 +1307,13 @@ func copilotModels(ctx context.Context, app copilotApp) ([]catalog.Model, error) } var out []catalog.Model waiting := map[string]bool{} + copilotSeenMu.Lock() + for _, m := range v.Data { + if m.Capabilities.Type == "chat" && len(m.Endpoints) > 0 { + copilotSeen[m.ID] = copilotAPIs(m.Endpoints) + } + } + copilotSeenMu.Unlock() for _, m := range v.Data { if m.Capabilities.Type != "chat" || copilotInternal.MatchString(m.ID) || m.Vendor == "Experimental" { continue @@ -1300,9 +1331,10 @@ func copilotModels(ctx context.Context, app copilotApp) ([]catalog.Model, error) } out = append(out, catalog.Model{ID: m.ID, Name: m.Name, Efforts: m.Capabilities.Supports.Efforts, APIs: copilotAPIs(m.Endpoints)}) } - if len(out) == 0 { - return nil, errors.New("Copilot lists no chat model for this account") - } + // Auto, which Copilot's clients offer every account beside the models + // it lists, and the only choice a Student plan has: an account whose + // list leaves it nothing to pick by hand still has it + out = append(out, copilotAutoModel) copilotTermsMu.Lock() copilotTerms[app.Token] = waiting copilotTermsMu.Unlock() diff --git a/internal/provider/account_test.go b/internal/provider/account_test.go index 2fbccc1e..1327c531 100644 --- a/internal/provider/account_test.go +++ b/internal/provider/account_test.go @@ -428,11 +428,11 @@ func TestCopilotSignAndModels(t *testing.T) { for _, m := range ms { ids = append(ids, m.ID) } - if strings.Join(ids, ",") != "gpt-5.5,claude-sonnet-5,gpt-4.1" || len(ms[0].Efforts) != 2 { + if strings.Join(ids, ",") != "gpt-5.5,claude-sonnet-5,gpt-4.1,auto" || len(ms[0].Efforts) != 2 { t.Fatalf("models: %v %+v", ids, ms) } p, _ = find(All(), "copilot") - if got := p.Available(); len(got) != 3 || got[0].ID != "gpt-5.5" { + if got := p.Available(); len(got) != 4 || got[0].ID != "gpt-5.5" { t.Fatalf("available after fetch: %+v", got) } diff --git a/internal/provider/copilot_auto.go b/internal/provider/copilot_auto.go new file mode 100644 index 00000000..c853d358 --- /dev/null +++ b/internal/provider/copilot_auto.go @@ -0,0 +1,162 @@ +package provider + +// Copilot's Auto: the model Copilot picks for the account, the only one a +// Student plan (Copilot Student / edu) may choose. Copilot's /models doesn't +// list it; its clients add it to their pickers themselves (Copilot CLI +// 1.0.79: {id:"auto", name:"Auto"} first, its AUTO_MODEL_ID) and, when it is +// picked, ask POST {api}/models/session with +// {"auto_mode":{"model_hints":["auto"]}} for a session: a session_token, the +// selected_model the requests then name, the available_models and when it +// expires_at (unix seconds). Every request of the session carries the token +// in Copilot-Session-Token, with the selected model in its body. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/catalog" +) + +// CopilotAuto is the model id that has Copilot pick the model. +const CopilotAuto = "auto" + +// copilotAutoModel is Auto as magpie lists it. +var copilotAutoModel = catalog.Model{ID: CopilotAuto, Name: "Auto"} + +type copilotAutoSession struct { + Token string + Model string // the model Copilot picked + ExpiresAt int64 +} + +var ( + copilotAutoMu sync.Mutex + copilotAutoSessions = map[string]copilotAutoSession{} // by GitHub token +) + +type copilotAutoKey struct{} + +// copilotAutoResolve is the account's Auto session, asked again a couple of +// minutes before it expires or when fresh is set. +func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilotAutoSession, error) { + copilotAutoMu.Lock() + defer copilotAutoMu.Unlock() + if a, ok := copilotAutoSessions[app.Token]; ok && !fresh && (a.ExpiresAt == 0 || time.Until(time.Unix(a.ExpiresAt, 0)) > 2*time.Minute) { + return a, nil + } + s, err := app.session(ctx) + if err != nil { + return copilotAutoSession{}, err + } + base := s.Endpoints.API + if base == "" { + base = copilotBase + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/models/session", strings.NewReader(`{"auto_mode":{"model_hints":["auto"]}}`)) + if err != nil { + return copilotAutoSession{}, err + } + 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) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return copilotAutoSession{}, errors.New("Copilot Auto: " + err.Error()) + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + var v struct { + Token string `json:"session_token"` + Selected json.RawMessage `json:"selected_model"` + ExpiresAt int64 `json:"expires_at"` + } + if res.StatusCode/100 != 2 || json.Unmarshal(b, &v) != nil || v.Token == "" { + return copilotAutoSession{}, errors.New("Copilot Auto: " + APIError(b, res.Status)) + } + // a model's id, or (Auto v2) the model itself + var model string + if json.Unmarshal(v.Selected, &model) != nil { + var m struct { + ID string `json:"id"` + } + json.Unmarshal(v.Selected, &m) + model = m.ID + } + if model == "" { + return copilotAutoSession{}, errors.New("Copilot Auto picked no model") + } + a := copilotAutoSession{Token: v.Token, Model: model, ExpiresAt: v.ExpiresAt} + copilotAutoSessions[app.Token] = a + return a, nil +} + +// ResolveAuto turns a request for a model the account picks itself +// (Copilot's Auto) into one for the model it picked: ctx then carries what +// the request is signed with. Any other model comes back as it was. +func (p Provider) ResolveAuto(ctx context.Context, model string) (context.Context, string, error) { + if p.Account == nil || p.Account.auto == nil || model != CopilotAuto { + return ctx, model, nil + } + a, err := p.Account.auto(p.Via(ctx)) + if err != nil { + return ctx, model, err + } + return context.WithValue(ctx, copilotAutoKey{}, a), a.Model, nil +} + +// 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 +// swapped for the one picked. +func copilotAutoSign(ctx context.Context, app copilotApp, req *http.Request, body []byte) (model string, err error) { + model = bodyModel(body) + if a, ok := ctx.Value(copilotAutoKey{}).(copilotAutoSession); ok && a.Model == model { + req.Header.Set("Copilot-Session-Token", a.Token) + return model, nil + } + if model != CopilotAuto { + return model, nil + } + a, err := copilotAutoResolve(ctx, app, false) + if err != nil { + return model, err + } + var m map[string]json.RawMessage + if json.Unmarshal(body, &m) != nil { + return model, errors.New("Copilot Auto: the request isn't JSON") + } + m["model"], _ = json.Marshal(a.Model) + nb, _ := json.Marshal(m) + req.Body = io.NopCloser(bytes.NewReader(nb)) + req.ContentLength = int64(len(nb)) + req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(nb)), nil } + req.Header.Set("Copilot-Session-Token", a.Token) + return a.Model, nil +} + +// 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. +var ( + copilotSeenMu sync.Mutex + copilotSeen = map[string][]string{} +) + +func copilotSeenAPIs(model string) []Protocol { + copilotSeenMu.Lock() + defer copilotSeenMu.Unlock() + var out []Protocol + for _, a := range copilotSeen[model] { + out = append(out, Protocol(a)) + } + return out +} diff --git a/internal/provider/copilot_auto_test.go b/internal/provider/copilot_auto_test.go new file mode 100644 index 00000000..d2b3e4ab --- /dev/null +++ b/internal/provider/copilot_auto_test.go @@ -0,0 +1,161 @@ +package provider + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" +) + +// studentCopilot stands in for Copilot's API as a Student plan sees it: its +// /models lists models none of which it may pick by hand, and Auto's +// session picks one of them, a model served on /responses alone. It notes +// what each request asked for. +type studentCopilot struct { + mu sync.Mutex + sessions int // POST /models/session + asked []string // " " + hints string // the last session request's body + expires int64 +} + +func (c *studentCopilot) ServeHTTP(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if r.Header.Get("Authorization") != "Bearer sess" { + w.WriteHeader(401) + return + } + c.mu.Lock() + defer c.mu.Unlock() + switch { + case r.URL.Path == "/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"}}, + {"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 == "/models/session" && r.Method == "POST": + c.sessions++ + c.hints = string(b) + json.NewEncoder(w).Encode(map[string]any{ + "session_token": "auto-tok-" + string(rune('0'+c.sessions)), + "selected_model": "gpt-5-mini", + "available_models": []string{"gpt-5-mini", "claude-sonnet-5"}, + "expires_at": c.expires, + "discounted_costs": map[string]float64{"gpt-5-mini": 0.1}, + }) + default: + c.asked = append(c.asked, r.URL.Path+" "+bodyModel(b)+" "+r.Header.Get("Copilot-Session-Token")) + io.WriteString(w, `{"ok":true}`) + } +} + +// A Student plan, whose only choice is Auto (Discord: 请问有对copilot学生 +// 套餐的支持吗?就是只能自动路由模型的那个): magpie lists Auto, as Copilot's +// clients do, where it used to list nothing; a request for it asks +// /models/session as the Copilot CLI does, is sent with the model Copilot +// picked and the session's token, the session kept until it nears its end. +func TestCopilotAuto(t *testing.T) { + signIn(t) + up := &studentCopilot{expires: time.Now().Add(time.Hour).Unix()} + 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{} + + p, _ := find(All(), "copilot") + ms, err := p.Fetch(context.Background()) + if err != nil || len(ms) != 1 || ms[0].ID != CopilotAuto || ms[0].Name != "Auto" { + t.Fatalf("models: %+v %v", ms, err) + } + // the model Auto picks is served where Copilot's list says, listed or not + if got := p.APIs("gpt-5-mini"); !slices.Equal(got, []Protocol{Responses}) { + t.Fatalf("gpt-5-mini APIs: %v", got) + } + + // the gateway's way: resolved first, so the API is the picked model's + ctx, model, err := p.ResolveAuto(context.Background(), CopilotAuto) + if err != nil || model != "gpt-5-mini" { + t.Fatalf("resolve: %q %v", model, err) + } + if up.hints != `{"auto_mode":{"model_hints":["auto"]}}` { + t.Fatalf("session asked with %s", up.hints) + } + send := func(ctx context.Context, body string) { + t.Helper() + 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) + } + res.Body.Close() + } + send(ctx, `{"model":"gpt-5-mini","input":"hi"}`) + // a request that names the model itself is not Auto's + send(context.Background(), `{"model":"gpt-5-mini","input":"hi"}`) + // a request for "auto" signed as it is (a model test) is swapped for + // the pick, in the same session + send(context.Background(), `{"model":"auto","input":"hi"}`) + want := []string{"/responses gpt-5-mini auto-tok-1", "/responses gpt-5-mini ", "/responses gpt-5-mini auto-tok-1"} + if !slices.Equal(up.asked, want) || up.sessions != 1 { + t.Fatalf("asked %q in %d sessions, want %q in 1", up.asked, up.sessions, want) + } + + // a session near its end is asked for again + copilotAutoMu.Lock() + a := copilotAutoSessions["gho_x"] + a.ExpiresAt = time.Now().Add(time.Minute).Unix() + copilotAutoSessions["gho_x"] = a + copilotAutoMu.Unlock() + if _, _, err := p.ResolveAuto(context.Background(), CopilotAuto); err != nil || up.sessions != 2 { + t.Fatalf("expiring session: %d sessions, %v", up.sessions, err) + } + + // any other model is left as it was, with nothing asked + if _, m, err := p.ResolveAuto(context.Background(), "claude-sonnet-5"); err != nil || m != "claude-sonnet-5" || up.sessions != 2 { + t.Fatalf("other model: %q %v", m, err) + } +} + +// Auto v2 answers the picked model as an object; a refusal is said. +func TestCopilotAutoSessionShapes(t *testing.T) { + signIn(t) + var refuse bool + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if refuse { + w.WriteHeader(403) + io.WriteString(w, `{"error":{"message":"Auto mode is not available for this account"}}`) + return + } + io.WriteString(w, `{"session_token":"t2","selected_model":{"id":"claude-sonnet-5","name":"Claude Sonnet 5"},"expires_at":0}`) + })) + defer api.Close() + app := copilotApp{User: "octocat", Token: "gho_shapes"} + copilotSessions = map[string]copilotSession{"gho_shapes": {Token: "sess", ExpiresAt: time.Now().Add(time.Hour).Unix(), Endpoints: struct { + API string `json:"api"` + }{API: api.URL}}} + copilotAutoSessions = map[string]copilotAutoSession{} + a, err := copilotAutoResolve(context.Background(), app, false) + if err != nil || a.Model != "claude-sonnet-5" || a.Token != "t2" { + t.Fatalf("v2: %+v %v", a, err) + } + refuse = true + if _, err := copilotAutoResolve(context.Background(), app, true); err == nil || !strings.Contains(err.Error(), "not available") { + t.Fatalf("refused: %v", err) + } +} diff --git a/internal/provider/copilot_cli_test.go b/internal/provider/copilot_cli_test.go index 07a5d364..54e4c06c 100644 --- a/internal/provider/copilot_cli_test.go +++ b/internal/provider/copilot_cli_test.go @@ -55,7 +55,7 @@ func TestCopilotCLILogin(t *testing.T) { t.Fatalf("copilot: %+v, asked %q", p, asked) } ms, err := p.Fetch(context.Background()) - if err != nil || len(ms) != 1 || ms[0].ID != "claude-sonnet-5" { + if err != nil || len(ms) != 2 || ms[0].ID != "claude-sonnet-5" || ms[1].ID != CopilotAuto { t.Fatalf("models: %+v %v", ms, err) } req, _ := http.NewRequest("POST", p.Chat+"/chat/completions", nil) diff --git a/internal/usage/served.go b/internal/usage/served.go index 356ee8db..a0586530 100644 --- a/internal/usage/served.go +++ b/internal/usage/served.go @@ -36,8 +36,9 @@ func bareModel(m string) string { } // Swapped reports whether served is another model than sent: not the same -// name, however dated, pinned or prefixed. +// name, however dated, pinned or prefixed. A vendor's "auto" (Copilot's, +// Cursor's) asked it to pick, so whichever answers wasn't swapped in. func Swapped(sent, served string) bool { a, b := bareModel(sent), bareModel(served) - return a != "" && b != "" && a != b + return a != "" && b != "" && a != b && a != "auto" }