codex: warm-up — a ChatGPT account's next window can be started as soon as the last one resets, by a one-word request to the mini model at low effort, so the 5-hour (or weekly) clock is already running when work begins; Settings → Codex 预热: off, weekly, or weekly and 5-hour. Checked every 5 minutes, only a window seen idle (nothing used, its reset a full span away) is warmed, three tries per reset

This commit is contained in:
yetone committed 2026-09-27 17:36:20 +08:00
1 parent 8f915a5a23
commit c96cfa0ba0
9 files changed
+647 -1

No files matched your search

+2
View File
@@ -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)
}
+7
View File
@@ -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
}
+6 -1
View File
@@ -3988,7 +3988,7 @@ const DISCORD_SVG = '<svg viewBox="0 0 24 24" width="13" height="13" fill="curre
function renderSettings() {
const s = prefs;
const keep = { theme: s.theme, lang: s.lang, tray: s.tray, dock: !!s.dock, dockWindow: !!s.dockWindow, proxy: s.proxy || "",
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [] };
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "" };
$("#themeSegs").replaceChildren(segs(THEMES.map(([id, name]) => [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);
+5
View File
@@ -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": "不想让模型厂商看到的词,用逗号分隔",
+4
View File
@@ -132,6 +132,10 @@
<div class="who"><div class="name" data-t>Proxy</div><div class="sub" id="proxySub"></div></div>
<div id="proxySegs" class="proxy-segs"></div>
</div>
<div class="row pref">
<div class="who"><div class="name" data-t>Codex warm-up</div><div class="sub" id="warmSub"></div></div>
<div id="warmSegs"></div>
</div>
</div>
<div class="row-head"><span class="label" data-t>Local network</span></div>
<div class="list prefs" id="lanList"></div>
+358
View File
@@ -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)
}
}
+243
View File
@@ -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())
}
}
+13
View File
@@ -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
}
+9
View File
@@ -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) {