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:
10 files changed
+470
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
} {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user