diff --git a/internal/gui/api.go b/internal/gui/api.go index 227197e6..e67ae6d1 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -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 +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1aaf4e32..f40df368 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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. diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 870e44b2..01c27274 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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.", diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 9f21b3bc..4f7d5f55 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -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/", diff --git a/internal/gui/tests/warm-at-own.test.cjs b/internal/gui/tests/warm-at-own.test.cjs new file mode 100644 index 00000000..41c4d6e6 --- /dev/null +++ b/internal/gui/tests/warm-at-own.test.cjs @@ -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, []); + }); +} diff --git a/internal/provider/codex_warmup.go b/internal/provider/codex_warmup.go index 43a0faf8..ff3031c4 100644 --- a/internal/provider/codex_warmup.go +++ b/internal/provider/codex_warmup.go @@ -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 { diff --git a/internal/provider/warmup_own_test.go b/internal/provider/warmup_own_test.go new file mode 100644 index 00000000..7d2233ed --- /dev/null +++ b/internal/provider/warmup_own_test.go @@ -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") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 91835e41..ce69987e 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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 }