copilot: each Auto try in the route names the model Auto picked, where the pick came from and Copilot's refusal, and /auto's refused pick goes to the account's own model, not /models/session (infinitr0us, #256 again: on v0.1.916 a Student account's first copilot/auto requests each took two 400 "The requested model is not supported." tries before gpt-4.1 answered, and both refused tries were logged as model "auto", so neither the route nor X-Magpie-Model said whether /auto answered the account, what it picked, or whether magpie fell back to /models/session) — a try now carries auto: [{model, via, skipped, refused}], via being /auto, /models/session or fallback (the model the account may pick by hand), skipped why /auto's pick wasn't taken (its error, or the refused model it picked), refused Copilot's words; the Routing page names the try "GitHub Copilot (auto → gpt-5.3-codex)" and puts a line per pick under it (zh, ja, de strings), and the log says each pick not from /auto and each refusal. POST /auto is asked at Copilot Chat's tier, "balance", which its AutoV2Fetcher always sends. Once /auto has answered with a model the account was refused, the model it may pick by hand stands in, as Copilot Chat asks nothing else: /models/session names the same few models for every plan, which this account was refused (gpt-5.4-mini, gpt-5.3-codex). With the owner's real (Individual) Copilot account through an in-process gateway under a sandbox HOME: /auto picked gpt-6-luna at tier balance, 200, the try's auto [{gpt-6-luna /auto}]. TestCopilotAutoPicksInRoute (a fake Student plan whose /auto picks gpt-5.3-codex, refused) and routing-copilot-auto.test.cjs (WebKit, en and zh) fail on the old code; routing-wb-refused, routing-effort, routing-kind and routing-zcode-blocked pass on WebKit; go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 05:48:27 +08:00
1 parent da2a018d89
commit 026158b0ad
8 files changed
+309 -18

No files matched your search

+70
View File
@@ -139,3 +139,73 @@ func TestCopilotAutoAsksAuto(t *testing.T) {
t.Fatalf("Copilot was asked %q", asked)
}
}
// #256 again (infinitr0us, v0.1.916, a Student account): the first tries
// of copilot/auto were refused, then gpt-4.1 answered, and the route
// logged each refused try as "auto": which model was refused, and whether
// /auto or /models/session picked it, wasn't told. Each try now names the
// models Auto picked for it, where from and Copilot's refusal; /auto is
// asked at Copilot Chat's tier, and once it has answered with a model the
// account was refused, the model it may pick by hand stands in, not
// /models/session's, which names the same few for every plan.
func TestCopilotAutoPicksInRoute(t *testing.T) {
var mu sync.Mutex
var asked, tiers []string
copilotFake(t, "gho_student256picks", 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, copilotStudentModels)
case "/auto":
var q struct {
Tier string `json:"tier"`
}
json.Unmarshal(b, &q)
tiers = append(tiers, q.Tier)
io.WriteString(w, `{"session_token":"v2-tok","selected_model":{"id":"gpt-5.3-codex","supported_endpoints":["/responses"]},"expires_at":`+
mustString(time.Now().Add(24*time.Hour).Unix())+`}`)
case "/models/session":
asked = append(asked, "/models/session")
io.WriteString(w, `{"session_token":"s-tok","selected_model":"gpt-5.4-mini","available_models":["gpt-5.4-mini"],"expires_at":`+
mustString(time.Now().Add(time.Hour).Unix())+`}`)
default:
m := modelOf(b)
asked = append(asked, r.URL.Path+" "+m)
if m != "gpt-4.1" {
w.WriteHeader(400)
io.WriteString(w, copilotNotSupported)
return
}
io.WriteString(w, `{"id":"c1","model":"gpt-4.1","choices":[{"index":0,"message":{"role":"assistant","content":"hello from gpt-4.1"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}`)
}
})
s := New()
code, body := postAs(t, s, "", `{"model":"copilot/auto","messages":[{"role":"user","content":"Reply with the single word: ok"}]}`)
if code != 200 || !strings.Contains(body, "hello from gpt-4.1") {
t.Fatalf("copilot/auto: %d %s", code, body)
}
mu.Lock()
defer mu.Unlock()
if strings.Join(asked, "|") != "/responses gpt-5.3-codex|/chat/completions gpt-4.1" {
t.Errorf("Copilot was asked %q", asked)
}
if len(tiers) == 0 || tiers[0] != "balance" {
t.Errorf("/auto was asked at the tiers %q", tiers)
}
var picks []string
for _, tr := range lastRoute(s).Tries {
for _, p := range tr.Auto {
picks = append(picks, p.Model+" "+p.Via+" "+map[bool]string{true: "refused", false: "ok"}[p.Refused != ""])
}
}
if strings.Join(picks, "|") != "gpt-5.3-codex /auto refused|gpt-4.1 fallback ok" {
t.Fatalf("the route told the picks %q", picks)
}
tries := lastRoute(s).Tries
last := tries[len(tries)-1].Auto
if !strings.Contains(tries[0].Auto[0].Refused, "requested model is not supported") || !strings.Contains(last[len(last)-1].Skipped, "picked gpt-5.3-codex, which the account was refused") {
t.Fatalf("the picks said %+v", tries)
}
}
+7 -1
View File
@@ -1544,6 +1544,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
})
held := false // answered as its vendor did a moment ago, without asking
var queued int64 // ms it waited for a slot of its key's or account's
// the models Copilot's Auto picked for it, where from, and which
// were refused (#256)
autoPicked := func() []provider.AutoPick { return nil }
if said, ok := verifyHeld(c.restKey()); ok && last {
// the account must be verified first (#152): the agent's
// reconnects are told so again, not sent on to a vendor that
@@ -1555,6 +1558,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// once (hw.stop), not read on until the vendor hangs up
ctx, stop := context.WithCancel(r.Context())
hw.stop = stop
if autoPicks(c) {
ctx, autoPicked = provider.WithAutoPicks(ctx)
}
// when the request last went out to the vendor, its body
// written: what came before is magpie's, what after the
// vendor's (Record.Sent)
@@ -1603,7 +1609,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
call.Error = refusedError(c.p, c.model, hw.failMsg)
}
try := Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: fixed, Fast: fast, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error, Queued: queued,
Served: call.Usage.Served}
Served: call.Usage.Served, Auto: autoPicked()}
asName := provider.SentNameOnIn(wiresOf(r.Context()), c.p.ID, accountAgent(c.p), c.model, sent)
try.Swapped, try.Routed = swapped(asName, call.Usage.Served), usage.GroupRouted(asName, call.Usage.Served)
try.TTFT, try.FirstText = hw.first.ms()
+3
View File
@@ -246,6 +246,9 @@ type Try struct {
// Codex resets was spent by itself (the user's setting) — on Who, and
// what spending it did — and the request asked again
Reset *AutoReset `json:"reset,omitempty"`
// Auto: the models Copilot's Auto picked for it, in turn — where each
// pick came from and Copilot's refusal of it — Model being "auto"
Auto []provider.AutoPick `json:"auto,omitempty"`
}
// AutoReset is a Codex or Claude reset spent by itself, on Who's account.
+12
View File
@@ -2247,6 +2247,10 @@ const I18N = {
"rerouted": "改道",
"errors your agent saw": "Agent 收到的错误",
"It said: {error}": "原话:{error}",
"Copilot's Auto picked {model} ({via})": "Copilot 的 Auto 选了 {model}({via})",
"the model the account may pick by hand": "账号可手动选的模型",
"not from /auto: {why}": "未取自 /auto:{why}",
"Copilot refused it: {error}": "Copilot 拒绝了它:{error}",
"WorkBuddy refuses chats from Codex and Claude Code (their system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group": "WorkBuddy 会拒绝来自 Codex 和 Claude Code 的对话(因为它们的系统提示词);请在 Hermes、OpenCode 或 Pi 里使用它,或在这个分组里再加一个供应商",
"the provider's network firewall blocked requests from this IP; wait a while, or switch to another network or proxy": "供应商的网络防火墙拦截了来自这个 IP 的请求;请稍等一会儿,或换个网络或代理",
"ZCode's Start Plan still turned this request away, though magpie sends it as the ZCode app does; it can be a network block of this IP, or ZCode checking for something new. Use an account with a GLM Coding Plan, or add another provider to this group": "ZCode 体验套餐仍拒绝了这个请求(magpie 已按 ZCode 客户端的方式发送);可能是网络拦截了这个 IP,或 ZCode 又加了新的校验。请改用有 GLM Coding Plan 的账号,或在这个分组里加上其他供应商",
@@ -5281,6 +5285,10 @@ const I18N = {
"rerouted": "再ルーティング",
"errors your agent saw": "エージェントが受けたエラー",
"It said: {error}": "メッセージ:{error}",
"Copilot's Auto picked {model} ({via})": "Copilot の Auto が {model} を選択({via})",
"the model the account may pick by hand": "アカウントが手動で選べるモデル",
"not from /auto: {why}": "/auto からではない:{why}",
"Copilot refused it: {error}": "Copilot に拒否されました:{error}",
"WorkBuddy refuses chats from Codex and Claude Code (their system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group": "WorkBuddy は Codex と Claude Code からのチャットを(そのシステムプロンプトが原因で)拒否します。Hermes、OpenCode、Pi から使うか、このグループに別のプロバイダを追加してください",
"the provider's network firewall blocked requests from this IP; wait a while, or switch to another network or proxy": "プロバイダのネットワークファイアウォールがこの IP からのリクエストをブロックしました。しばらく待つか、別のネットワークやプロキシに切り替えてください",
"ZCode's Start Plan still turned this request away, though magpie sends it as the ZCode app does; it can be a network block of this IP, or ZCode checking for something new. Use an account with a GLM Coding Plan, or add another provider to this group": "magpie は ZCode アプリと同じ方法で送っていますが、ZCode の Start Plan がこのリクエストを拒否しました。この IP がネットワークでブロックされているか、ZCode が新たなチェックを加えた可能性があります。GLM Coding Plan のあるアカウントを使うか、このグループに別のプロバイダを追加してください",
@@ -8308,6 +8316,10 @@ const I18N = {
"rerouted": "umgeleitet",
"errors your agent saw": "Fehler, die Ihr Agent sah",
"It said: {error}": "Meldung: {error}",
"Copilot's Auto picked {model} ({via})": "Copilots Auto wählte {model} ({via})",
"the model the account may pick by hand": "das Modell, das das Konto selbst wählen darf",
"not from /auto: {why}": "nicht über /auto: {why}",
"Copilot refused it: {error}": "Copilot lehnte es ab: {error}",
"WorkBuddy refuses chats from Codex and Claude Code (their system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group": "WorkBuddy lehnt Chats von Codex und Claude Code ab (wegen ihres System-Prompts); nutzen Sie es aus Hermes, OpenCode oder Pi oder fügen Sie dieser Gruppe einen weiteren Anbieter hinzu",
"the provider's network firewall blocked requests from this IP; wait a while, or switch to another network or proxy": "die Netzwerk-Firewall des Anbieters hat Anfragen von dieser IP blockiert; warten Sie eine Weile oder wechseln Sie Netzwerk oder Proxy",
"ZCode's Start Plan still turned this request away, though magpie sends it as the ZCode app does; it can be a network block of this IP, or ZCode checking for something new. Use an account with a GLM Coding Plan, or add another provider to this group": "Der Start Plan von ZCode hat diese Anfrage dennoch abgewiesen, obwohl magpie sie wie die ZCode-App sendet; möglich sind eine Netzwerksperre dieser IP oder eine neue Prüfung durch ZCode. Nutzen Sie ein Konto mit GLM Coding Plan oder fügen Sie dieser Gruppe einen weiteren Anbieter hinzu",
+10
View File
@@ -574,6 +574,8 @@
function trySaid(r, i) {
const tr = r.tries[i], w = tried(r, tr), agent = agentName(r.agent);
let name = w ? `${who(w)} (${w.model})` : tr.id;
// Copilot's Auto: the model it picked last, which this try went as
if (w && tr.auto?.length) name = `${who(w)} (${w.model} → ${tr.auto[tr.auto.length - 1].model})`;
if (tr.effort) name += " " + t("at {level} reasoning", { level: tr.effort });
if (!tr.done) return t("{who} is answering…", { who: name });
// a Codex reset spent by itself: with Codex's own sign-in the one try
@@ -1155,6 +1157,14 @@
for (const a of asides(r)) items.push([a, "aside"]);
r.tries.forEach((tr, i) => {
items.push([tryWhy(r, i), tr.done ? (tryOk(tr) ? "ok" : "bad") : "wait"]);
// each model Copilot's Auto picked for it, where from, and whether
// Copilot refused it (#256)
for (const p of tr.auto || []) {
let s = t("Copilot's Auto picked {model} ({via})", { model: p.model, via: p.via === "fallback" ? t("the model the account may pick by hand") : p.via });
if (p.skipped) s += " · " + t("not from /auto: {why}", { why: p.skipped });
if (p.refused) s += " · " + t("Copilot refused it: {error}", { error: p.refused });
items.push([s, "aside"]);
}
// what the vendor said, word for word: the why above is magpie's reading of it
if (tr.done && !tryOk(tr) && tr.error) {
const hint = HINTS.find((h) => tr.error.endsWith(" — " + h));
@@ -0,0 +1,96 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Copilot's Auto (#256, infinitr0us on v0.1.916, a Student account): the
// route logged each refused try as "auto", so which model Copilot refused,
// and whether /auto or /models/session picked it, wasn't told. Each try's
// line now names the model Auto picked, and a line under it says where the
// pick came from, why not /auto when it didn't, and Copilot's refusal.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const now = new Date();
const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-");
const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString();
const refused = "Copilot: The requested model is not supported.";
const key = { id: "copilot", provider: "copilot", name: "GitHub Copilot", kind: "provider", model: "auto" };
const routes = [{
id: 100, seq: 100, time: at(0), agent: "opencode", model: "copilot/auto", provider: "copilot", order: [key],
tries: [
{ id: key.id, model: "auto", start: at(0), done: true, status: 400, ms: 300, error: refused,
auto: [{ model: "gpt-5.3-codex", via: "/auto", refused }] },
{ id: key.id, model: "auto", start: at(0), done: true, status: 200, ms: 400,
auto: [{ model: "gpt-4.1", via: "fallback", skipped: "picked gpt-5.3-codex, which the account was refused" }] },
],
done: true, status: 200, ms: 700,
}];
function serve(lang) {
const state = { agents: [{ id: "opencode", name: "OpenCode", path: "/test/opencode.json", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") {
const d = url.searchParams.get("day");
return json({ cut: false, days: [{ day, requests: routes.length }], routes: d ? routes : [] });
}
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await route.fulfill({ body: await fs.readFile(file), contentType });
};
}
const want = {
en: [
"Copilot's Auto picked gpt-5.3-codex (/auto) · Copilot refused it: " + refused,
"Copilot's Auto picked gpt-4.1 (the model the account may pick by hand) · not from /auto: picked gpt-5.3-codex, which the account was refused",
],
zh: [
"Copilot 的 Auto 选了 gpt-5.3-codex(/auto) · Copilot 拒绝了它:" + refused,
"Copilot 的 Auto 选了 gpt-4.1(账号可手动选的模型) · 未取自 /auto:picked gpt-5.3-codex, which the account was refused",
],
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: Copilot Auto's picks are told on each try`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const context = await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(15000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang));
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-copilot-auto.png`), fullPage: true });
}
await browser.close();
});
await page.goto("http://magpie.test/?view=routing");
await page.locator(".rt-day").nth(1).click();
await page.locator(".rt-req").first().click();
await page.waitForTimeout(400);
const got = await page.locator(".rt-steps li").evaluateAll((ls) => ls.map((l) => [l.className, l.textContent]));
assert.deepEqual(got.filter(([, s]) => s.startsWith(want[lang][0].slice(0, 12))).map(([c, s]) => [c, s]), want[lang].map((s) => ["aside", s]), JSON.stringify(got));
// each try's line names the model it went as
const tries = got.filter(([c]) => c === "ok" || c === "bad").map(([, s]) => s);
assert(tries[0].includes("auto → gpt-5.3-codex") && tries[1].includes("auto → gpt-4.1"), JSON.stringify(tries));
assert.deepEqual(errors, []);
});
}
}
+108 -17
View File
@@ -26,8 +26,11 @@ package provider
// models the account is served, where /models/session still names the
// same few for every plan (gpt-5.3-codex, gpt-5.4, gpt-5.4-mini,
// claude-haiku-4.5): a Student plan was refused each of those (#256), and
// an Individual one is picked gpt-6-luna. magpie asks /auto first, and
// /models/session when /auto has nothing it can use.
// an Individual one is picked gpt-6-luna. magpie asks /auto first, at
// Copilot Chat's default tier ("balance"), and /models/session only when
// /auto doesn't answer: a pick of /auto's the account was refused has the
// model it may pick by hand stand in. Each pick a request is sent with is
// noted on its try in the route (AutoPick).
import (
"bytes"
@@ -35,6 +38,7 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"slices"
"strings"
@@ -67,6 +71,78 @@ type copilotAutoSession struct {
Token string
Model string // the model Copilot picked
ExpiresAt int64
// Via: where the pick came from, "/auto", "/models/session" or
// AutoFallback; Skipped: why /auto's wasn't taken, when it isn't
Via, Skipped string
}
// AutoFallback is an Auto pick that is the model the account may pick by
// hand, Copilot having no Auto session to give it (copilotAutoFallback).
const AutoFallback = "fallback"
// AutoPick is a model Copilot's Auto picked for a try of a request: where
// the pick came from, why not from /auto when it didn't, and Copilot's
// refusal of it, which the route shows (#256: a Student plan's picks were
// refused, the tries logged as "auto").
type AutoPick struct {
Model string `json:"model"`
Via string `json:"via"`
Skipped string `json:"skipped,omitempty"`
Refused string `json:"refused,omitempty"`
}
type autoPicks struct {
mu sync.Mutex
list []AutoPick
}
type autoPicksKey struct{}
// WithAutoPicks has the Auto picks a request is sent with noted: the func
// returns them.
func WithAutoPicks(ctx context.Context) (context.Context, func() []AutoPick) {
p := &autoPicks{}
return context.WithValue(ctx, autoPicksKey{}, p), func() []AutoPick {
p.mu.Lock()
defer p.mu.Unlock()
return slices.Clone(p.list)
}
}
// notePick notes the request is sent with a: once, while it isn't refused.
func notePick(ctx context.Context, a copilotAutoSession) {
p, ok := ctx.Value(autoPicksKey{}).(*autoPicks)
if !ok || a.Model == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if n := len(p.list); n > 0 && p.list[n-1].Model == a.Model && p.list[n-1].Via == a.Via && p.list[n-1].Refused == "" {
return
}
p.list = append(p.list, AutoPick{Model: a.Model, Via: a.Via, Skipped: a.Skipped})
if a.Skipped != "" {
log.Printf("copilot auto: %s picked %s (/auto: %s)", a.Via, a.Model, a.Skipped)
}
}
// pickRefused notes Copilot refused the pick of model, saying said.
func pickRefused(ctx context.Context, model, said string) {
p, ok := ctx.Value(autoPicksKey{}).(*autoPicks)
if !ok {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for i := len(p.list) - 1; i >= 0; i-- {
if p.list[i].Model == model {
if p.list[i].Refused == "" {
p.list[i].Refused = said
log.Printf("copilot auto: %s (%s) refused: %s", model, p.list[i].Via, said)
}
return
}
}
}
var (
@@ -98,10 +174,17 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
return copilotAutoSession{}, err
}
base := s.apiBase(app.Host)
if a, ok := copilotAutoRoute(ctx, app, s, base); ok {
a, why, answered := copilotAutoRoute(ctx, app, s, base)
if why == "" {
copilotAutoSessions[app.Token] = a
return a, nil
}
if answered {
// /auto answered with a model the account was refused: Copilot
// Chat asks nothing else, and /models/session names the same few
// models for every plan, which a Student one is refused (#256)
return copilotAutoFallback(ctx, app, "/auto "+why)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/models/session", strings.NewReader(`{"auto_mode":{"model_hints":["auto"]}}`))
if err != nil {
return copilotAutoSession{}, err
@@ -125,7 +208,7 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
ExpiresAt int64 `json:"expires_at"`
}
if res.StatusCode == http.StatusNotFound {
return copilotAutoFallback(ctx, app, APIError(b, res.Status))
return copilotAutoFallback(ctx, app, "/auto "+why+"; /models/session: "+APIError(b, res.Status))
}
if res.StatusCode/100 != 2 || json.Unmarshal(b, &v) != nil || v.Token == "" {
return copilotAutoSession{}, errors.New("Copilot Auto: " + APIError(b, res.Status))
@@ -162,30 +245,31 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
if model == "" && len(v.Available) > 0 {
// every model Auto offers the account was refused it: the one it
// may pick by hand, as when it has no Auto
return copilotAutoFallback(ctx, app, "every model Copilot Auto offers was refused")
return copilotAutoFallback(ctx, app, "/auto "+why+"; every model /models/session offers was refused")
}
if model == "" {
return copilotAutoSession{}, errors.New("Copilot Auto picked no model")
}
a := copilotAutoSession{Token: v.Token, Model: model, ExpiresAt: v.ExpiresAt}
a = copilotAutoSession{Token: v.Token, Model: model, ExpiresAt: v.ExpiresAt, Via: "/models/session", Skipped: why}
copilotAutoSessions[app.Token] = a
return a, nil
}
// copilotAutoRoute asks POST /auto for the model, as Copilot Chat does: not
// ok when Copilot doesn't answer it (an older API, Auto v2 not offered the
// account) or picks one the account was refused.
func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, base string) (copilotAutoSession, bool) {
// copilotAutoRoute asks POST /auto for the model, as Copilot Chat does, at
// its default tier: why says what went wrong when Copilot doesn't answer it
// (an older API, Auto v2 not offered the account), answered being set when
// it did with a pick the account was refused.
func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, base string) (a copilotAutoSession, why string, answered bool) {
prompt, _ := ctx.Value(copilotAutoPromptKey{}).(string)
if strings.TrimSpace(prompt) == "" {
prompt = "hi" // a model test, or a turn of tool results alone
}
body, _ := json.Marshal(map[string]string{"prompt": prompt})
body, _ := json.Marshal(map[string]string{"prompt": prompt, "tier": "balance"})
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/auto", bytes.NewReader(body))
if err != nil {
return copilotAutoSession{}, false
return copilotAutoSession{}, err.Error(), false
}
req.Header.Set("Authorization", "Bearer "+s.Token)
req.Header.Set("Content-Type", "application/json")
@@ -195,7 +279,7 @@ func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, bas
req.Header.Set("X-GitHub-Api-Version", copilotAutoV2Version)
res, err := http.DefaultClient.Do(req)
if err != nil {
return copilotAutoSession{}, false
return copilotAutoSession{}, err.Error(), false
}
defer res.Body.Close()
var v struct {
@@ -207,8 +291,13 @@ func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, bas
ExpiresAt int64 `json:"expires_at"`
}
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode/100 != 2 || json.Unmarshal(b, &v) != nil || v.Token == "" || v.Selected.ID == "" || copilotRefuses(app.Token, v.Selected.ID) {
return copilotAutoSession{}, false
switch {
case res.StatusCode/100 != 2:
return copilotAutoSession{}, APIError(b, res.Status), false
case json.Unmarshal(b, &v) != nil || v.Token == "" || v.Selected.ID == "":
return copilotAutoSession{}, "picked no model: " + clipLine(string(b)), false
case copilotRefuses(app.Token, v.Selected.ID):
return copilotAutoSession{}, "picked " + v.Selected.ID + ", which the account was refused", true
}
// the pick's endpoints, which the account's list may not give
copilotSeenMu.Lock()
@@ -220,7 +309,7 @@ func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, bas
if v.ExpiresAt > 0 && v.ExpiresAt < exp {
exp = v.ExpiresAt
}
return copilotAutoSession{Token: v.Token, Model: v.Selected.ID, ExpiresAt: exp}, true
return copilotAutoSession{Token: v.Token, Model: v.Selected.ID, ExpiresAt: exp, Via: "/auto"}, "", true
}
// copilotAutoFallback stands in for an Auto session Copilot has none of for
@@ -241,7 +330,7 @@ func copilotAutoFallback(ctx context.Context, app copilotApp, why string) (copil
if len(picks) == 0 {
return copilotAutoSession{}, errors.New("Copilot doesn't offer Auto to this account (" + why + "), and lists no model it may pick by hand that it serves; check the plan at github.com/settings/copilot")
}
a := copilotAutoSession{Model: picks[0], ExpiresAt: time.Now().Add(10 * time.Minute).Unix()}
a := copilotAutoSession{Model: picks[0], ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), Via: AutoFallback, Skipped: why}
copilotAutoSessions[app.Token] = a
return a, nil
}
@@ -290,6 +379,7 @@ func copilotAutoSign(ctx context.Context, app copilotApp, req *http.Request, bod
if a.Token != "" {
req.Header.Set("Copilot-Session-Token", a.Token)
}
notePick(ctx, a)
return model, nil
}
// Auto's pick sent again once the account was refused it
@@ -313,6 +403,7 @@ func copilotAutoSign(ctx context.Context, app copilotApp, req *http.Request, bod
if a.Token != "" {
req.Header.Set("Copilot-Session-Token", a.Token)
}
notePick(ctx, a)
return a.Model, nil
}
+3
View File
@@ -55,6 +55,9 @@ func copilotRefused(ctx context.Context, app copilotApp, model string, status in
if model == "" || model == CopilotAuto {
return false
}
if auto {
pickRefused(ctx, model, APIError(body, "400"))
}
copilotRefusedMu.Lock()
if copilotRefusedAt[app.Token] == nil {
copilotRefusedAt[app.Token] = map[string]time.Time{}