Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/window-warmup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ magpie plugin add @magpie-community/page-window-warmup

新包在 npm 上还没有第一个版本时,magpie 从社区仓库的 `dist` 分支安装并在之后自动更新,功能不受影响;npm 上有版本之后按常规方式安装即可。

## 更新说明

- **0.1.4**:修复 Antigravity 等窗口重置后不自动发送的问题(这类卡在窗口空闲时不清空重置点,详见「三种触发」);服务口令持久化,Magpie 重启插件后已打开的页面不再报「口令不对」;新建任务在列表底部展开。
- **0.1.3**:编辑区在所编辑任务的下方展开;页脚链接不再改动编辑区按钮的样式。
- **0.1.2**:模型缓存只存分档变体的供应商(如 Antigravity)也能选到模型;「反馈问题」附带匿名诊断日志。
- **0.1.1**:被路由组隐藏的模型照常发送,不再报「已不在模型列表」。
- **0.1.0**:首个版本,支持窗口重置后、每天定时、固定间隔三种触发。

## 三种触发

| 触发 | 到期点 | 去重 |
Expand All @@ -30,6 +38,10 @@ magpie plugin add @magpie-community/page-window-warmup

所有任务都必须选定账号(预热发给具体账号或 key;交给网关自动选,就没有稳定的去重与窗口归属)。调度器只保留一个定时器,最长睡 10 分钟就醒来重算,系统睡眠、改系统时间都能自愈。额度快照约每 10 分钟及每次发送后刷新。magpie 的额度缓存只在它路由请求那一刻读卡(没有托盘的 `magpie web` 平时不读),所以窗口任务快到期要发送时,以及发送满 1 分钟后,插件会让 magpie 重读这一张订阅卡(`POST /v1/magpie/quotas/refresh`,同用量页单卡「刷新」,只查余额);卡片读取时刻(`readAt`)早于上次发送的空闲读数一律不信,宁可晚发也不重发。

有的供应商(如 Antigravity)在窗口空闲时不清空重置点,而是报「读卡时刻 + 窗口长度」,每读一次往后推一次。插件不按供应商区分,而是看读数本身:新出现的未来重置点满 1 分钟后再读一次卡,如果重置点在到来之前往后挪了、挪动量与两次读卡间隔相差不超过 5 秒、且用量为 0,就按空闲处理(同样按小时计 key);同一个重置点隔 1 分钟以上重读仍不变,就确认是真在计时。所以这类窗口会比重置点晚 1~3 分钟发出。

还有一种报法:旧窗口到期后,卡上直接出现下一轮的重置点(如 Antigravity 报「旧重置点 + 5 小时」)并保持不动,看着和真在计时一样,其实没人开窗。插件的判定是:读到新重置点时旧重置点已经过去、插件在旧重置点之后没发过、且用量为 0,就当空闲立即发,去重 key 取那个已过去的旧重置点,所以每轮最多发一次。发送后厂商若真开了窗,重置点会变成「发送时刻 + 窗口长度」;若厂商其实已自动开了下一窗、重置点不变,也不会再发。

## 模型与思考档位

模型选「自动」时,任务里不存选中值,每次发送前按统一账号规则现选(目录与调度器同一份实现):
Expand Down Expand Up @@ -173,6 +185,7 @@ Magpie 内部用「候选 ID」区分同一供应商下的多把 key 和多个
| key 与账号对应关系的接口 | sha256 指纹三方对齐(key 目录、缓存 `Keys`、发送头) | Magpie 更改 keyID 算法时三处同时失配,key 账号集体失效 |
| 起名 key 之外的额度卡匹配方式 | 指纹反查 key 名再按卡名匹配 | 没起名的 key 匹配不上(卡名是打码形态),账号没有窗口,窗口任务会一直报「账号的额度里没有这个窗口」 |
| 订阅供应商的稳定 id | 额度卡上的供应商名按订阅 agent 名对 | 在 Magpie 里改过名的供应商,目录里的账号与模型可能匹配不上 |
| 额度卡对「窗口空闲」的统一表示(有的卡报 `resetsAt: null`,有的报已过去的时间,Antigravity 报「读卡时刻 + 窗口长度」) | 不按供应商区分,看重置点是否随读卡同步后挪,或是旧重置点过后直接出现的下一轮重置点,且用量为 0(0.1.4 起) | 0.1.3 及以前,Antigravity 的窗口重置后一直不自动发送,只能手动发;判定要多读一次卡,发送比重置点晚 1~3 分钟;旧版 Magpie 不给 `readAt` 时无法判定,仍是旧表现 |
| 发送侧「模型不存在」的可判别错误 | 只能按 HTTP 错误原文记录 | 「已下线」和「该账号无权使用」在执行记录里无法区分 |

**计费与窗口**
Expand All @@ -187,6 +200,7 @@ Magpie 内部用「候选 ID」区分同一供应商下的多把 key 和多个

- **供应商没有额度卡**(常见于插件型订阅供应商):看不到窗口,只能用每天定时或固定间隔。
- **额度卡瞬断或 Magpie 没在运行**:插件静默等下一轮刷新,可能错过一次重置;Magpie 关闭期间不执行,重开后每个任务最多补发一次。
- **空闲窗口的报法各家不同**:插件靠读数本身判断窗口是否空闲(见「三种触发」)。如果某家的卡在窗口真在计时时,重置点也随读卡往后推、用量又显示 0,插件会把它当成空闲,每小时最多多发一次。旧重置点过后直接出现下一轮重置点、用量又显示 0 时,插件也当空闲发一次:若那其实是自动续上的固定周期窗口,或有人在别处用过但用量读作 0,每轮会多发一条 `hi`。
- **额度读数的时效**:依赖 Magpie 的缓存与单卡重读限频(30 秒),拿不到新读数时按快照时刻判断,发送可能提前或推迟。

**模型与账号权限**
Expand Down
83 changes: 75 additions & 8 deletions packages/window-warmup/dist/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -960,10 +960,10 @@ async function send(base, task, pin, effort, signal, timeoutMs = SEND_TIMEOUT_MS

// src/server.ts
import { randomBytes, timingSafeEqual } from "crypto";
import { readFile as readFile3 } from "fs/promises";
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
import path2, { basename, extname, join as join3, resolve as resolve2 } from "path";
// package.json
var version = "0.1.3";
var version = "0.1.4";

// src/diagnostics.ts
var DIAGNOSTICS_SCHEMA = "window-warmup-diagnostics/1";
Expand Down Expand Up @@ -1161,6 +1161,8 @@ var QUOTA_REFRESH_MS = 600000;
var POST_SEND_SETTLE_MS = 60000;
var CARD_FRESH_MS = 60000;
var CARD_REFRESH_GAP_MS = 60000;
var ROLL_GAP_MS = 60000;
var ROLL_TOLERANCE_MS = 5000;
var HOUR_MS2 = 3600000;
var DAY_MS = 24 * HOUR_MS2;
var INTERRUPTED_REASON = "Magpie \u5728\u53D1\u9001\u4E2D\u9014\u9000\u51FA\uFF0C\u7ED3\u679C\u672A\u77E5";
Expand Down Expand Up @@ -1222,6 +1224,7 @@ class Scheduler {
saveError = null;
lastErrKey = new Map;
lastCardRefresh = new Map;
probedFor = new Map;
constructor(deps) {
this.store = deps.store;
this.base = deps.base;
Expand Down Expand Up @@ -1314,6 +1317,7 @@ class Scheduler {
this.store.data.runs = this.store.data.runs.filter((r) => r.taskId !== id);
delete this.store.data.marks[id];
this.lastErrKey.delete(id);
this.probedFor.delete(id);
await this.store.save();
this.rearm();
});
Expand Down Expand Up @@ -1533,12 +1537,27 @@ class Scheduler {
for (const task of this.store.data.tasks) {
if (!task.enabled)
continue;
const raw = this.quotaWindow(task)?.resetsAt;
if (!raw || Number.isNaN(new Date(raw).getTime()))
const win = this.quotaWindow(task);
const raw = win?.resetsAt;
if (!win || !raw || Number.isNaN(new Date(raw).getTime()))
continue;
const readAt = this.loginCard(task)?.readAt ?? null;
const mark = this.marksOf(task.id);
if (mark.lastReset !== raw) {
const prevReset = Date.parse(mark.lastReset ?? "");
const read = Date.parse(readAt ?? "");
const dRead = read - Date.parse(mark.lastResetReadAt ?? "");
mark.resetRolls = prevReset > read && dRead >= ROLL_GAP_MS && Math.abs(Date.parse(raw) - prevReset - dRead) <= ROLL_TOLERANCE_MS;
const sent = this.lastSentMs(task);
mark.resetCarried = prevReset <= read && !(sent >= prevReset) ? mark.lastReset : null;
mark.lastReset = raw;
mark.lastResetReadAt = readAt;
dirty = true;
} else if (!mark.lastResetReadAt && readAt) {
mark.lastResetReadAt = readAt;
dirty = true;
} else if (mark.resetRolls && Date.parse(readAt ?? "") - Date.parse(mark.lastResetReadAt ?? "") >= ROLL_GAP_MS) {
mark.resetRolls = false;
dirty = true;
}
}
Expand All @@ -1552,13 +1571,21 @@ class Scheduler {
const due = windowDue(win, this.quota.at);
if ("error" in due)
return null;
const mark = this.marksOf(task.id);
if (mark.resetRolls && win.resetsAt === mark.lastReset && win.used === 0) {
if (this.staleIdle(task))
return null;
return { at: new Date(this.quota.at.getTime()), resetKey: `idle:${hourFloorISO(this.quota.at)}` };
}
if (mark.resetCarried && due.resetKey === mark.lastReset && win.used === 0) {
return { at: new Date(this.quota.at.getTime()), resetKey: `after:${mark.resetCarried}` };
}
if (due.resetKey.startsWith("idle:") && this.staleIdle(task))
return null;
const raw = win.resetsAt;
if (raw && !Number.isNaN(new Date(raw).getTime())) {
return { at: due.at, resetKey: raw };
}
const mark = this.marksOf(task.id);
const isFuture = (iso) => !!iso && new Date(iso).getTime() > now.getTime();
if (isFuture(mark.lastReset) || mark.resetKeys.some(isFuture))
return null;
Expand All @@ -1576,6 +1603,24 @@ class Scheduler {
const sent = this.lastSentMs(task);
return Number.isFinite(readAt) && Number.isFinite(sent) && readAt < sent + POST_SEND_SETTLE_MS;
}
rollProbeAt(task, now) {
const mark = this.store.data.marks[task.id];
const card = this.loginCard(task);
const win = this.quotaWindow(task);
if (!mark || !card || !win?.resetsAt || win.used !== 0)
return null;
if (mark.resetRolls || win.resetsAt !== mark.lastReset)
return null;
if (this.probedFor.get(task.id) === win.resetsAt)
return null;
if (!(Date.parse(win.resetsAt) > now.getTime()))
return null;
const first = Date.parse(mark.lastResetReadAt ?? "");
const read = Date.parse(card.readAt ?? "");
if (!Number.isFinite(first) || !Number.isFinite(read) || read - first >= ROLL_GAP_MS)
return null;
return first + ROLL_GAP_MS;
}
async readCards(now) {
let asked = false;
for (const task of this.store.data.tasks) {
Expand All @@ -1590,13 +1635,17 @@ class Scheduler {
const due = this.currentWindowDue(task, now);
const readFresh = now.getTime() - readAt <= CARD_FRESH_MS;
const dueNow = due !== null && this.windowReady(task, due, now) && !readFresh;
if (!afterSend && !dueNow)
const probeAt = this.rollProbeAt(task, now);
const probe = probeAt !== null && now.getTime() >= probeAt;
if (!afterSend && !dueNow && !probe)
continue;
const key = `${card.provider}|${card.account}`;
const last = this.lastCardRefresh.get(key);
if (last !== undefined && now.getTime() - last < CARD_REFRESH_GAP_MS)
continue;
this.lastCardRefresh.set(key, now.getTime());
if (probe)
this.probedFor.set(task.id, this.quotaWindow(task).resetsAt);
asked = true;
await this.refreshCardFn(this.base, card.provider, card.account).catch(() => {});
}
Expand Down Expand Up @@ -1766,6 +1815,9 @@ class Scheduler {
const settled = this.lastSentMs(task) + POST_SEND_SETTLE_MS;
if (settled > now.getTime())
minDelay = Math.min(minDelay, settled - now.getTime());
const probeAt = this.rollProbeAt(task, now);
if (probeAt !== null && probeAt > now.getTime())
minDelay = Math.min(minDelay, probeAt - now.getTime());
const due = this.currentWindowDue(task, now);
if (due && due.at.getTime() > now.getTime()) {
minDelay = Math.min(minDelay, due.at.getTime() - now.getTime());
Expand Down Expand Up @@ -2092,9 +2144,24 @@ async function acquireLock(dir, opts = {}) {

// src/server.ts
var TOKEN_HEADER = "x-window-warmup-token";
var TOKEN_FILE = "token";
var MARKER_HEADER = "x-window-warmup";
var PROBE_TIMEOUT_MS = 2000;
var BODY_LIMIT = 64 * 1024;
async function loadOrCreateToken(dir) {
const file = join3(dir, TOKEN_FILE);
try {
const text = (await readFile3(file, "utf8")).trim();
if (/^[0-9a-f]{64}$/.test(text))
return text;
} catch {}
const generated = randomBytes(32).toString("hex");
try {
await mkdir2(dir, { recursive: true });
await writeFile2(file, generated, { encoding: "utf8", mode: 384 });
} catch {}
return generated;
}
async function alreadyServing(port) {
try {
const res = await fetch(`http://127.0.0.1:${port}/`, {
Expand Down Expand Up @@ -2169,7 +2236,7 @@ async function startService(opts) {
else
lockedBy = lock.pid;
}
const token = randomBytes(32).toString("hex");
const token = await loadOrCreateToken(opts.dir);
let stopped = false;
let server;
try {
Expand Down Expand Up @@ -2381,7 +2448,7 @@ async function startService(opts) {
return new Response(null, { status: 204, headers: corsHeaders(origin) });
if (url.pathname.startsWith("/api/")) {
if (!tokenOK(req.headers.get(TOKEN_HEADER))) {
return json({ error: "\u53E3\u4EE4\u4E0D\u5BF9\uFF1A\u8BF7\u91CD\u65B0\u6253\u5F00\u7A97\u53E3\u9884\u70ED\u9875" }, 401, corsHeaders(origin));
return json({ error: "\u53E3\u4EE4\u4E0D\u5BF9\uFF1A\u8BF7\u91CD\u65B0\u6253\u5F00\u7A97\u53E3\u9884\u70ED\u9875", reload: true }, 401, corsHeaders(origin));
}
return api(req, url, origin);
}
Expand Down
32 changes: 30 additions & 2 deletions packages/window-warmup/dist/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,23 @@ var formError = null;
var confirmDeleteAt = 0;
var runsExpanded = false;
var catalogTimer;
var REAUTH_PARAM = "ww-reauth";
function reauthURL(href, on) {
const url = new URL(href);
if (on)
url.searchParams.set(REAUTH_PARAM, "1");
else
url.searchParams.delete(REAUTH_PARAM);
return url.toString();
}
function shouldReloadOnAuthError(status, data, isDirty, alreadyReloaded) {
if (alreadyReloaded)
return false;
if (status !== 401 && status !== 403)
return false;
const isTokenErr = typeof data === "object" && data !== null && data.reload === true || typeof data === "object" && data !== null && String(data.error).includes("口令");
return !!(isTokenErr && !isDirty);
}
async function apiJSON(path, init = {}) {
const res = await fetch(path, {
...init,
Expand All @@ -966,7 +983,16 @@ async function apiJSON(path, init = {}) {
}
});
const body = await res.json().catch(() => ({}));
const reloaded = new URL(location.href).searchParams.has(REAUTH_PARAM);
if (res.ok && reloaded) {
try {
history.replaceState(history.state, "", reauthURL(location.href, false));
} catch {}
}
if (!res.ok) {
if (shouldReloadOnAuthError(res.status, body, dirty, reloaded)) {
location.replace(reauthURL(location.href, true));
}
const msg = body.error;
throw new Error(typeof msg === "string" && msg ? msg : `HTTP ${res.status}`);
}
Expand Down Expand Up @@ -1717,14 +1743,14 @@ function renderList() {
nodes.push(e);
return nodes;
}
if (isNew && draft)
nodes.push(rowEl(draft, null, true), buildEditor());
for (const t of tasks) {
const open = openId === t.id;
nodes.push(rowEl(open && draft ? draft : t, t, open));
if (open && draft)
nodes.push(buildEditor());
}
if (isNew && draft)
nodes.push(rowEl(draft, null, true), buildEditor());
return nodes;
});
restoreFocus(list, focus);
Expand Down Expand Up @@ -2070,13 +2096,15 @@ export {
nextLabel,
nextPointsAfter,
normalizeLang,
reauthURL,
rowSub,
runOutcome,
runReason,
runTimeLabel,
setLang,
shortModel,
shouldCloseOnScroll,
shouldReloadOnAuthError,
statusOf,
sumView,
syncDraftOnPoll,
Expand Down
2 changes: 1 addition & 1 deletion packages/window-warmup/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magpie-community/page-window-warmup",
"version": "0.1.3",
"version": "0.1.4",
"description": "窗口预热 Window Warmup:定时消息插件,定时给指定供应商/账号/模型发一条短消息,在额度窗口重置后、每天定时或每隔几小时自动预热保活,提前开启5小时/每周订阅额度(配额)窗口,适用 Codex、Claude、GLM 智谱、ZCode 等。Scheduled keep-alive message to warm up quota / usage / rate limit windows after reset (cron-like).",
"author": "YangHonghang (https://github.com/YangHonghang)",
"type": "module",
Expand Down
Loading
Loading