opencode-plugin-quota-resume 0.0.0-stage → 0.2.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -2
  3. package/package.json +32 -4
  4. package/src/index.ts +263 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-plugin-quota-resume 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,99 @@
1
- # Temporary Holding Version
1
+ # opencode-plugin-quota-resume
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
+ OpenCode **v2** 插件:当会话因第三方订阅(token plan)的额度/限流(HTTP 429 等)而**彻底失败**(内置重试耗尽)后,自动等待到下一个额度窗口(默认 5 小时)边界,然后向**同一个会话、同一个订阅模型**重新提交一条“继续”prompt,实现“自动重试直到额度恢复”。
4
+
5
+ ## 工作原理
6
+
7
+ 1. 订阅 `session.execution.failed` 事件——这是内置重试全部耗尽后的终态失败,不受单次请求重试次数硬上限的约束;
8
+ 2. 判断失败原因是否为限流/额度耗尽:HTTP 状态码匹配(默认 `429`)或错误信息正则匹配(默认包含 `rate limit / too many requests / quota / extra usage / balance has run out / insufficient balance / 额度 / 限流 / 余额不足` 等,覆盖 Kimi `Extra Usage balance has run out`、OpenAI `insufficient_quota` 等常见文案);
9
+ 3. 计算到下一个额度窗口边界的等待时间(`窗口 - (now - 锚点) % 窗口 + 缓冲`);
10
+ 4. 定时器到期后调用 `ctx.session.prompt` 自动重新提交,继续执行;
11
+ 5. 若用户在等待期间手动继续任务,定时器自动取消。
12
+
13
+ ## 保护机制
14
+
15
+ - 每个会话同时最多挂起一个恢复定时器;
16
+ - 每会话自动恢复次数上限(默认 12 次),防止额度长期不恢复时的无限循环;
17
+ - 等待时长下限(默认 60s),避免窗口对齐结果过小时高频请求;
18
+ - 插件卸载/服务停止时清理全部定时器。
19
+
20
+ ## 安装
21
+
22
+ ### 方式一:npm 发布包(推荐)
23
+
24
+ ```bash
25
+ opencode plugin add opencode-plugin-quota-resume
26
+ ```
27
+
28
+ 或在 `opencode.json(c)` 中配置:
29
+
30
+ ```jsonc
31
+ {
32
+ "plugins": ["opencode-plugin-quota-resume"],
33
+ }
34
+ ```
35
+
36
+ ### 方式二:本地目录
37
+
38
+ ```jsonc
39
+ {
40
+ "plugins": ["../path/to/opencode-plugin-quota-resume"],
41
+ }
42
+ ```
43
+
44
+ ### 方式三:开发调试
45
+
46
+ 把本仓库放到任意 `.opencode/plugins/` 目录下会自动加载(无需改配置):
47
+
48
+ ```text
49
+ <project>/.opencode/plugins/opencode-plugin-quota-resume/
50
+ ```
51
+
52
+ ## 配置(均为可选)
53
+
54
+ ```jsonc
55
+ {
56
+ "plugins": [
57
+ {
58
+ "package": "opencode-plugin-quota-resume",
59
+ "options": {
60
+ "windowHours": 5, // 订阅额度窗口长度(小时)
61
+ "windowEpoch": "2026-01-01T00:00:00.000Z", // 窗口对齐锚点(ISO 时间)
62
+ "bufferSeconds": 30, // 窗口边界后的额外缓冲(秒)
63
+ "minDelaySeconds": 60, // 等待时长下限(秒)
64
+ "resumeText": "继续上一个任务", // 恢复时自动发送的 prompt
65
+ "maxResumesPerSession": 12, // 每会话自动恢复次数上限
66
+ "statusCodes": [429], // 视为限流的 HTTP 状态码
67
+ "messagePatterns": ["rate.?limit", "too many requests", "quota", "extra usage", "balance has run out", "insufficient balance", "额度", "限流", "余额不足"] // 错误信息正则
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ > `windowEpoch` 的作用:把等待时间对齐到服务商额度窗口的整点边界。如果你的 5 小时窗口是从首次请求或固定时刻滚动开始,把锚点设为该时刻即可;不确定时保持默认即可,等待上限就是一个窗口长度。
75
+
76
+ ## 日志
77
+
78
+ 插件运行日志(`[quota-resume]` 前缀)写入 OpenCode 服务端日志,可用 `opencode debug paths log` 查看日志路径。
79
+
80
+ ## 已知限制
81
+
82
+ - 定时器保存在内存中,OpenCode 服务端重启后未触发的恢复任务会丢失(失败消息仍保留在会话里,手动继续即可);
83
+ - 恢复时发送的是**新的 prompt**,而不是从断点无损续传——模型会看到之前的完整会话历史(含失败信息),对于“继续上一个任务”这类指令通常足够。
84
+
85
+ ## 开发
86
+
87
+ ```bash
88
+ npm install
89
+ npm run typecheck
90
+ npm test
91
+ ```
92
+
93
+ ## 发布
94
+
95
+ ```bash
96
+ npm publish
97
+ ```
98
+
99
+ `prepublishOnly` 会自动执行类型检查和测试。
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "opencode-plugin-quota-resume",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "OpenCode v2 plugin: when a session dies on a provider rate/quota limit (HTTP 429 etc.), wait until the plan's quota window resets, then automatically re-prompt the session to continue on the same subscription model.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "tsx --test test/*.test.ts",
17
+ "prepublishOnly": "npm run typecheck && npm test"
18
+ },
19
+ "keywords": [
20
+ "opencode",
21
+ "opencode-plugin",
22
+ "rate-limit",
23
+ "quota",
24
+ "429",
25
+ "retry"
26
+ ],
27
+ "license": "MIT",
28
+ "devDependencies": {
29
+ "@opencode/plugin": "latest",
30
+ "@types/node": "^22.0.0",
31
+ "tsx": "^4.19.0",
32
+ "typescript": "^5.6.0"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,263 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ /**
4
+ * opencode-plugin-quota-resume
5
+ *
6
+ * 方案二(事件驱动):
7
+ * 1. 订阅 `session.execution.failed` —— 内置重试全部耗尽后的终态失败;
8
+ * 2. 失败原因是限流/额度耗尽(HTTP 429 或错误信息匹配)时,
9
+ * 计算到下一个订阅额度窗口(默认 5 小时)边界的等待时间;
10
+ * 3. 定时器到期后向同一个会话重新提交一条“继续”prompt,
11
+ * 从而在“同一个订阅模型”上自动恢复,直到额度恢复为止。
12
+ *
13
+ * 保护机制:
14
+ * - 每个会话同时最多挂起一个恢复定时器;
15
+ * - 若用户在等待期间手动继续(会话重新进入执行),自动取消定时器;
16
+ * - 每个会话的自动恢复次数有上限(默认 12 次),防止无限循环;
17
+ * - 等待时长有下限(默认 60s),窗口对齐结果过小时不会高频轰炸服务端。
18
+ */
19
+
20
+ const LOG_PREFIX = "[quota-resume]"
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // 纯函数工具(独立导出以便测试)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface QuotaResumeOptions {
27
+ /** 订阅额度窗口长度(小时),默认 5 */
28
+ windowHours: number
29
+ /** 额度窗口的对齐锚点(epoch 毫秒),默认 2026-01-01T00:00:00Z */
30
+ windowEpochMs: number
31
+ /** 窗口边界后的额外缓冲(毫秒),默认 30s */
32
+ bufferMs: number
33
+ /** 等待时长下限(毫秒),默认 60s */
34
+ minDelayMs: number
35
+ /** 恢复执行时自动发送的 prompt 文本 */
36
+ resumeText: string
37
+ /** 每个会话最多自动恢复次数,默认 12 */
38
+ maxResumesPerSession: number
39
+ /** 视为限流的 HTTP 状态码 */
40
+ statusCodes: number[]
41
+ /** 视为限流的错误信息正则(匹配 type + message,忽略大小写) */
42
+ messagePatterns: RegExp[]
43
+ }
44
+
45
+ const DEFAULT_MESSAGE_PATTERN_SOURCES = [
46
+ "rate.?limit",
47
+ "too many requests",
48
+ "resource exhausted",
49
+ "quota",
50
+ "extra usage",
51
+ "balance has run out",
52
+ "insufficient balance",
53
+ "insufficient_quota",
54
+ "out of credits",
55
+ "额度",
56
+ "限流",
57
+ "余额不足",
58
+ "余额耗尽",
59
+ "欠费",
60
+ ]
61
+
62
+ const DEFAULTS = {
63
+ windowHours: 5,
64
+ windowEpoch: "2026-01-01T00:00:00.000Z",
65
+ bufferSeconds: 30,
66
+ minDelaySeconds: 60,
67
+ resumeText: "继续上一个任务",
68
+ maxResumesPerSession: 12,
69
+ statusCodes: [429],
70
+ messagePatterns: DEFAULT_MESSAGE_PATTERN_SOURCES,
71
+ } as const
72
+
73
+ function asNumber(value: unknown, fallback: number): number {
74
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback
75
+ }
76
+
77
+ function asString(value: unknown, fallback: string): string {
78
+ return typeof value === "string" && value.trim() !== "" ? value : fallback
79
+ }
80
+
81
+ function asNumberArray(value: unknown, fallback: number[]): number[] {
82
+ if (!Array.isArray(value)) return fallback
83
+ const result = value.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
84
+ return result.length > 0 ? result : fallback
85
+ }
86
+
87
+ function asPatternArray(value: unknown, fallback: string[]): RegExp[] {
88
+ const sources = Array.isArray(value)
89
+ ? value.filter((v): v is string => typeof v === "string" && v.trim() !== "")
90
+ : fallback
91
+ const patterns: RegExp[] = []
92
+ for (const source of sources) {
93
+ try {
94
+ patterns.push(new RegExp(source, "i"))
95
+ } catch {
96
+ // 忽略无效正则,使用默认兜底
97
+ }
98
+ }
99
+ return patterns.length > 0 ? patterns : fallback.map((s) => new RegExp(s, "i"))
100
+ }
101
+
102
+ /** 解析插件配置(来自 opencode.json 的 plugins 数组选项),带安全兜底 */
103
+ export function resolveOptions(raw: unknown): QuotaResumeOptions {
104
+ const opts = (raw ?? {}) as Record<string, unknown>
105
+ const epochDate = Date.parse(asString(opts.windowEpoch, DEFAULTS.windowEpoch))
106
+ return {
107
+ windowHours: asNumber(opts.windowHours, DEFAULTS.windowHours),
108
+ windowEpochMs: Number.isNaN(epochDate) ? Date.parse(DEFAULTS.windowEpoch) : epochDate,
109
+ bufferMs: asNumber(opts.bufferSeconds, DEFAULTS.bufferSeconds) * 1000,
110
+ minDelayMs: asNumber(opts.minDelaySeconds, DEFAULTS.minDelaySeconds) * 1000,
111
+ resumeText: asString(opts.resumeText, DEFAULTS.resumeText),
112
+ maxResumesPerSession: Math.max(1, Math.floor(asNumber(opts.maxResumesPerSession, DEFAULTS.maxResumesPerSession))),
113
+ statusCodes: asNumberArray(opts.statusCodes, [...DEFAULTS.statusCodes]),
114
+ messagePatterns: asPatternArray(opts.messagePatterns, [...DEFAULTS.messagePatterns]),
115
+ }
116
+ }
117
+
118
+ /**
119
+ * 计算到下一个额度窗口边界的等待毫秒数。
120
+ * - remainder = (now - epoch) % window
121
+ * - remainder 很小(< minDelay)说明窗口刚重置,只等 minDelay;
122
+ * - 否则等待 remainder + buffer(缓冲服务端计数刷新延迟)。
123
+ */
124
+ export function nextWindowDelay(
125
+ nowMs: number,
126
+ windowMs: number,
127
+ epochMs: number,
128
+ bufferMs: number,
129
+ minDelayMs: number,
130
+ ): number {
131
+ if (windowMs <= 0) return minDelayMs
132
+ const remainder = ((nowMs - epochMs) % windowMs + windowMs) % windowMs
133
+ if (remainder < minDelayMs) return minDelayMs
134
+ return windowMs - remainder + bufferMs
135
+ }
136
+
137
+ export interface ProviderErrorLike {
138
+ status?: number
139
+ type?: string
140
+ message?: string
141
+ }
142
+
143
+ /** 判断一次失败是否应视为“额度/限流” */
144
+ export function matchesQuotaError(error: ProviderErrorLike | undefined, options: QuotaResumeOptions): boolean {
145
+ if (!error) return false
146
+ if (error.status !== undefined && options.statusCodes.includes(error.status)) return true
147
+ const haystack = `${error.type ?? ""} ${error.message ?? ""}`.toLowerCase()
148
+ return options.messagePatterns.some((pattern) => pattern.test(haystack))
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // 事件载荷提取(不同版本字段位置可能不同,做防御式提取)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function extractError(payload: unknown): ProviderErrorLike | undefined {
156
+ const p = payload as Record<string, unknown> | undefined
157
+ const candidates = [p?.error, (p?.durable as Record<string, unknown> | undefined)?.error]
158
+ for (const candidate of candidates) {
159
+ if (candidate && typeof candidate === "object") return candidate as ProviderErrorLike
160
+ }
161
+ return undefined
162
+ }
163
+
164
+ function extractSessionID(payload: unknown): string | undefined {
165
+ const p = payload as Record<string, unknown> | undefined
166
+ const durable = p?.durable as Record<string, unknown> | undefined
167
+ const candidates = [p?.sessionID, durable?.sessionID]
168
+ for (const candidate of candidates) {
169
+ if (typeof candidate === "string" && candidate !== "") return candidate
170
+ }
171
+ return undefined
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // 插件主体
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export default Plugin.define({
179
+ id: "quota-resume",
180
+ async setup(ctx) {
181
+ const options = resolveOptions(ctx.options)
182
+ const windowMs = options.windowHours * 60 * 60 * 1000
183
+
184
+ /** sessionID -> 挂起中的恢复定时器 */
185
+ const pending = new Map<string, ReturnType<typeof setTimeout>>()
186
+ /** sessionID -> 已自动恢复次数 */
187
+ const resumeCounts = new Map<string, number>()
188
+
189
+ const cancelPending = (sessionID: string, reason: string) => {
190
+ const timer = pending.get(sessionID)
191
+ if (timer === undefined) return
192
+ clearTimeout(timer)
193
+ pending.delete(sessionID)
194
+ console.log(`${LOG_PREFIX} session ${sessionID} 的自动恢复已取消(${reason})`)
195
+ }
196
+
197
+ const controller = new AbortController()
198
+ void (async () => {
199
+ for await (const event of ctx.event.subscribe({
200
+ types: [
201
+ "session.execution.failed",
202
+ "session.execution.started",
203
+ "session.execution.succeeded",
204
+ "session.execution.interrupted",
205
+ ],
206
+ signal: controller.signal,
207
+ })) {
208
+ const sessionID = extractSessionID(event)
209
+ if (!sessionID) continue
210
+
211
+ // 会话重新进入执行(用户手动继续 / 其他来源)→ 取消挂起的自动恢复
212
+ if (event.type !== "session.execution.failed") {
213
+ cancelPending(sessionID, "会话已恢复执行")
214
+ continue
215
+ }
216
+
217
+ // 仅处理终态失败
218
+ if (pending.has(sessionID)) continue
219
+ const error = extractError(event)
220
+ if (!matchesQuotaError(error, options)) continue
221
+
222
+ const count = (resumeCounts.get(sessionID) ?? 0) + 1
223
+ if (count > options.maxResumesPerSession) {
224
+ console.warn(
225
+ `${LOG_PREFIX} session ${sessionID} 自动恢复次数已达上限(${options.maxResumesPerSession}),停止重试。` +
226
+ `最后一个错误:${error?.message ?? JSON.stringify(error)}`,
227
+ )
228
+ continue
229
+ }
230
+
231
+ const delay = nextWindowDelay(Date.now(), windowMs, options.windowEpochMs, options.bufferMs, options.minDelayMs)
232
+ resumeCounts.set(sessionID, count)
233
+ console.log(
234
+ `${LOG_PREFIX} session ${sessionID} 因额度/限流失败(${error?.status ?? error?.type ?? "unknown"}),` +
235
+ `第 ${count}/${options.maxResumesPerSession} 次自动恢复将于 ${Math.round(delay / 60000)} 分钟后执行`,
236
+ )
237
+
238
+ pending.set(
239
+ sessionID,
240
+ setTimeout(() => {
241
+ pending.delete(sessionID)
242
+ void ctx.session
243
+ .prompt({ sessionID, text: options.resumeText })
244
+ .then(() => console.log(`${LOG_PREFIX} session ${sessionID} 已自动重新提交,继续执行`))
245
+ .catch((e: unknown) =>
246
+ console.error(`${LOG_PREFIX} session ${sessionID} 自动重新提交失败:`, e),
247
+ )
248
+ }, delay),
249
+ )
250
+ }
251
+ })().catch((e: unknown) => {
252
+ if ((e as Error)?.name !== "AbortError") console.error(`${LOG_PREFIX} 事件订阅异常:`, e)
253
+ })
254
+
255
+ // 插件卸载时清理
256
+ return () => {
257
+ controller.abort()
258
+ for (const timer of pending.values()) clearTimeout(timer)
259
+ pending.clear()
260
+ resumeCounts.clear()
261
+ }
262
+ },
263
+ })