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:
1 parent
da5f7a8115
commit
2cd25e2e88
8 files changed
+473
-4
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user