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" }