codex: each ChatGPT account can have its own daily warm-up time (#957) — two Plus accounts both warmed at the one codexWarmAt (07:00) started and ran out their 5-hour windows together. settings.codexWarmAtOf holds an account's own time ("HH:MM" or "off"), by its name in lower case; an account not in it follows codexWarmAt as before. warmNow picks each account's time, and the loop wakes for an account's own time even with the global one off. Settings › Usage › Warm-up, Codex: with two accounts or more, each has a row under Daily warm-up — Same (the time above), Own (a time field) or Off — saved through POST /api/settings/codex-warm-at, and the full Settings save keeps the map. zh/ja/de added.

TestWarmAtEachAccountsOwn, TestWarmLookAtAnAccountsOwnTime and TestSetCodexWarmAt pass with -race -count=20, and the first two fail when an account's own time is ignored. TestSettingsSaveKeepsWhatItDoesNotSend covers the new field and fails without the keep in api.go. warm-at-own (two accounts: rows, Own posts 06:00 then 09:00, Off, back to Same, the page doesn't move, nothing cut, no <select>; one account: no rows) passes in Chromium and WebKit, en/zh/ja/de, 900px and 440px, and fails 8 of 9 with main's app.js. i18n-keys, gui-ja, gui-de, settings-groups, settings-sections, click-scroll, reset-reminder, usage-alert and codex-auto-review pass in both engines; go test ./internal/provider ./internal/settings ./internal/gui and vet pass.
This commit is contained in:
yetone committed 2026-10-06 14:32:58 +08:00
1 parent da5f7a8115
commit 2cd25e2e88
8 files changed
+473 -4

No files matched your search

+31
View File
@@ -367,6 +367,9 @@ type settingsJSON struct {
CodexWarmed *time.Time `json:"codexWarmed,omitempty"`
// and the Claude warm-up
ClaudeWarmed *time.Time `json:"claudeWarmed,omitempty"`
// the ChatGPT accounts signed in, each of which can have a daily
// warm-up time of its own (#957)
CodexUsers []string `json:"codexUsers,omitempty"`
// whether a WorkBuddy (China) account is signed in, and each one's
// last daily check-in
WorkBuddy bool `json:"workbuddy"`
@@ -490,6 +493,7 @@ func settingsState() settingsJSON {
s.LANURLs, s.LANContainer = gateway.LANURLs(), gateway.ContainerAddrs()
}
s.CodexWarmed, s.ClaudeWarmed = latest(provider.CodexWarmed()), latest(provider.ClaudeWarmed())
s.CodexUsers = codexUsers()
s.WorkBuddy, s.WorkBuddyCheckins = provider.HasWorkBuddy(), provider.WorkBuddyCheckins()
s.Trae, s.TraeCheckins = provider.HasTrae(), provider.TraeCheckins()
s.MiniMax, s.MiniMaxCheckins = provider.HasMiniMax(), provider.MiniMaxCheckins()
@@ -984,6 +988,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
in.CodexAutoReset = cur.CodexAutoReset
// and which of them spend their credits, set there too
in.CodexNoCredits = cur.CodexNoCredits
// and each Codex account's own daily warm-up (codex-warm-at below)
in.CodexWarmAtOf = cur.CodexWarmAtOf
// and the text size, which the keyboard changes too (text-size below)
in.TextSize = cur.TextSize
// the version the Update pill was hidden for, set from the pill
@@ -1194,6 +1200,20 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
})
// whether a Codex account spends one of its resets by itself once its
// week is used up, the Usage card's toggle, set on its own
// a Codex account's own daily warm-up time: "06:00", "off", or "" to
// follow the one for all (#957)
mux.HandleFunc("POST /api/settings/codex-warm-at", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ User, At string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if err := provider.SetCodexWarmAt(in.User, in.At); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
mux.HandleFunc("POST /api/settings/codex-auto-reset", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
User string
@@ -1643,3 +1663,14 @@ func tilde(p string) string {
}
return p
}
// codexUsers is the ChatGPT accounts signed in, by name, each once.
func codexUsers() []string {
var out []string
for _, l := range provider.Logins("codex") {
if l.User != "" && !slices.ContainsFunc(out, func(u string) bool { return strings.EqualFold(u, l.User) }) {
out = append(out, l.User)
}
}
return out
}
+53
View File
@@ -16981,6 +16981,7 @@ function renderSettings() {
(v) => savePrefs({ ...keep, codexWarmAt: v }));
renderWarmAt($("#claudeWarmAtSegs"), $("#claudeWarmAtSub"), s.claudeWarmAt, s.claudeWarmup, t("Sent through Claude Code."),
(v) => savePrefs({ ...keep, claudeWarmAt: v }));
renderWarmAtOwn(s);
// WorkBuddy's daily check-in pressed for each account, its tab shown
// while one is signed in
$("#warmTab-wb").hidden = !s.workbuddy && !s.workbuddyCheckin;
@@ -17725,6 +17726,58 @@ function renderWarmAt(box, sub, at, onReset, via, save) {
box.append(segs([["off", t("Off")], ["on", t("On")]], at ? "on" : "off", (v) => save(v === "on" ? at || "06:00" : "")));
}
// renderWarmAtOwn gives each ChatGPT account its own row under Daily
// warm-up (#957, Evan26Ma): two accounts started at one time run out
// together, one at 06:00 and one at 09:00 take over from one another. An
// account follows the time above, has its own, or none (settings'
// codexWarmAtOf, set on its own); the rows show with two accounts or more,
// or while one has a time of its own.
function renderWarmAtOwn(s) {
const list = $("#codexWarmList");
list.querySelectorAll(".warm-own").forEach((r) => r.remove());
const own = s.codexWarmAtOf || {};
const users = [...(s.codexUsers || [])];
for (const u of Object.keys(own)) if (!users.some((x) => x.toLowerCase() === u)) users.push(u);
if (users.length < 2 && !Object.keys(own).length) return;
const save = async (user, at) => {
try {
prefs = await writingPrefs(api("settings/codex-warm-at", { user, at }));
if (state) state.settings = prefs;
renderSettings();
} catch (e) {
status(t(e.message), "err");
renderSettings();
}
};
for (const user of users) {
const at = own[user.toLowerCase()];
const mode = at === undefined ? "same" : at === "off" ? "off" : "own";
const r = el("div", "row pref warm-own");
r.dataset.user = user;
const who = el("div", "who");
const name = el("div", "name", user);
name.title = user;
const sub = el("div", "sub", mode === "own" ? t("Its own time each day")
: mode === "off" ? t("Not started at a time of day")
: s.codexWarmAt ? t("Same as above, {at}", { at: s.codexWarmAt }) : t("Same as above, off"));
sub.title = t("Give accounts different times and one window starts as another runs out.");
who.append(name, sub);
const box = el("div", "warm-at");
if (mode === "own") {
const i = input(at, "06:00", "time");
i.className = "at";
i.setAttribute("aria-label", t("{who}'s time of day", { who: user }));
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") i.blur(); };
i.onchange = () => { if (i.value && i.value !== at) save(user, i.value); };
box.append(i);
}
box.append(segs([["same", t("Same")], ["own", t("Own")], ["off", t("Off")]], mode, (v) =>
save(user, v === "same" ? "" : v === "off" ? "off" : s.codexWarmAt || "06:00")));
r.append(who, box);
list.append(r);
}
}
// renderAlerts draws the usage alerts (#368): a notification when a window
// that routing counts reaches the share set, or a balance falls to the
// amount set, each once; Off, or the number in a field beside On.
+24
View File
@@ -553,6 +553,14 @@ const I18N = {
"One tiny request starts the next window at once": "发一条极小的请求,让下一个窗口立即开始计时",
"One tiny request through Claude Code (Haiku) starts the next window at once": "通过 Claude Code(Haiku)发一条极小的请求,让下一个窗口立即开始计时",
"Starts each account's 5-hour window at this time every day": "每天到这个时间开启各账号的 5 小时窗口",
"Its own time each day": "每天按它自己的时间",
"Not started at a time of day": "不按时间定时预热",
"Same as above, {at}": "与上面相同,{at}",
"Same as above, off": "与上面相同,已关闭",
"Give accounts different times and one window starts as another runs out.": "给各账号设不同的时间,一个窗口用完时另一个刚好开启。",
"{who}'s time of day": "{who} 的预热时间",
"Same": "相同",
"Own": "单独",
"06:00 gives three by 21:00.": "设为 06:00,到 21:00 就能用上三个。",
"Sent through Claude Code.": "通过 Claude Code 发送。",
"One tiny request, sent only to an account whose 5-hour window isn't running then.": "只发一条极小的请求,且只发给那时 5 小时窗口没在计时的账号。",
@@ -4013,6 +4021,14 @@ const I18N = {
"One tiny request starts the next window at once": "ごく小さなリクエストを 1 件送り、次の期間をすぐに開始します",
"One tiny request through Claude Code (Haiku) starts the next window at once": "Claude Code(Haiku)経由でごく小さなリクエストを 1 件送り、次の期間をすぐに開始します",
"Starts each account's 5-hour window at this time every day": "毎日この時刻に各アカウントの 5 時間枠を開始します",
"Its own time each day": "毎日このアカウント独自の時刻",
"Not started at a time of day": "時刻指定では開始しません",
"Same as above, {at}": "上と同じ、{at}",
"Same as above, off": "上と同じ、オフ",
"Give accounts different times and one window starts as another runs out.": "アカウントごとに時刻をずらすと、一方の枠が尽きるころにもう一方の枠が始まります。",
"{who}'s time of day": "{who} の時刻",
"Same": "同じ",
"Own": "個別",
"06:00 gives three by 21:00.": "06:00 にすると、21:00 までに 3 回使えます。",
"Sent through Claude Code.": "Claude Code 経由で送信します。",
"One tiny request, sent only to an account whose 5-hour window isn't running then.": "ごく小さなリクエストを 1 件、その時点で 5 時間枠が動いていないアカウントにだけ送ります。",
@@ -7476,6 +7492,14 @@ const I18N = {
"One tiny request starts the next window at once": "Eine winzige Anfrage startet sofort das nächste Zeitfenster",
"One tiny request through Claude Code (Haiku) starts the next window at once": "Eine winzige Anfrage über Claude Code (Haiku) startet sofort das nächste Zeitfenster",
"Starts each account's 5-hour window at this time every day": "Startet das 5-Stunden-Zeitfenster jedes Kontos täglich zu dieser Uhrzeit",
"Its own time each day": "Täglich zu seiner eigenen Uhrzeit",
"Not started at a time of day": "Nicht zu einer Uhrzeit gestartet",
"Same as above, {at}": "Wie oben, {at}",
"Same as above, off": "Wie oben, aus",
"Give accounts different times and one window starts as another runs out.": "Mit verschiedenen Uhrzeiten beginnt ein Zeitfenster, wenn ein anderes aufgebraucht ist.",
"{who}'s time of day": "Uhrzeit für {who}",
"Same": "Gleich",
"Own": "Eigene",
"06:00 gives three by 21:00.": "06:00 ergibt drei bis 21:00.",
"Sent through Claude Code.": "Über Claude Code gesendet.",
"One tiny request, sent only to an account whose 5-hour window isn't running then.": "Eine winzige Anfrage, nur an Konten, deren 5-Stunden-Zeitfenster dann nicht läuft.",
+1
View File
@@ -142,6 +142,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
ChinaMirror: true,
CodexAutoReset: []string{"me@example.com"},
CodexNoCredits: []string{"me@example.com"},
CodexWarmAtOf: map[string]string{"me@example.com": "09:00"},
TextSize: 125,
UpdateSkip: "0.1.500",
UpdateMirror: "https://mirror.example/",
+138
View File
@@ -0,0 +1,138 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #957 (Evan26Ma): two Plus accounts started by Daily warm-up at one time
// run out together; each can have its own time, so one takes over as the
// other's window ends. Under Daily warm-up each ChatGPT account has a row:
// Same (the time above), Own (a time field) or Off, each set on its own
// (POST /api/settings/codex-warm-at {user, at}: "" for Same). The rows show
// with two accounts or more, or while one has its own time; a click never
// moves the page; nothing runs off the side at 440px. en, zh, ja, de; the
// API is faked here.
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");
function serve(lang, asked, users) {
const settings = { theme: "light", lang, currency: "usd", codexWarmup: "", codexWarmAt: "06:00", codexUsers: users };
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({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/settings/codex-warm-at") {
const { user, at } = route.request().postDataJSON();
asked.push([user, at]);
const of = { ...(settings.codexWarmAtOf || {}) };
delete of[user.toLowerCase()];
if (at) of[user.toLowerCase()] = at;
settings.codexWarmAtOf = Object.keys(of).length ? of : undefined;
return json(settings);
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
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)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const W = {
en: { same: "Same as above, 06:00", own: "Its own time each day", off: "Not started at a time of day", segs: ["Same", "Own", "Off"] },
zh: { same: "与上面相同,06:00", own: "每天按它自己的时间", off: "不按时间定时预热", segs: ["相同", "单独", "关闭"] },
ja: { same: "上と同じ、06:00", own: "毎日このアカウント独自の時刻", off: "時刻指定では開始しません", segs: ["同じ", "個別", "オフ"] },
de: { same: "Wie oben, 06:00", own: "Täglich zu seiner eigenen Uhrzeit", off: "Nicht zu einer Uhrzeit gestartet", segs: ["Gleich", "Eigene", "Aus"] },
};
const scrolls = (page) => page.evaluate(() => [scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join(","));
const sideways = (page) => page.evaluate(() => {
const out = [];
const de = document.documentElement, v = document.querySelector("#view-settings");
if (de.scrollWidth > de.clientWidth) out.push("page");
if (v.scrollWidth > v.clientWidth) out.push("view");
for (const r of document.querySelectorAll(".warm-own")) {
const rr = r.getBoundingClientRect();
for (const e of r.querySelectorAll(".name, .sub, .segs, input")) {
const b = e.getBoundingClientRect();
if (b.right > rr.right + 0.5 || b.left < rr.left - 0.5) out.push(r.dataset.user + " " + e.className);
if (e.classList.contains("name") && b.width < 40) out.push(r.dataset.user + " name squeezed to " + b.width);
}
}
return out;
});
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh", "ja", "de"]) {
const w = W[lang];
for (const width of [900, 440]) {
test(`${engine} ${lang} ${width}px: each Codex account has its own daily warm-up time`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width, height: 800 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], asked = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, asked, ["a@example.com", "B@Example.com"]));
await page.goto("http://magpie.test/?view=settings&tab=usage");
const rows = page.locator("#codexWarmList .warm-own");
await rows.first().waitFor();
assert.deepEqual(await rows.evaluateAll((rs) => rs.map((r) => r.dataset.user)), ["a@example.com", "B@Example.com"]);
assert.deepEqual((await rows.locator(".sub").allInnerTexts()).map((s) => s.trim()), [w.same, w.same]);
assert.deepEqual((await rows.first().locator(".segs .opt").allInnerTexts()).map((s) => s.trim()), w.segs);
assert.equal(await rows.locator("input").count(), 0, "no time field while it follows the one above");
assert.deepEqual(await sideways(page), []);
// Own: the time above to start from, then its own
// the reader scrolls down to it (the view keeps the reader's place, not a script's)
await page.mouse.move(220, 400);
for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, 400); await page.waitForTimeout(20); }
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `warm-at-own-${engine}-${lang}-${width}.png`) });
const y = await scrolls(page);
await rows.first().locator(".segs .opt").nth(1).click();
await page.locator('.warm-own[data-user="a@example.com"] input').waitFor();
assert.deepEqual(asked, [["a@example.com", "06:00"]]);
const a = page.locator('.warm-own[data-user="a@example.com"]');
assert.equal((await a.locator(".sub").innerText()).trim(), w.own);
await a.locator("input").fill("09:00");
await a.locator("input").press("Enter");
for (let i = 0; i < 50 && asked.length < 2; i++) await page.waitForTimeout(40);
assert.deepEqual(asked[1], ["a@example.com", "09:00"]);
// B off, by its name as it is; then back to the time above
const b = page.locator('.warm-own[data-user="B@Example.com"]');
await b.locator(".segs .opt").nth(2).click();
for (let i = 0; i < 50 && asked.length < 3; i++) await page.waitForTimeout(40);
assert.deepEqual(asked[2], ["B@Example.com", "off"]);
await page.waitForFunction((s) => document.querySelector('.warm-own[data-user="B@Example.com"] .sub')?.textContent === s, w.off);
await b.locator(".segs .opt").nth(0).click();
for (let i = 0; i < 50 && asked.length < 4; i++) await page.waitForTimeout(40);
assert.deepEqual(asked[3], ["B@Example.com", ""]);
await page.waitForFunction((s) => document.querySelector('.warm-own[data-user="B@Example.com"] .sub')?.textContent === s, w.same);
assert.equal(await page.locator('.warm-own[data-user="a@example.com"] input').inputValue(), "09:00");
assert.equal(await scrolls(page), y, "a click moved the page");
assert.deepEqual(await sideways(page), []);
assert.equal(await page.locator("select").count(), 0);
assert.deepEqual(errors, []);
});
}
}
test(`${engine}: one account has no rows of its own`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve("en", [], ["a@example.com"]));
await page.goto("http://magpie.test/?view=settings&tab=usage");
await page.locator("#warmAtSegs .opt").first().waitFor();
assert.equal(await page.locator(".warm-own").count(), 0);
assert.deepEqual(errors, []);
});
}
+86 -4
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"io"
"log"
"maps"
"net/http"
"os"
"path/filepath"
@@ -104,15 +105,38 @@ type codexWarmer struct {
// warmed, when set, is told of an account a request went to, so what
// it has left is read again rather than taken from before.
warmed func(user string)
// ownAt, when set, is the accounts that have a time of day of their
// own, by name in lower case, "" for none: theirs in place of the one
// warmNow is given (#957).
ownAt func() map[string]string
}
// atOf is the time of day user's 5-hour window starts at: its own, else
// at.
func (c codexWarmer) atOf(own map[string]string, user, at string) string {
if a, ok := own[strings.ToLower(user)]; ok {
return a
}
return at
}
// owned is c's accounts' own times of day, none when it has no ownAt.
func (c codexWarmer) owned() map[string]string {
if c.ownAt == nil {
return nil
}
return c.ownAt()
}
// 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 — or whose 5-hour window isn't running at the time
// of day at ("06:00", "" none) — and keeps what it saw.
func (c codexWarmer) warmNow(ctx context.Context, which, at string) []CodexWarm {
// of day at ("06:00", "" none), or the account's own (ownAt) — and keeps
// what it saw.
func (c codexWarmer) warmNow(ctx context.Context, which, dayAt string) []CodexWarm {
st := readWarmState(c.path)
now := c.now()
own := c.owned()
usage := c.usage(ctx)
users := make([]string, 0, len(usage))
for u := range usage {
@@ -127,6 +151,7 @@ func (c codexWarmer) warmNow(ctx context.Context, which, at string) []CodexWarm
continue // nothing known: what was seen stands
}
key := strings.ToLower(user)
at := c.atOf(own, user, dayAt)
prev, next := st[key], map[string]warmWindow{}
var due []string
onReset, days := map[string]bool{}, map[string]string{}
@@ -447,12 +472,53 @@ func codexWarmedIn(path string) map[string]time.Time {
return out
}
// codexWarmAtOf is the ChatGPT accounts' own times of day (settings'
// CodexWarmAtOf), "" for one that has none.
func codexWarmAtOf() map[string]string {
of := settings.Load().CodexWarmAtOf
out := make(map[string]string, len(of))
for user, at := range of {
if at == "off" {
at = ""
}
out[user] = at
}
return out
}
// CodexWarmAtOf is user's own time of day to start its 5-hour window at,
// "off" for none; false when it follows settings' CodexWarmAt.
func CodexWarmAtOf(user string) (string, bool) {
at, ok := settings.Load().CodexWarmAtOf[strings.ToLower(strings.TrimSpace(user))]
return at, ok
}
// SetCodexWarmAt gives user a time of day of its own ("06:00"), none
// ("off"), or, "", has it follow settings' CodexWarmAt again.
func SetCodexWarmAt(user, at string) error {
user = strings.ToLower(strings.TrimSpace(user))
if user == "" {
return fmt.Errorf("which Codex account?")
}
s := settings.Load()
of := maps.Clone(s.CodexWarmAtOf)
if of == nil {
of = map[string]string{}
}
delete(of, user)
if at = strings.TrimSpace(at); at != "" {
of[user] = at
}
s.CodexWarmAtOf = of
return settings.Save(s)
}
// 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,
warmed: func(user string) { StaleAllowance("codex", user) }}
warmed: func(user string) { StaleAllowance("codex", user) }, ownAt: codexWarmAtOf}
keepWarm(ctx, "codex", w, func() (string, string) { s := settings.Load(); return s.CodexWarmup, s.CodexWarmAt })
}
@@ -492,7 +558,7 @@ func keepWarm(ctx context.Context, name string, w codexWarmer, prefs func() (whi
}
// the wall clock, which goes on while the machine sleeps
now := w.now().Round(0)
if which, at := prefs(); (which != "" || at != "") && (now1 || last.IsZero() || now.Sub(last) >= codexWarmEvery || dayStartPassed(at, last, now)) {
if which, at := prefs(); warmLook(which, at, w.owned(), now1, last, now) {
last = now
c, cancel := context.WithTimeout(ctx, 2*time.Minute)
for _, r := range w.warmNow(c, which, at) {
@@ -514,6 +580,22 @@ func keepWarm(ctx context.Context, name string, w codexWarmer, prefs func() (whi
}
}
// warmLook says whether keepWarm looks at the windows now: a warm-up is
// on — on reset (which), or at a time of day, the one for all (at) or an
// account's own — and a read kicked it (kicked), it hasn't looked yet, it
// last looked codexWarmEvery ago, or one of those times came since.
func warmLook(which, at string, own map[string]string, kicked bool, last, now time.Time) bool {
ats := []string{at}
for _, a := range own {
ats = append(ats, a)
}
if which == "" && !slices.ContainsFunc(ats, func(a string) bool { return a != "" }) {
return false
}
return kicked || last.IsZero() || now.Sub(last) >= codexWarmEvery ||
slices.ContainsFunc(ats, func(a string) bool { return dayStartPassed(a, last, now) })
}
// notStartedHooks is, by agent, what a usage read finding an account's
// window not started tells, and the windows each account has.
var notStartedHooks struct {
+112
View File
@@ -0,0 +1,112 @@
package provider
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/settings"
)
// #957 (Evan26Ma): two Plus accounts started at one daily time run out
// together; each can have its own, so one takes over as the other's
// window ends. An account with none follows the time for all, and one set
// off is never started at a time of day.
func TestWarmAtEachAccountsOwn(t *testing.T) {
path := filepath.Join(t.TempDir(), "codex-warmup.json")
f := &fakeWarm{now: at8(28, 6, 0), errs: map[string]error{}}
idleAll := func() {
for _, u := range []string{"a@example.com", "b@example.com", "c@example.com"} {
f.ws[u] = []QuotaWindow{win("5 hours", fiveHours, 0, f.now.Add(fiveHours))}
}
}
f.ws = map[string][]QuotaWindow{}
idleAll()
own := map[string]string{"a@example.com": "09:00", "c@example.com": ""}
run := func(at string) []string {
t.Helper()
f.sent = nil
w := f.warmer(path)
w.ownAt = func() map[string]string { return own }
w.warmNow(context.Background(), "", at)
return f.sent
}
// six: b, which follows the time for all; a waits for its nine, c for none
if got := strings.Join(run("06:00"), ","); got != "b@example.com" {
t.Fatalf("at six sent %q", got)
}
// nine: a, as its own name is kept in any case
f.now = at8(28, 9, 0)
idleAll()
own = map[string]string{"a@example.com": "09:00", "c@example.com": ""}
f.ws["A@Example.com"] = f.ws["a@example.com"]
delete(f.ws, "a@example.com")
if got := strings.Join(run("06:00"), ","); got != "A@Example.com" {
t.Fatalf("at nine sent %q", got)
}
// the time for all off: an account's own still starts it, the rest none
f.now = at8(29, 9, 0)
idleAll()
delete(f.ws, "a@example.com")
if got := strings.Join(run(""), ","); got != "A@Example.com" {
t.Fatalf("the next day, the time for all off, sent %q", got)
}
}
// keepWarm looks at the windows when an account's own time comes, even
// with nothing else on, and never with nothing on at all.
func TestWarmLookAtAnAccountsOwnTime(t *testing.T) {
last, now := at8(28, 8, 59), at8(28, 9, 0)
if !warmLook("", "", map[string]string{"a@x": "09:00"}, false, last, now) {
t.Error("an account's own nine not looked at")
}
if warmLook("", "", map[string]string{"a@x": "10:00"}, false, last, now) {
t.Error("looked at before its time")
}
if warmLook("", "", map[string]string{"a@x": ""}, true, time.Time{}, now) {
t.Error("looked at with nothing on")
}
if !warmLook("", "06:00", nil, false, time.Time{}, now) || !warmLook("week", "", nil, false, now.Add(-codexWarmEvery), now) {
t.Error("the time for all, or the warm-up on reset, not looked at")
}
}
// An account's own time is kept by its name in lower case, as 06:00
// whichever way it came; "" follows the time for all again; one that isn't
// a time is refused.
func TestSetCodexWarmAt(t *testing.T) {
azureHome(t)
if err := SetCodexWarmAt("A@Example.com ", "9:00"); err != nil {
t.Fatal(err)
}
if err := SetCodexWarmAt("b@example.com", "OFF"); err != nil {
t.Fatal(err)
}
if at, ok := CodexWarmAtOf("a@example.com"); !ok || at != "09:00" {
t.Fatalf("a: %q %v", at, ok)
}
got := codexWarmAtOf()
if len(got) != 2 || got["a@example.com"] != "09:00" || got["b@example.com"] != "" {
t.Fatalf("own times %v", got)
}
if err := SetCodexWarmAt("a@example.com", "nine"); err == nil {
t.Fatal("nine taken")
}
if err := SetCodexWarmAt("a@example.com", ""); err != nil {
t.Fatal(err)
}
if _, ok := CodexWarmAtOf("a@example.com"); ok {
t.Fatal("still its own")
}
if err := SetCodexWarmAt("b@example.com", ""); err != nil {
t.Fatal(err)
}
if s := settings.Load(); s.CodexWarmAtOf != nil {
t.Fatalf("kept %v", s.CodexWarmAtOf)
}
if err := SetCodexWarmAt(" ", "06:00"); err == nil {
t.Fatal("no account taken")
}
}
+28
View File
@@ -112,6 +112,11 @@ type Settings struct {
// with CodexWarmup or without it. ClaudeWarmAt is the Claude accounts'.
CodexWarmAt string `json:"codexWarmAt,omitempty"`
ClaudeWarmAt string `json:"claudeWarmAt,omitempty"`
// CodexWarmAtOf is a ChatGPT account's own time of day for that, by
// its name in lower case, "off" for none: two accounts started hours
// apart take over from one another, where at one time they run out
// together (#957). An account not in it has CodexWarmAt.
CodexWarmAtOf map[string]string `json:"codexWarmAtOf,omitempty"`
// CodexAutoReset are the ChatGPT accounts (lower-case) that spend one
// of their rate-limit resets by themselves once their weekly window is
// used up and no other account can take the request: at most one a
@@ -826,6 +831,11 @@ func Save(s Settings) error {
return fmt.Errorf("a warm-up's time of day must look like 06:00, not %q", at)
}
}
for user, at := range s.CodexWarmAtOf {
if _, _, ok := Clock(at); at != "off" && !ok {
return fmt.Errorf("%s's warm-up time of day must look like 06:00 or be off, not %q", user, at)
}
}
if !slices.Contains(TrayEvery, s.TrayUsageEvery) {
return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery)
}
@@ -988,6 +998,24 @@ func (s Settings) normal() Settings {
*at = fmt.Sprintf("%02d:%02d", h, m)
}
}
// an account's own, by its name in lower case; one with none follows
// CodexWarmAt and isn't kept
if s.CodexWarmAtOf != nil {
of := map[string]string{}
for user, at := range s.CodexWarmAtOf {
user, at = strings.ToLower(strings.TrimSpace(user)), strings.ToLower(strings.TrimSpace(at))
if h, m, ok := Clock(at); ok {
at = fmt.Sprintf("%02d:%02d", h, m)
}
if user != "" && at != "" {
of[user] = at
}
}
s.CodexWarmAtOf = of
if len(of) == 0 {
s.CodexWarmAtOf = nil
}
}
return s
}