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:
1 parent
da2a018d89
commit
026158b0ad
8 files changed
+309
-18
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
Reference in new issue
Block a user