quota-keepalive 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 quota-keepalive contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,199 @@
1
- # Temporary Holding Version
1
+ # quota-keepalive
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 面向 Magpie 全部已登录供应商的额度签到、真实对话保活、执行留痕和额度趋势页面。
4
+ 插件不接管任何供应商的登录,也不会把页面伪装成单一供应商专属功能;登录、模型、
5
+ 额度和签到 Hook 仍由各供应商插件维护,本插件负责统一检测、调度、执行和展示。
6
+
7
+ ## 功能
8
+
9
+ - 一键检测 Magpie 当前已登录的供应商、账号、模型和额度窗口。
10
+ - 一键签到:通过各供应商登录插件公开的 `auth.checkin` 接口执行,尊重供应商、
11
+ 账号两级启停;页面会明确显示未适配或未登录的账号,不会静默跳过。
12
+ - 真实对话保活:通过各供应商自己的登录插件取得签名请求,发送一条真实消息。
13
+ - 模型选择:只从该账号实际列出的模型中选择。默认优先近期真实保活成功过的模型,
14
+ 再按成功率、成功时间和失败次数排序;没有历史记录时沿用供应商自己的模型顺序,
15
+ 不再按免费或价格抢先选择。第一个模型不可用时自动换下一档,全部不可用才记失败。
16
+ - 自定义模型:页面里每个供应商都有可搜索的模型下拉框;选中模型会优先尝试,
17
+ 失败后仍会自动回退到该账号可用的其他模型。列表会显示倍率、免费状态和模型 ID。
18
+ - 定时任务:页面内可配置每 5 分钟到 7 天执行一次,任务可选“只签到”“只保活”
19
+ 或“签到 + 保活”。
20
+ - 执行记录:检测、签到、模型切换、保活、额度和失败都会写入 JSONL 诊断日志;
21
+ 页面“最近执行记录”只展示签到和保活任务,刷新额度不会挤占任务记录。点击记录
22
+ 可查看完整执行时间、账号明细和同一轮执行链路。单独账号触发、没有总任务总结
23
+ 锚点的保活记录也会显示,不会因为缺少汇总日志而被旧任务掩盖。
24
+ - 今日结果:供应商行分别汇总“签到 X/Y”和“保活 X/Y”,账号卡显示已签到、
25
+ 签到积分(如 `+100`)、已保活,以及设备受限、额度不足、待重试等具体原因。
26
+ 跳过项不再计入“需要处理”,只有明确失败才进入待处理数量。
27
+ - 近 5 天历史:每个有执行记录的账号可展开查看最近 5 天的签到、积分和保活结果。
28
+ - 额度视图:统一展示账号“可用 / 总计”额度窗口和余额变化,并由 Magpie 的额度历史
29
+ 提供趋势数据。供应商若只返回“已用 / 总量”展示值,只有在数值能相互核对时才换算,
30
+ 无法确认语义时保留原始展示,避免把“已用”误当成“可用”。
31
+ - 自动刷新额度:启用中的供应商每 1 分钟刷新一次,停用的供应商不刷新;顶部可刷新全部
32
+ 启用供应商,每个供应商卡片也可单独立即刷新。供应商没有可读取额度卡时记为跳过;
33
+ Magpie 30 秒内已刷新过时使用缓存并记为跳过,只有网络、登录失效或空数据等真实
34
+ 异常才记为失败。
35
+ - 多账号支持:所有供应商账号逐个执行,同一账号同一北京时间日成功后不会重复发送。
36
+ - 账号级控制:每个账号的“签到/保活”开关只控制自动任务;旁边独立的“执行签到”
37
+ “执行保活”按钮只对当前账号立即执行一次。手动保活必须二次确认,仍会遵守供应商
38
+ 整体启停,但可以单独执行一个已关闭自动任务的账号。
39
+
40
+ ## 安装
41
+
42
+ 从 GitHub 安装:
43
+
44
+ ```sh
45
+ magpie plugin add github:sljdxde/quota-keepalive
46
+ ```
47
+
48
+ 从本地目录安装:
49
+
50
+ ```sh
51
+ magpie plugin add "$(pwd)"
52
+ ```
53
+
54
+ 插件不需要接管 provider。供应商登录仍由 Magpie 已安装的对应社区插件提供;本插件
55
+ 直接从 Magpie 的插件目录加载这些签名适配器,因此 Magpie 或供应商插件更新后,
56
+ 只要其公开接口没有变化,本插件仍可继续使用。若供应商包改名为不兼容的接口,页面
57
+ 会明确显示失败原因,不会静默跳过。
58
+
59
+ 安装后在 Magpie 左侧 `Plugins` 分组打开“额度签到”。如果页面文件刚更新,
60
+ 重新打开一次插件页即可刷新清单,不需要重启 Magpie。
61
+
62
+ 发布到 npm 后也可以按包名安装:
63
+
64
+ ```sh
65
+ magpie plugin add quota-keepalive
66
+ ```
67
+
68
+ ## 页面操作
69
+
70
+ - `一键检测`:刷新供应商、账号、模型和额度快照。
71
+ - `全部签到`:立即对所有具备签到能力的供应商执行签到。
72
+ - `发送保活`:确认真实请求后,为所有已登录且适配的供应商账号发送消息。
73
+ - 账号后的“签到/保活”开关:控制该账号是否参加自动任务,不会立即发送请求。
74
+ - 账号后的“执行签到/执行保活”:只对当前账号立即执行一次;执行保活会先弹出确认框。
75
+ - 连续切换多个开关时会合并成最后一次状态写入,并按 Magpie 的页面配置限频串行保存,
76
+ 不会因为快速连续点击触发“每秒只能写入一次”的报错。
77
+ - 圆形刷新按钮:立即刷新全部启用供应商额度;供应商卡片内的刷新按钮只刷新该供应商。
78
+ - `保存计划`:设置统一执行间隔和任务类型。计划默认关闭,防止无意间消耗积分。
79
+ - 定时计划保存或修改后,从下一个间隔开始执行;需要立即执行时使用页面顶部的手动按钮。
80
+
81
+ 页面每 5 秒读取一次本地快照;插件后台每 1 分钟刷新启用中供应商的额度,停用项会
82
+ 跳过。额度刷新仍写入后台诊断日志,但不会出现在页面的签到/保活执行记录中。
83
+ 执行中的任务会显示在状态条中。快照生成时会一次
84
+ 计算今日状态和近 5 天摘要,页面轮询只读取结果,不会在浏览器里重复聚合历史日志。
85
+ 最近执行记录按任务折叠显示;点击任意记录可展开精确执行时间、耗时、供应商、账号
86
+ 数量、执行 ID、完整说明,以及本次任务下每个账号的结果和积分。
87
+
88
+ ## 模型可用性
89
+
90
+ 保活不是“只选免费或最便宜”后盲目发送:
91
+
92
+ 1. 从供应商模型和该账号的模型白名单取交集。
93
+ 2. 读取近 5 天真实保活记录,优先该账号成功过的模型。
94
+ 3. 账号没有记录时,优先该供应商其他账号成功过的模型;仍无记录则跟随供应商模型列表顺序。
95
+ 4. 已知失败的模型排在未验证模型之后;单个账号最多尝试配置数量的候选模型。
96
+ 5. 模型不存在、额度拒绝、上游不可用或空回复时切换下一个候选。
97
+ 6. WorkBuddy CN 按 dsh 的 probe 语义,HTTP 2xx 表示上游已经接受并记录一次真实对话;
98
+ `max_tokens` 为 1 时可能只有 `[DONE]`、usage 或空正文,仍算成功。JSON 业务错误、
99
+ HTML 登录页和超时不算成功。
100
+
101
+ 账号卡片会优先显示最近一次真实保活实际使用的模型(`实际模型`);没有执行历史时,
102
+ 依次显示用户指定的 `所选模型` 或自动排序出的 `自动首选`。供应商设置行也会汇总
103
+ 最近实际使用的模型,日志会保留每次模型切换及失败原因。
104
+
105
+ 展开供应商卡片后,可在“保活模型”输入框里搜索模型名或模型 ID。下拉列表默认包含
106
+ 该供应商的全部可用模型,并显示免费状态、倍率和模型 ID;选中后点击“自动”可恢复
107
+ 自动选择。实际请求仍只使用该账号模型白名单中的模型;选中项不可用时自动回退。
108
+
109
+ ## 配置
110
+
111
+ 页面保存的配置写入 Magpie 插件级 options。也可以用命令修改:
112
+
113
+ ```sh
114
+ magpie plugin options quota-keepalive '{
115
+ "scheduleEnabled": true,
116
+ "scheduleMinutes": 60,
117
+ "scheduleAction": "all",
118
+ "modelPreferences": {
119
+ "workbuddy": "deepseek-v4.1-flash",
120
+ "codearts": "deepseek-v4.1-flash"
121
+ },
122
+ "modelCandidates": 4,
123
+ "inputTokens": 25000,
124
+ "maxAttempts": 3,
125
+ "timeoutMs": 90000,
126
+ "logMaxBytes": 2097152
127
+ }'
128
+ ```
129
+
130
+ - `scheduleEnabled`:是否启用定时任务,默认 `false`。
131
+ - `scheduleMinutes`:统一执行间隔,默认 60 分钟,范围 5 分钟到 7 天。
132
+ - `scheduleAction`:`checkin`、`keepalive` 或 `all`,默认 `checkin`。
133
+ - `model`:可选的首选模型;不是白名单,候选仍会按可用性回退。
134
+ - `modelPreferences`:按供应商指定首选模型,例如
135
+ `{"workbuddy":"deepseek-v4.1-flash","codearts":"deepseek-v4.1-flash"}`。未配置时
136
+ WorkBuddy CN 默认优先 `deepseek-v4.1-flash`,这是 dsh 实测较稳定的真实对话模型;
137
+ 其他供应商按近 5 天可用性排序,没有成功记录时按供应商模型顺序选择,不按免费或倍率抢先选择。
138
+ - `modelCandidates`:每个账号每次最多尝试的模型数,默认 4,范围 1–12。
139
+ - `inputTokens`:真实请求的输入规模,默认 25000,范围 1000–25000。
140
+ - `maxAttempts`:每个账号每个北京时间日最多保活尝试轮数,默认 3。
141
+ - `timeoutMs`:单个模型请求的整体截止时间,默认 90 秒,范围 10–180 秒。WorkBuddy
142
+ CN 的 25k 输入请求可能先返回响应头、再继续流式读取;90 秒比 25 秒更接近 dsh 的
143
+ probe 容忍度,同时仍避免任务无限挂起。即使供应商
144
+ 登录插件内部忽略中止信号,外层也会结束该候选并继续回退;账号较多时不要调得过大。
145
+ - `logMaxBytes`:单个日志上限,默认 2 MiB,超出后轮转一份 `.1`。
146
+ - `statePath`、`logPath`、`snapshotPath`、`commandPath`:可选绝对路径。
147
+
148
+ ## 执行周期与失败
149
+
150
+ Magpie 自己的每日签到调度仍会在启动后约 2 分钟进行首轮,之后约每 30 分钟巡检;
151
+ 失败约 30 分钟后重试。本插件的定时任务是额外的统一调度,默认关闭,适合需要更
152
+ 频繁签到或保活的场景。本插件的定时任务不会在保存后立即执行,也不会在修改后
153
+ 重启 Magpie;修改配置会替换运行中的定时器,并从新的间隔重新计时。
154
+
155
+ 保活失败会按 `maxAttempts` 重试;达到上限后当天不再自动发送。网络失败、登录过期、
156
+ 模型不可用和空回复都会进入日志。遇到明确的 HTTP 402/429 额度耗尽或免费额度用尽时,
157
+ 当天停止自动重试,避免持续发送无效请求。手动“发送保活”或账号级“执行保活”可以重试
158
+ 当天失败或额度耗尽的账号;签到或保活当天已经成功时,即使手动触发也会跳过,不会重复
159
+ 发送。供应商整体停用时,账号级手动按钮也会禁用。
160
+
161
+ ## 日志
162
+
163
+ 默认日志文件:
164
+
165
+ ```text
166
+ ~/.config/magpie/workbuddy-daily.log.jsonl
167
+ ```
168
+
169
+ 查看最近记录:
170
+
171
+ ```sh
172
+ node ./bin/daily-keepalive-log.js --limit 50
173
+ node ./bin/daily-keepalive-log.js --day 2026-10-30 --status failed
174
+ node ./bin/daily-keepalive-log.js --json
175
+ ```
176
+
177
+ 也能通过 Agent 工具 `quota-keepalive_logs` 查询。日志只记录账号稳定哈希、供应商、
178
+ 结果、模型和脱敏后的错误,不保存令牌、完整账号标识、企业标识或模型回复。
179
+
180
+ ## 安全
181
+
182
+ - 状态和日志文件以 `0600` 创建,写入使用临时文件和原子 rename。
183
+ - `plugin-auth.json` 的更新使用短锁,避免与 Magpie 或另一个任务并发写坏登录。
184
+ - 模型回复不会写入状态或日志。
185
+ - 日志和页面显示会对令牌、Bearer、API key 和账号标识做脱敏。
186
+ - 未安装对应供应商插件、未登录或没有可用模型时,会明确记为跳过或失败。
187
+
188
+ ## 验证
189
+
190
+ ```sh
191
+ bun test
192
+ bun test --coverage
193
+ ```
194
+
195
+ 测试使用 mock 请求,不会访问供应商,也不会消耗积分。当前核心 JavaScript
196
+ 模块的行覆盖率和函数覆盖率均为 100%。性能边界测试覆盖 60 个供应商、600 个账号、
197
+ 5,000 个模型、10 万条记录和 16 组执行历史的聚合路径;测试使用宽松的固定预算,
198
+ 同时校验输出规模与结果正确性,避免只看耗时。页面视觉和真实供应商协议仍需在
199
+ Magpie 中人工验收。
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "./workbuddy-daily-log.js"
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from "node:path"
4
+ import {
5
+ defaultLogPath,
6
+ formatLogRecords,
7
+ readDailyLogs,
8
+ } from "../src/log.js"
9
+
10
+ const STATUSES = new Set(["started", "success", "failed", "skipped"])
11
+
12
+ function usage() {
13
+ return `Usage: workbuddy-daily-log [options]
14
+
15
+ Options:
16
+ --limit N Show the last N matching records (default 50, max 500)
17
+ --day YYYY-MM-DD Filter by Beijing date
18
+ --status STATUS started, success, failed, or skipped
19
+ --file PATH Read one exact log file instead of current plus previous
20
+ --json Print JSONL instead of human-readable lines
21
+ -h, --help Show this help
22
+
23
+ Default log:
24
+ ${defaultLogPath()}`
25
+ }
26
+
27
+ function parse(args) {
28
+ const out = { limit: 50, json: false }
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i]
31
+ if (arg === "-h" || arg === "--help") return { help: true }
32
+ if (arg === "--json") {
33
+ out.json = true
34
+ continue
35
+ }
36
+ if (!["--limit", "--day", "--status", "--file"].includes(arg)) {
37
+ throw new Error(`unknown option: ${arg}`)
38
+ }
39
+ const value = args[++i]
40
+ if (value === undefined || value.startsWith("--")) {
41
+ throw new Error(`${arg} needs a value`)
42
+ }
43
+ if (arg === "--limit") out.limit = Number(value)
44
+ if (arg === "--day") out.day = value
45
+ if (arg === "--status") out.status = value
46
+ if (arg === "--file") out.file = resolve(value)
47
+ }
48
+ if (!Number.isInteger(out.limit) || out.limit < 1 || out.limit > 500) {
49
+ throw new Error("--limit must be an integer from 1 to 500")
50
+ }
51
+ if (out.day && !/^\d{4}-\d{2}-\d{2}$/.test(out.day)) {
52
+ throw new Error("--day must be YYYY-MM-DD")
53
+ }
54
+ if (out.status && !STATUSES.has(out.status)) {
55
+ throw new Error("--status must be started, success, failed, or skipped")
56
+ }
57
+ return out
58
+ }
59
+
60
+ async function main() {
61
+ const args = parse(process.argv.slice(2))
62
+ if (args.help) {
63
+ console.log(usage())
64
+ return
65
+ }
66
+ const logPath = args.file || process.env.WORKBUDDY_DAILY_LOG || defaultLogPath()
67
+ const result = await readDailyLogs({
68
+ logPath,
69
+ includePrevious: !args.file,
70
+ day: args.day || "",
71
+ status: args.status || "",
72
+ limit: args.limit,
73
+ })
74
+ if (result.malformed) {
75
+ console.error(`Skipped ${result.malformed} malformed or unrelated JSONL line(s).`)
76
+ }
77
+ if (!result.records.length) {
78
+ console.log("没有找到符合条件的日志记录。")
79
+ console.log(`已查看:\n${result.files.join("\n")}`)
80
+ return
81
+ }
82
+ if (args.json) {
83
+ console.log(result.records.map((record) => JSON.stringify(record)).join("\n"))
84
+ } else {
85
+ console.log(formatLogRecords(result.records))
86
+ }
87
+ }
88
+
89
+ main().catch((error) => {
90
+ console.error(`读取日志失败: ${error instanceof Error ? error.message : error}`)
91
+ process.exitCode = 1
92
+ })
package/index.js ADDED
@@ -0,0 +1,333 @@
1
+ import { resolveKeepaliveOptions } from "./src/config.js"
2
+ import { beijingDay } from "./src/common.js"
3
+ import {
4
+ formatLogRecords,
5
+ readDailyLogs,
6
+ } from "./src/log.js"
7
+ import { appendDailyLog, newRunId } from "./src/log.js"
8
+ import { readLivePluginOptions } from "./src/live-options.js"
9
+ import { consumePendingCommand, runCommand } from "./src/runner.js"
10
+ import { snapshotWarning, writeDailySnapshot } from "./src/snapshot.js"
11
+ import { createProviderCheckin } from "./src/checkin-runner.js"
12
+ import { createWorkBuddyKeepalive } from "./src/workbuddy.js"
13
+ import { readCommand } from "./src/commands.js"
14
+
15
+ const LOG_STATUSES = new Set(["started", "success", "failed", "skipped"])
16
+ const SCHEDULER = Symbol.for("quota-keepalive.scheduler")
17
+ const OPTIONS_WATCHER = Symbol.for("quota-keepalive.options-watcher")
18
+ const QUOTA_REFRESHER = Symbol.for("quota-keepalive.quota-refresher")
19
+ const REFRESH_INTERVAL_MS = 60_000
20
+
21
+ export function stopScheduler() {
22
+ const previous = globalThis[SCHEDULER]
23
+ if (previous?.timer) clearInterval(previous.timer)
24
+ if (previous) delete globalThis[SCHEDULER]
25
+ }
26
+
27
+ export function startScheduler({ config, checkin, keepalive }) {
28
+ // Every behavior setting must participate in the signature. Otherwise a
29
+ // model or provider/account preference change would leave the running timer
30
+ // on the previous closures and configuration.
31
+ const signature = JSON.stringify(config)
32
+ const previous = globalThis[SCHEDULER]
33
+ if (previous?.signature === signature) return
34
+ stopScheduler()
35
+ if (!config.scheduleEnabled) return
36
+ let running = false
37
+ const timer = setInterval(async () => {
38
+ if (running) return
39
+ running = true
40
+ try {
41
+ const result = await runCommand({
42
+ action: config.scheduleAction,
43
+ config,
44
+ checkin,
45
+ keepalive,
46
+ force: false,
47
+ })
48
+ await appendDailyLog({
49
+ logPath: config.logPath,
50
+ logMaxBytes: config.logMaxBytes,
51
+ entry: {
52
+ ts: new Date().toISOString(),
53
+ day: beijingDay(),
54
+ provider: "quota-keepalive",
55
+ run: result.run || newRunId(),
56
+ event: "schedule",
57
+ status: result.status,
58
+ outcome: result.status,
59
+ message: result.summary,
60
+ },
61
+ })
62
+ await writeDailySnapshot({
63
+ ...(config.configHome ? { configHome: config.configHome } : {}),
64
+ logPath: config.logPath,
65
+ snapshotPath: config.snapshotPath,
66
+ commandPath: config.commandPath,
67
+ })
68
+ } catch (error) {
69
+ await appendDailyLog({
70
+ logPath: config.logPath,
71
+ logMaxBytes: config.logMaxBytes,
72
+ entry: {
73
+ ts: new Date().toISOString(),
74
+ day: beijingDay(),
75
+ provider: "quota-keepalive",
76
+ run: newRunId(),
77
+ event: "schedule",
78
+ status: "failed",
79
+ outcome: "failed",
80
+ error: error instanceof Error ? error.message : String(error),
81
+ },
82
+ })
83
+ } finally {
84
+ running = false
85
+ }
86
+ }, config.scheduleMinutes * 60_000)
87
+ timer.unref?.()
88
+ globalThis[SCHEDULER] = { timer, signature }
89
+ }
90
+
91
+ export function stopOptionsWatcher() {
92
+ const previous = globalThis[OPTIONS_WATCHER]
93
+ if (previous?.timer) clearInterval(previous.timer)
94
+ if (previous) delete globalThis[OPTIONS_WATCHER]
95
+ }
96
+
97
+ export function stopQuotaRefresher() {
98
+ const previous = globalThis[QUOTA_REFRESHER]
99
+ if (previous?.timer) clearInterval(previous.timer)
100
+ if (previous?.initial) clearTimeout(previous.initial)
101
+ if (previous) delete globalThis[QUOTA_REFRESHER]
102
+ }
103
+
104
+ export async function refreshQuotasNow({
105
+ config,
106
+ run = runCommand,
107
+ command = readCommand,
108
+ log = appendDailyLog,
109
+ }) {
110
+ const current = await command(config.commandPath).catch(() => null)
111
+ if (current && ["queued", "running"].includes(current.status)) {
112
+ return { status: "skipped", summary: "额度刷新已跳过:已有任务正在执行。" }
113
+ }
114
+ let result
115
+ try {
116
+ result = await run({ action: "refresh", config })
117
+ } catch (error) {
118
+ result = {
119
+ run: newRunId(),
120
+ status: "failed",
121
+ summary: `额度刷新失败:${error?.message || error}`,
122
+ error: error?.message || String(error),
123
+ }
124
+ }
125
+ await log({
126
+ logPath: config.logPath,
127
+ logMaxBytes: config.logMaxBytes,
128
+ entry: {
129
+ ts: new Date().toISOString(),
130
+ day: beijingDay(),
131
+ provider: "quota-keepalive",
132
+ run: result.run || newRunId(),
133
+ event: "refresh",
134
+ status: result.status,
135
+ outcome: result.status,
136
+ message: result.summary,
137
+ ...(result.error ? { error: result.error } : {}),
138
+ },
139
+ }).catch(() => {})
140
+ await writeDailySnapshot({
141
+ ...(config.configHome ? { configHome: config.configHome } : {}),
142
+ logPath: config.logPath,
143
+ snapshotPath: config.snapshotPath,
144
+ commandPath: config.commandPath,
145
+ }).catch((error) => {
146
+ console.warn(`[quota-keepalive] ${snapshotWarning(error)}`)
147
+ })
148
+ return result
149
+ }
150
+
151
+ export function startQuotaRefresher({
152
+ config,
153
+ refresh = refreshQuotasNow,
154
+ intervalMs = REFRESH_INTERVAL_MS,
155
+ firstDelayMs = 30_000,
156
+ }) {
157
+ const signature = JSON.stringify([
158
+ config.configHome || "",
159
+ config.commandPath,
160
+ config.providerPreferences,
161
+ ])
162
+ const previous = globalThis[QUOTA_REFRESHER]
163
+ if (previous?.signature === signature) return
164
+ stopQuotaRefresher()
165
+
166
+ let running = false
167
+ const tick = async () => {
168
+ if (running) return
169
+ running = true
170
+ try {
171
+ await refresh({ config })
172
+ } catch (error) {
173
+ console.warn(`[quota-keepalive] cannot refresh quotas: ${error?.message || error}`)
174
+ } finally {
175
+ running = false
176
+ }
177
+ }
178
+ const initial = setTimeout(tick, firstDelayMs)
179
+ const timer = setInterval(tick, intervalMs)
180
+ initial.unref?.()
181
+ timer.unref?.()
182
+ globalThis[QUOTA_REFRESHER] = { timer, initial, signature }
183
+ }
184
+
185
+ export function startOptionsWatcher({
186
+ input,
187
+ rawOptions,
188
+ config,
189
+ readOptions = readLivePluginOptions,
190
+ start = startScheduler,
191
+ consume = consumePendingCommand,
192
+ makeCheckin = createProviderCheckin,
193
+ makeKeepalive = createWorkBuddyKeepalive,
194
+ startRefresh = startQuotaRefresher,
195
+ }) {
196
+ stopOptionsWatcher()
197
+ if (!config.watchOptions) return
198
+ let running = false
199
+ const timer = setInterval(async () => {
200
+ if (running) return
201
+ running = true
202
+ try {
203
+ const liveOptions = await readOptions(config.configHome)
204
+ const liveRaw = { ...rawOptions, ...liveOptions }
205
+ const liveConfig = resolveKeepaliveOptions(liveRaw)
206
+ const checkin = makeCheckin({ input, config: liveConfig })
207
+ const keepalive = makeKeepalive({ input, config: liveConfig })
208
+ start({ config: liveConfig, checkin, keepalive })
209
+ startRefresh({ config: liveConfig })
210
+ await consume({
211
+ config: liveConfig,
212
+ rawOptions: liveRaw,
213
+ checkin,
214
+ keepalive,
215
+ })
216
+ } catch (error) {
217
+ console.warn(`[quota-keepalive] cannot read live options: ${error?.message || error}`)
218
+ } finally {
219
+ running = false
220
+ }
221
+ }, 2_000)
222
+ timer.unref?.()
223
+ globalThis[OPTIONS_WATCHER] = { timer }
224
+ }
225
+
226
+ export function logTool(config) {
227
+ return {
228
+ logs: {
229
+ description:
230
+ "Reads recent quota check-in and keepalive logs. Logs use hashed account tags and never include tokens, full account ids, or model replies.",
231
+ args: {
232
+ type: "object",
233
+ properties: {
234
+ limit: {
235
+ type: "integer",
236
+ minimum: 1,
237
+ maximum: 200,
238
+ default: 50,
239
+ description: "Number of most recent matching records to return.",
240
+ },
241
+ day: {
242
+ type: "string",
243
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$",
244
+ description: "Only return records for this Beijing date (YYYY-MM-DD).",
245
+ },
246
+ status: {
247
+ type: "string",
248
+ enum: ["started", "success", "failed", "skipped"],
249
+ description: "Only return records with this status.",
250
+ },
251
+ file: {
252
+ type: "string",
253
+ enum: ["all", "current", "previous"],
254
+ default: "all",
255
+ description: "Read the current log, its previous rotated log, or both.",
256
+ },
257
+ },
258
+ additionalProperties: false,
259
+ },
260
+ async execute(args = {}) {
261
+ const limit = args.limit === undefined ? 50 : Number(args.limit)
262
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
263
+ throw new Error("limit must be an integer from 1 to 200")
264
+ }
265
+ if (args.day && !/^\d{4}-\d{2}-\d{2}$/.test(args.day)) {
266
+ throw new Error("day must be YYYY-MM-DD")
267
+ }
268
+ if (args.status && !LOG_STATUSES.has(args.status)) {
269
+ throw new Error("status must be started, success, failed, or skipped")
270
+ }
271
+ const file = args.file || "all"
272
+ if (!["all", "current", "previous"].includes(file)) {
273
+ throw new Error("file must be all, current, or previous")
274
+ }
275
+
276
+ const previousOnly = file === "previous"
277
+ const result = await readDailyLogs({
278
+ logPath: previousOnly ? `${config.logPath}.1` : config.logPath,
279
+ includePrevious: file === "all",
280
+ day: args.day || "",
281
+ status: args.status || "",
282
+ limit,
283
+ })
284
+ if (!result.records.length) {
285
+ return `No quota keepalive log records matched. Looked in:\n${result.files.join("\n")}`
286
+ }
287
+ const warning = result.malformed
288
+ ? `\nSkipped ${result.malformed} malformed or unrelated line(s).`
289
+ : ""
290
+ return `Quota Keepalive logs (${result.records.length} records, Beijing time)${warning}\n${
291
+ formatLogRecords(result.records)
292
+ }`
293
+ },
294
+ },
295
+ }
296
+ }
297
+
298
+ // This plugin deliberately declares no provider/auth hook. The installed
299
+ // provider plugins keep owning sign-in, model lists, quota and check-in for
300
+ // WorkBuddy, CodeArts, MiniMax and every other provider. This plugin only
301
+ // orchestrates their tasks, so Magpie's plugin card stays provider-neutral.
302
+ export async function QuotaKeepalivePlugin(input, rawOptions = {}) {
303
+ const config = resolveKeepaliveOptions(rawOptions)
304
+ const checkin = createProviderCheckin({ input, config })
305
+ const keepalive = createWorkBuddyKeepalive({ input, config })
306
+ startScheduler({ config, checkin, keepalive })
307
+ startQuotaRefresher({ config })
308
+ startOptionsWatcher({ input, rawOptions, config })
309
+
310
+ await consumePendingCommand({ config, rawOptions, checkin, keepalive }).catch((error) => {
311
+ console.warn(`[quota-keepalive] cannot run the page command: ${error?.message || error}`)
312
+ })
313
+ await writeDailySnapshot({
314
+ ...(config.configHome ? { configHome: config.configHome } : {}),
315
+ logPath: config.logPath,
316
+ snapshotPath: config.snapshotPath,
317
+ commandPath: config.commandPath,
318
+ }).catch((error) => {
319
+ console.warn(`[quota-keepalive] ${snapshotWarning(error)}`)
320
+ })
321
+
322
+ return {
323
+ tool: logTool(config),
324
+ }
325
+ }
326
+
327
+ // Magpie treats every named export as a plugin entry unless the default
328
+ // export identifies one server. Keep testable helpers named while exposing
329
+ // only QuotaKeepalivePlugin to the host.
330
+ export default {
331
+ id: "quota-keepalive",
332
+ server: QuotaKeepalivePlugin,
333
+ }