Files
yetone ffa55fc277 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.
2026-09-30 10:17:15 +08:00

76 lines
3.1 KiB
Go

package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// The standalone CLI's sign-in: who in a JSONC config.json, the token in
// the keychain, sent as is under the CLI's integration.
func TestCopilotCLILogin(t *testing.T) {
home := signIn(t)
os.Remove(filepath.Join(home, ".config", "github-copilot", "apps.json"))
cli := filepath.Join(home, "copilot-home")
t.Setenv("COPILOT_HOME", cli)
os.MkdirAll(cli, 0o700)
os.WriteFile(filepath.Join(cli, "config.json"), []byte(`// written by copilot
{
"lastLoggedInUser": {"host": "https://github.com", "login": "octocat"},
"loggedInUsers": [{"host": "https://github.com", "login": "octocat"}], // one
}`), 0o600)
oldSecret := copilotCLISecret
var asked string
copilotCLISecret = func(account string) string { asked = account; return "gho_cli" }
defer func() { copilotCLISecret = oldSecret }()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer gho_cli" || r.Header.Get("Copilot-Integration-Id") != "copilot-developer-cli" {
w.WriteHeader(401)
return
}
w.Write([]byte(`{"data":[{"id":"claude-sonnet-5","name":"Claude Sonnet 5","model_picker_enabled":true,"capabilities":{"type":"chat"}}]}`))
}))
defer api.Close()
user := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token gho_cli" {
w.WriteHeader(401)
return
}
json.NewEncoder(w).Encode(map[string]any{"endpoints": map[string]string{"api": api.URL}})
}))
defer user.Close()
oldUser, oldToken := CopilotUserURL, CopilotTokenURL
CopilotUserURL, CopilotTokenURL = user.URL, "http://127.0.0.1:1/never"
defer func() { CopilotUserURL, CopilotTokenURL = oldUser, oldToken }()
copilotSessions = map[string]copilotSession{}
p, ok := find(All(), "copilot")
if !ok || p.Account.User != "octocat" || asked != "https://github.com:octocat" {
t.Fatalf("copilot: %+v, asked %q", p, asked)
}
ms, err := p.Fetch(context.Background())
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)
if err := p.Sign(context.Background(), req, Chat, []byte(`{"messages":[{"role":"user","content":"hi"}]}`)); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(req.URL.String(), api.URL) || req.Header.Get("Authorization") != "Bearer gho_cli" || req.Header.Get("Copilot-Integration-Id") != "copilot-developer-cli" {
t.Fatalf("signed: %s %v", req.URL, req.Header)
}
// signed in with the token kept in plaintext: no keychain
asked = ""
os.WriteFile(filepath.Join(cli, "config.json"), []byte(`{"lastLoggedInUser":{"host":"https://github.com","login":"hubot"},"copilotTokens":{"https://github.com:hubot":"gho_plain"}}`), 0o600)
if app, ok := copilotLogin(filepath.Join(home, ".config")); !ok || app.User != "hubot" || app.Token != "gho_plain" || asked != "" {
t.Fatalf("plaintext: %+v asked %q", app, asked)
}
}