From c96cfa0ba0ed410e4e24e2decd08795c83f28fe8 Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 27 Sep 2026 17:36:20 +0800 Subject: [PATCH] =?UTF-8?q?codex:=20warm-up=20=E2=80=94=20a=20ChatGPT=20ac?= =?UTF-8?q?count's=20next=20window=20can=20be=20started=20as=20soon=20as?= =?UTF-8?q?=20the=20last=20one=20resets,=20by=20a=20one-word=20request=20t?= =?UTF-8?q?o=20the=20mini=20model=20at=20low=20effort,=20so=20the=205-hour?= =?UTF-8?q?=20(or=20weekly)=20clock=20is=20already=20running=20when=20work?= =?UTF-8?q?=20begins;=20Settings=20=E2=86=92=20Codex=20=E9=A2=84=E7=83=AD:?= =?UTF-8?q?=20off,=20weekly,=20or=20weekly=20and=205-hour.=20Checked=20eve?= =?UTF-8?q?ry=205=20minutes,=20only=20a=20window=20seen=20idle=20(nothing?= =?UTF-8?q?=20used,=20its=20reset=20a=20full=20span=20away)=20is=20warmed,?= =?UTF-8?q?=20three=20tries=20per=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/gateway.go | 2 + internal/gui/api.go | 7 + internal/gui/assets/app.js | 7 +- internal/gui/assets/i18n.js | 5 + internal/gui/assets/index.html | 4 + internal/provider/codex_warmup.go | 358 +++++++++++++++++++++++++ internal/provider/codex_warmup_test.go | 243 +++++++++++++++++ internal/settings/settings.go | 13 + internal/settings/settings_test.go | 9 + 9 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 internal/provider/codex_warmup.go create mode 100644 internal/provider/codex_warmup_test.go diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 96ba364d..94f99ba4 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -177,6 +177,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error { go provider.KeepLoginsAlive(ctx) // and signs Codex in to its next account when the one it is on is out go provider.KeepCodexOnAnAccountWithRoom(ctx) + // and, when settings say to, starts its accounts' next windows as the last reset + go provider.KeepCodexWindowsWarm(ctx) for _, f := range WhileServing { go f(ctx) } diff --git a/internal/gui/api.go b/internal/gui/api.go index 97ffc1ee..fcdf6be7 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -114,6 +114,8 @@ type settingsJSON struct { Login bool `json:"login"` // where other machines reach the gateway while it is shared LANURLs []string `json:"lanURLs,omitempty"` + // when the Codex warm-up last started an account's window + CodexWarmed *time.Time `json:"codexWarmed,omitempty"` } func settingsState() settingsJSON { @@ -123,6 +125,11 @@ func settingsState() settingsJSON { if s.LAN { s.LANURLs = gateway.LANURLs() } + for _, t := range provider.CodexWarmed() { + if s.CodexWarmed == nil || t.After(*s.CodexWarmed) { + s.CodexWarmed = &t + } + } return s } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 4221f9f9..1dd98f19 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3988,7 +3988,7 @@ const DISCORD_SVG = ' [id, t(name)]), s.theme, (theme) => savePrefs({ ...keep, theme }))); $("#langSegs").replaceChildren(segs(LOCALES.map(([id, name]) => [id, t(name)]), s.lang, (lang) => savePrefs({ ...keep, lang }))); $("#traySegs").replaceChildren(segs(TRAYS.map(([id, name]) => [id, t(name)]), s.tray || "panel", (tray) => savePrefs({ ...keep, tray }))); @@ -4000,6 +4000,11 @@ function renderSettings() { // the system's record, set on its own, not with the other choices $("#loginSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.login ? "on" : "off", (v) => api("settings/login", { on: v === "on" }).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); }))); + // a ChatGPT account's next window started as soon as the last resets + $("#warmSegs").replaceChildren(segs([["off", t("Off")], ["week", t("Weekly")], ["all", t("Weekly and 5-hour")]], + s.codexWarmup || "off", (v) => savePrefs({ ...keep, codexWarmup: v === "off" ? "" : v }))); + $("#warmSub").textContent = t("When a ChatGPT account's window resets, send it one tiny request so the next one starts counting at once") + + (s.codexWarmed ? " · " + t("last started {when}", { when: syncWhen(s.codexWarmed) }) : ""); renderProxy(s, keep); renderRedact(s, keep); renderLAN(s); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 71caf78c..ee0ca46f 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -86,6 +86,11 @@ const I18N = { "Other computers send it as their API key; a new one stops the old from working": "其他电脑把它填作 API Key;换新后旧的立即失效", "API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API Key、私钥、Token 和密码以占位符发给模型厂商,回复里再还原", "Mask personal data": "脱敏个人信息", + "Codex warm-up": "Codex 预热", + "Weekly": "每周", + "Weekly and 5-hour": "每周和 5 小时", + "When a ChatGPT account's window resets, send it one tiny request so the next one starts counting at once": "ChatGPT 账号的额度窗口一重置,就替它发一条极小的请求,让下一个窗口立即开始计时", + "last started {when}": "上次启动于{when}", "Emails, phone numbers, ID and bank card numbers too": "邮箱、手机号、身份证号和银行卡号也一并脱敏", "Masked words": "脱敏词", "Your own words to keep from vendors, separated by commas": "不想让模型厂商看到的词,用逗号分隔", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index bbe10179..2c5b0890 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -132,6 +132,10 @@
Proxy
+
+
Codex warm-up
+
+
Local network
diff --git a/internal/provider/codex_warmup.go b/internal/provider/codex_warmup.go new file mode 100644 index 00000000..fec1446f --- /dev/null +++ b/internal/provider/codex_warmup.go @@ -0,0 +1,358 @@ +package provider + +// Starting a ChatGPT account's next window as soon as the last one resets. +// A Codex window starts at the account's first request after it resets, not +// at the reset: an account that sits idle for a day after its week ends has +// its next week end a day later too. So, while the setting is on, whichever +// magpie runs the gateway looks at each account's windows every few minutes +// and, when one has started over — its reset time passed, or its use fell +// back (OpenAI resetting everyone's limits early) — and nothing has used it +// since, sends that account one tiny request, as the gateway sends any. +// Once per reset: what it saw and did is kept in codex-warmup.json, so a +// restart doesn't send it again. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +const ( + // codexWarmEvery is how often the windows are looked at. + codexWarmEvery = 5 * time.Minute + // warmSlack is how far into a window it is still taken as unused: an + // idle window's reset is always a whole window from now. + warmSlack = 10 * time.Minute + // warmTries is how many failed warm-ups one reset gets. + warmTries = 3 +) + +// warmWindow is one window of an account as last seen, and when magpie +// last started it. +type warmWindow struct { + ResetsAt time.Time `json:"resetsAt,omitzero"` + Used float64 `json:"used"` + Warmed time.Time `json:"warmed,omitzero"` + // Pending is a reset whose warm-up failed, tried again next time + // until warmTries have failed. + Pending bool `json:"pending,omitempty"` + Failed int `json:"failed,omitempty"` +} + +// warmState is every account's windows, by lower-cased user and window name. +type warmState map[string]map[string]warmWindow + +func codexWarmPath() string { return filepath.Join(filepath.Dir(Path()), "codex-warmup.json") } + +func readWarmState(path string) warmState { + st := warmState{} + if b, err := os.ReadFile(path); err == nil { + json.Unmarshal(b, &st) + } + return st +} + +// CodexWarm is what came of looking at one account: the windows it was +// sent a request for, and why that failed. +type CodexWarm struct { + User string `json:"user"` + Windows []string `json:"windows"` + Err string `json:"error,omitempty"` +} + +// codexWarmer is the warm-up with what it reads and sends given, for tests. +type codexWarmer struct { + path string + now func() time.Time + usage func(context.Context) map[string]SubscriptionQuota // by user + send func(ctx context.Context, user string) error +} + +// warmNow reads each account's windows, the weekly ones or with which +// "all" the 5-hour ones too, sends a request to each account one of them +// has started over on, and keeps what it saw. +func (c codexWarmer) warmNow(ctx context.Context, which string) []CodexWarm { + st := readWarmState(c.path) + now := c.now() + usage := c.usage(ctx) + users := make([]string, 0, len(usage)) + for u := range usage { + users = append(users, u) + } + slices.Sort(users) + var out []CodexWarm + changed := false + for _, user := range users { + q := usage[user] + if q.Error != "" { + continue // nothing known: what was seen stands + } + key := strings.ToLower(user) + prev, next := st[key], map[string]warmWindow{} + var due []string + for _, w := range q.Windows { + if w.Span <= 0 || w.Aside || w.Model != "" || w.Span < 24*time.Hour && which != "all" { + continue + } + cur := asOf(w, now) + p, seen := prev[w.Name] + n := warmWindow{Used: cur.Used, Warmed: p.Warmed} + if cur.ResetsAt != nil { + n.ResetsAt = *cur.ResetsAt + } + if warmDue(p, seen, cur, now) { + due = append(due, w.Name) + // kept as it was until a request goes, so it is due again + n = p + } + next[w.Name] = n + } + if len(due) > 0 { + r := CodexWarm{User: user, Windows: due} + err := c.send(ctx, user) + for _, name := range due { + n, p := next[name], prev[name] + switch { + case err == nil: + n = windowSeen(q, name, now) + n.Warmed = now + case p.Failed+1 >= warmTries: // given up on this reset + n = windowSeen(q, name, now) + default: + n.Pending, n.Failed = true, p.Failed+1 + } + next[name] = n + } + if err != nil { + r.Err = err.Error() + } + out = append(out, r) + } + if !mapsEqual(prev, next) { + st[key], changed = next, true + } + } + if changed { + if b, err := json.MarshalIndent(st, "", " "); err == nil { + if err := writePrivate(c.path, append(b, '\n')); err != nil { + log.Printf("codex warm-up: %v", err) + } + } + } + return out +} + +// windowSeen is window name of q as now, as warmNow keeps it. +func windowSeen(q SubscriptionQuota, name string, now time.Time) warmWindow { + for _, w := range q.Windows { + if w.Name == name { + cur := asOf(w, now) + n := warmWindow{Used: cur.Used} + if cur.ResetsAt != nil { + n.ResetsAt = *cur.ResetsAt + } + return n + } + } + return warmWindow{} +} + +func mapsEqual(a, b map[string]warmWindow) bool { + if len(a) != len(b) { + return false + } + for k, x := range a { + y, ok := b[k] + if !ok || !x.ResetsAt.Equal(y.ResetsAt) || x.Used != y.Used || !x.Warmed.Equal(y.Warmed) || x.Failed != y.Failed || x.Pending != y.Pending { + return false + } + } + return true +} + +// asOf is w as of now, its reset made absolute: a window read before a +// reset that has since passed (a cached read, or one kept through a +// failed fetch) has started over, from nothing. +func asOf(w QuotaWindow, now time.Time) QuotaWindow { + if w.ResetsAt == nil && w.ResetSecs > 0 { + t := now.Add(time.Duration(w.ResetSecs) * time.Second) + w.ResetsAt = &t + } + if w.ResetsAt != nil && !now.Before(*w.ResetsAt) { + w.Used, w.ResetsAt = 0, nil + } + return w +} + +// warmDue says whether a window seen as p (seen false the first time) and +// now as cur wants starting: it is unused, and has started over since p — +// or is seen for the first time. Use falling back is a reset whatever the +// window says, OpenAI resetting everyone's limits early among them. One +// whose warm-up failed is still due. +func warmDue(p warmWindow, seen bool, cur QuotaWindow, now time.Time) bool { + switch { + case seen && p.Pending: + return true + case !seen: + return idle(cur, now) + case p.Used-cur.Used >= 1: + return true + case !p.ResetsAt.IsZero() && !now.Before(p.ResetsAt): + return idle(cur, now) + } + return false +} + +// idle says whether a window (as of now) hasn't started: nothing used, +// and its reset a whole window away, as the backend reports one not yet +// begun — or not known. +func idle(w QuotaWindow, now time.Time) bool { + return w.Used == 0 && (w.ResetsAt == nil || w.ResetsAt.Sub(now) >= w.Span-warmSlack) +} + +// codexWarmUsage is each ChatGPT account's windows, the cached reads the +// Usage page and the switch to an account with room share; an account +// whose sign-in lapsed is left out. +func codexWarmUsage(ctx context.Context) map[string]SubscriptionQuota { + u := LoginUsage(ctx, "codex") + for _, l := range Logins("codex") { + if l.Lapsed != "" { + delete(u, l.User) + } + } + return u +} + +// warmCodexLogin sends the ChatGPT account user one tiny request, with its +// own sign-in: Codex's for the account Codex is on, logins.json's else. +func warmCodexLogin(ctx context.Context, user string) error { + ls := Logins("codex") + i := slices.IndexFunc(ls, func(l Login) bool { return strings.EqualFold(l.User, user) }) + if i < 0 { + return fmt.Errorf("no ChatGPT account %q", user) + } + l := ls[i] + token := func(ctx context.Context) (string, string, error) { return savedLoginToken(ctx, "codex", l.User) } + if l.Active { + token = func(ctx context.Context) (string, string, error) { return codexToken(ctx, codexAuthPath()) } + } + model, effort, err := warmModel(l.User) + if err != nil { + return err + } + return warmCodex(ctx, codexSign(token), model, effort) +} + +// warmModel is the model a warm-up asks, and at what effort: the account's +// smallest (a mini) or else the first it lists, at low. +func warmModel(user string) (model, effort string, err error) { + ms, _, ok := catalog.Live(accountModels("codex", user)) + if !ok { + ms, _, _ = catalog.Live("codex") + } + if len(ms) == 0 { + ms = catalog.Codex() + } + if len(ms) == 0 { + return "", "", errors.New("no Codex model is known yet") + } + m := ms[0] + if i := slices.IndexFunc(ms, func(m catalog.Model) bool { return strings.Contains(m.ID, "mini") }); i >= 0 { + m = ms[i] + } + switch { + case slices.Contains(m.Efforts, "low"): + effort = "low" + case len(m.Efforts) > 0: + effort = m.Efforts[0] + } + return m.ID, effort, nil +} + +// warmCodex sends one "hi" to the ChatGPT backend as a Codex account's +// requests go (codexBody: Codex's instructions, not stored), and reads the +// reply to its end. +func warmCodex(ctx context.Context, sign func(context.Context, *http.Request, []byte) error, model, effort string) error { + in := map[string]any{"model": model, "input": "hi"} + if effort != "" { + in["reasoning"] = map[string]any{"effort": effort} + } + b, _ := json.Marshal(in) + body := codexBody(b) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, CodexBase+"/responses", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if err := sign(ctx, req, body); err != nil { + return err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + msg, _ := io.ReadAll(io.LimitReader(res.Body, 512)) + return fmt.Errorf("%s: %s", res.Status, strings.TrimSpace(string(msg))) + } + _, err = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20)) + return err +} + +// CodexWarmed is when magpie last started a window of each ChatGPT +// account, by user as kept (lower-cased). +func CodexWarmed() map[string]time.Time { return codexWarmedIn(codexWarmPath()) } + +func codexWarmedIn(path string) map[string]time.Time { + out := map[string]time.Time{} + for user, ws := range readWarmState(path) { + for _, w := range ws { + if w.Warmed.After(out[user]) { + out[user] = w.Warmed + } + } + } + return out +} + +// KeepCodexWindowsWarm starts the ChatGPT accounts' windows as they reset, +// while settings say to, two minutes after it starts and every +// codexWarmEvery after that, until ctx ends. +func KeepCodexWindowsWarm(ctx context.Context) { + w := codexWarmer{path: codexWarmPath(), now: time.Now, usage: codexWarmUsage, send: warmCodexLogin} + t := time.NewTimer(2 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if which := settings.Load().CodexWarmup; which != "" { + c, cancel := context.WithTimeout(ctx, 2*time.Minute) + for _, r := range w.warmNow(c, which) { + if r.Err != "" { + log.Printf("codex warm-up: %s's %s window: %s", r.User, strings.Join(r.Windows, ", "), r.Err) + } else { + log.Printf("codex warm-up: started %s's %s window", r.User, strings.Join(r.Windows, ", ")) + } + } + cancel() + } + t.Reset(codexWarmEvery) + } +} diff --git a/internal/provider/codex_warmup_test.go b/internal/provider/codex_warmup_test.go new file mode 100644 index 00000000..a5e8caf2 --- /dev/null +++ b/internal/provider/codex_warmup_test.go @@ -0,0 +1,243 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +const ( + week = 7 * 24 * time.Hour + fiveHours = 5 * time.Hour +) + +func win(name string, span time.Duration, used float64, resets time.Time) QuotaWindow { + w := QuotaWindow{Name: name, Used: used, Span: span} + if !resets.IsZero() { + w.ResetsAt = &resets + } + return w +} + +func TestWarmDue(t *testing.T) { + now := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC) + idleNow := win("7 days", week, 0, now.Add(week)) + running := win("7 days", week, 12, now.Add(3*24*time.Hour)) + for _, c := range []struct { + name string + p warmWindow + seen bool + cur QuotaWindow + want bool + }{ + {"first seen, unused", warmWindow{}, false, idleNow, true}, + {"first seen, reset not known", warmWindow{}, false, win("7 days", week, 0, time.Time{}), true}, + {"first seen, running", warmWindow{}, false, running, false}, + {"first seen, used a little", warmWindow{}, false, win("7 days", week, 0, now.Add(week-time.Hour)), false}, + {"the same window again", warmWindow{ResetsAt: *running.ResetsAt, Used: 12}, true, running, false}, + {"reset passed, unused since", warmWindow{ResetsAt: now.Add(-time.Minute), Used: 40}, true, idleNow, true}, + {"reset passed, used since", warmWindow{ResetsAt: now.Add(-time.Hour), Used: 0}, true, win("7 days", week, 3, now.Add(week-50*time.Minute)), false}, + {"reset early: use fell to nothing, reset kept", warmWindow{ResetsAt: now.Add(2 * 24 * time.Hour), Used: 70}, true, + win("7 days", week, 0, now.Add(2*24*time.Hour)), true}, + {"reset early: no window running", warmWindow{ResetsAt: now.Add(2 * 24 * time.Hour), Used: 70}, true, idleNow, true}, + {"an unused window sliding on", warmWindow{ResetsAt: now.Add(week - 5*time.Minute)}, true, idleNow, false}, + {"a stale read past its reset", warmWindow{ResetsAt: now.Add(-time.Minute), Used: 50}, true, + win("7 days", week, 50, now.Add(-time.Minute)), true}, + {"a failed warm-up", warmWindow{Pending: true, Failed: 1}, true, running, true}, + } { + if got := warmDue(c.p, c.seen, asOf(c.cur, now), now); got != c.want { + t.Errorf("%s: due %v, want %v", c.name, got, c.want) + } + } +} + +// fakeWarm is an account's windows as the backend tells them, and the +// warm-ups sent. +type fakeWarm struct { + now time.Time + ws map[string][]QuotaWindow + errs map[string]error + sent []string +} + +func (f *fakeWarm) warmer(path string) codexWarmer { + return codexWarmer{path: path, now: func() time.Time { return f.now }, + usage: func(context.Context) map[string]SubscriptionQuota { + out := map[string]SubscriptionQuota{} + for u, ws := range f.ws { + out[u] = SubscriptionQuota{Provider: "codex", User: u, Windows: ws} + } + return out + }, + send: func(_ context.Context, user string) error { + f.sent = append(f.sent, user) + return f.errs[user] + }} +} + +func (f *fakeWarm) run(t *testing.T, path, which string) []CodexWarm { + t.Helper() + return f.warmer(path).warmNow(context.Background(), which) +} + +func TestCodexWarmOncePerReset(t *testing.T) { + path := filepath.Join(t.TempDir(), "codex-warmup.json") + start := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC) + f := &fakeWarm{now: start, errs: map[string]error{}} + resets := start.Add(10 * time.Minute) + f.ws = map[string][]QuotaWindow{ + "a@example.com": {win("5 hours", fiveHours, 30, start.Add(time.Hour)), win("7 days", week, 60, resets)}, + "b@example.com": {win("7 days", week, 10, start.Add(3*24*time.Hour))}, + } + // running windows are only noted + if rs := f.run(t, path, "week"); len(rs) != 0 || len(f.sent) != 0 { + t.Fatalf("warmed running windows: %+v", rs) + } + // a's week resets; the next read, cached from before, still has the + // old window: it has started over all the same + f.now = resets.Add(time.Minute) + rs := f.run(t, path, "week") + if len(rs) != 1 || rs[0].User != "a@example.com" || strings.Join(rs[0].Windows, ",") != "7 days" || rs[0].Err != "" { + t.Fatalf("after the reset: %+v", rs) + } + // once: the read now shows it unused, then the window it started + f.ws["a@example.com"][1] = win("7 days", week, 0, f.now.Add(week)) + f.now = f.now.Add(5 * time.Minute) + f.run(t, path, "week") + f.ws["a@example.com"][1] = win("7 days", week, 0, resets.Add(time.Minute+week)) + f.now = f.now.Add(5 * time.Minute) + f.run(t, path, "week") + // nor after a restart, which reads what was kept + f.now = f.now.Add(5 * time.Minute) + f.run(t, path, "week") + if strings.Join(f.sent, ",") != "a@example.com" { + t.Fatalf("sent %v", f.sent) + } + if w := codexWarmedIn(path)["a@example.com"]; !w.Equal(resets.Add(time.Minute)) { + t.Fatalf("warmed at %v", w) + } + + // OpenAI resets b early: its use falls to nothing before its reset + f.ws["b@example.com"][0].Used = 0 + f.now = f.now.Add(5 * time.Minute) + if rs := f.run(t, path, "week"); len(rs) != 1 || rs[0].User != "b@example.com" { + t.Fatalf("early reset: %+v", rs) + } + f.now = f.now.Add(5 * time.Minute) + f.run(t, path, "week") + if strings.Join(f.sent, ",") != "a@example.com,b@example.com" { + t.Fatalf("sent %v", f.sent) + } + + // the 5-hour window only when asked for, and both windows one request + f.sent = nil + f.ws["a@example.com"][0] = win("5 hours", fiveHours, 0, time.Time{}) + f.now = f.now.Add(5 * time.Minute) + if rs := f.run(t, path, "week"); len(rs) != 0 { + t.Fatalf("the 5-hour window, weekly only: %+v", rs) + } + f.ws["a@example.com"][1] = win("7 days", week, 0, f.now.Add(week)) + f.ws["b@example.com"][0] = win("7 days", week, 0, f.now.Add(week)) + var hours []string + for _, r := range f.run(t, path, "all") { + hours = append(hours, r.User+":"+strings.Join(r.Windows, "+")) + } + // a's week is running (warmed above, its reset a week off): only its 5 hours + if strings.Join(hours, ",") != "a@example.com:5 hours" { + t.Fatalf("all: %v", hours) + } +} + +func TestCodexWarmFailureRetried(t *testing.T) { + path := filepath.Join(t.TempDir(), "codex-warmup.json") + now := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC) + f := &fakeWarm{now: now, errs: map[string]error{"a@example.com": errors.New("503")}, + ws: map[string][]QuotaWindow{"a@example.com": {win("7 days", week, 0, now.Add(week))}}} + for i := range warmTries + 2 { + f.now = now.Add(time.Duration(i) * 5 * time.Minute) + f.ws["a@example.com"][0] = win("7 days", week, 0, f.now.Add(week)) + f.run(t, path, "week") + } + if len(f.sent) != warmTries { + t.Fatalf("tried %d times, want %d", len(f.sent), warmTries) + } + if w := codexWarmedIn(path); !w["a@example.com"].IsZero() { + t.Fatalf("warmed: %v", w) + } +} + +// A warm-up goes to the ChatGPT backend as the gateway's Codex requests +// do: the account's own sign-in, Codex's instructions, nothing stored. +func TestCodexWarmRequest(t *testing.T) { + home := signIn(t) // me@example.com, acct-1, lists gpt-5.5 + rememberLogins(true) + var mu sync.Mutex + var got []map[string]any + var hdr http.Header + var usageAsked int + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch r.URL.Path { + case "/backend-api/wham/usage": + usageAsked++ + // a week that hasn't started: nothing used, its reset a week off + json.NewEncoder(w).Encode(map[string]any{"plan_type": "plus", "rate_limit": map[string]any{ + "primary_window": map[string]any{"used_percent": 0, "limit_window_seconds": 18000, "reset_after_seconds": 18000}, + "secondary_window": map[string]any{"used_percent": 0, "limit_window_seconds": 604800, "reset_after_seconds": 604800}}}) + case "/backend-api/codex/responses": + var m map[string]any + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &m) + got, hdr = append(got, m), r.Header.Clone() + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n") + default: + http.NotFound(w, r) + } + })) + defer fake.Close() + old := CodexBase + CodexBase = fake.URL + "/backend-api/codex" + t.Cleanup(func() { CodexBase = old }) + + loginUsageCache.Lock() + loginUsageCache.m = nil + loginUsageCache.Unlock() + w := codexWarmer{path: codexWarmPath(), now: time.Now, usage: codexWarmUsage, send: warmCodexLogin} + rs := w.warmNow(context.Background(), "week") + if len(rs) != 1 || rs[0].User != "me@example.com" || rs[0].Err != "" { + t.Fatalf("warm: %+v", rs) + } + if !strings.HasPrefix(codexWarmPath(), filepath.Join(home, ".config")) { + t.Fatalf("kept at %s", codexWarmPath()) + } + if len(got) != 1 { + t.Fatalf("%d requests", len(got)) + } + m := got[0] + if m["model"] != "gpt-5.5" || m["store"] != false || m["stream"] != true || m["instructions"] == "" || m["instructions"] == nil { + t.Fatalf("body: %v", m) + } + if b, _ := json.Marshal(m["input"]); !strings.Contains(string(b), `"hi"`) { + t.Fatalf("input: %s", b) + } + if !strings.HasPrefix(hdr.Get("Authorization"), "Bearer ") || hdr.Get("chatgpt-account-id") != "acct-1" || hdr.Get("originator") != "codex_cli_rs" { + t.Fatalf("headers: %v", hdr) + } + // a second look, the read cached: nothing more is sent + if rs := w.warmNow(context.Background(), "week"); len(rs) != 0 || len(got) != 1 { + t.Fatalf("again: %+v", rs) + } + if CodexWarmed()["me@example.com"].IsZero() { + t.Fatalf("not kept: %v", CodexWarmed()) + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 90431889..603cb604 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -46,6 +46,11 @@ type Settings struct { // key magpie makes when LAN is first turned on. LAN bool `json:"lan,omitempty"` LANKey string `json:"lanKey,omitempty"` + // CodexWarmup starts a ChatGPT account's next window as soon as the + // last one resets, with one tiny request, so it counts from then (a + // Codex window starts at its first use): "" off, "week" the weekly + // window, "all" the 5-hour one too. + CodexWarmup string `json:"codexWarmup,omitempty"` // How the agents are listed, by agent id. AgentOrder comes first, as // ordered; an agent it doesn't name (one installed since) follows in // magpie's own order. A hidden agent is folded away at the bottom of the @@ -110,6 +115,8 @@ var ( Themes = []string{"system", "light", "dark"} Langs = []string{"system", "en", "zh"} Trays = []string{"panel", "window"} + // Warmups are CodexWarmup's values, off as "". + Warmups = []string{"", "week", "all"} ) // Path is the settings file. @@ -145,6 +152,9 @@ func Save(s Settings) error { if !slices.Contains(Trays, s.Tray) { return fmt.Errorf("tray must be one of %v, not %q", Trays, s.Tray) } + if !slices.Contains(Warmups, s.CodexWarmup) { + return fmt.Errorf("codex warm-up must be off, week or all, not %q", s.CodexWarmup) + } s.Proxy = strings.TrimSpace(s.Proxy) if s.Proxy != "" && s.Proxy != "direct" { raw := s.Proxy @@ -177,6 +187,9 @@ func (s Settings) normal() Settings { if s.Tray == "" { s.Tray = "panel" } + if s.CodexWarmup == "off" { + s.CodexWarmup = "" + } return s } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index db7e3984..a45e1da6 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -27,6 +27,15 @@ func TestRoundTrip(t *testing.T) { if filepath.Base(Path()) != "settings.json" { t.Fatal(Path()) } + if Save(Settings{CodexWarmup: "week"}) != nil || Load().CodexWarmup != "week" { + t.Fatal("codex warm-up not kept") + } + if Save(Settings{CodexWarmup: "off"}) != nil || Load().CodexWarmup != "" { + t.Fatal("off is off") + } + if Save(Settings{CodexWarmup: "daily"}) == nil { + t.Fatal("bad codex warm-up accepted") + } } func TestMigrate(t *testing.T) {