diff --git a/internal/gateway/copilot_repick_test.go b/internal/gateway/copilot_repick_test.go index 1671095e..ff709525 100644 --- a/internal/gateway/copilot_repick_test.go +++ b/internal/gateway/copilot_repick_test.go @@ -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) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 51442090..93611c6a 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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() diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index 9050d2ff..ec52ae2b 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -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. diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 154f1a35..de97248c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 27cdfb0d..2e46ea2e 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -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)); diff --git a/internal/gui/tests/routing-copilot-auto.test.cjs b/internal/gui/tests/routing-copilot-auto.test.cjs new file mode 100644 index 00000000..53e56ee1 --- /dev/null +++ b/internal/gui/tests/routing-copilot-auto.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/provider/copilot_auto.go b/internal/provider/copilot_auto.go index 126c3fe8..224c0673 100644 --- a/internal/provider/copilot_auto.go +++ b/internal/provider/copilot_auto.go @@ -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 } diff --git a/internal/provider/copilot_refused.go b/internal/provider/copilot_refused.go index 92de303e..93817db4 100644 --- a/internal/provider/copilot_refused.go +++ b/internal/provider/copilot_refused.go @@ -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{}