copilot: Auto — Copilot's own pick, the only choice a Student plan has — is listed for every Copilot account and works through magpie, as Copilot's clients do it (#256; Discord, CakeJskSUMM: Copilot Student can only use Auto) — Copilot's /models doesn't list Auto; its clients add it themselves (Copilot CLI 1.0.79: {id:"auto",name:"Auto"}, AUTO_MODEL_ID) and ask POST {api}/models/session with {"auto_mode":{"model_hints":["auto"]}} for a session_token, the selected_model and expires_at, each request then naming that model with the token in Copilot-Session-Token. magpie listed no Auto, failed an account whose list left nothing to pick by hand ("Copilot lists no chat model for this account"), and sent "auto" as a model. Now copilot_auto.go keeps the session per GitHub account, asked again two minutes before it expires; the gateway resolves Auto before choosing the API, so a picked model served only on /responses goes there; a body still naming "auto" (the model test) is rewritten; the endpoints of every model Copilot lists, hidden ones too, are kept for the picked one. A reply to "auto" from another model isn't counted as swapped (Cursor's auto too); the Copilot CLI's picker, which offers auto itself, leaves out the listed one. Tested with fakes in a sandbox HOME: provider/copilot_auto_test.go (a Student-like account whose models are all disabled lists [auto], the session body sent as above, requests carry the picked gpt-5-mini and the token, one session reused, renewed near expiry, an object-shaped selected_model, a refusal reported) and gateway/copilot_auto_test.go (a chat request for copilot/auto goes to /responses as gpt-5-mini with the token, two turns one session; fails without the gateway change); served_test gains the auto cases. go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried with a real Student account: what its /models returns, whether the plain session request suffices (Auto v2's /models/session/intent is not done), and the body is from VS Code's Copilot Chat, not captured traffic.

This commit is contained in:
yetone committed 2026-09-30 10:17:15 +08:00
1 parent be4298b294
commit ffa55fc277
10 files changed
+470 -10

No files matched your search

+2
View File
@@ -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
+91
View File
@@ -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 // "<path> <model> <session token>"
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) }
+9
View File
@@ -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))
+2
View File
@@ -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},
} {
+37 -5
View File
@@ -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()
+2 -2
View File
@@ -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)
}
+162
View File
@@ -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
}
+161
View File
@@ -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 // "<path> <model> <Copilot-Session-Token>"
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)
}
}
+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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"
}