oc-retry-guard:独立子仓(模型严重错误自动重试·事不过三2分钟周期)

- 仅装本插件 oc-retry-guard.ts + .gitignore
- 分支名 = 文件夹名,推到共用远程根 oc_session.git 的 oc-retry-guard 分支
This commit is contained in:
youpaishidifu
2026-10-19 09:37:19 +08:00
commit dde3dcf3e9
2 changed files with 260 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
*.tsbuildinfo
__pycache__/
*.py[cod]
*.bak-*
skills-backup/
*-debug.js
*-test.js
*.test.mjs
.vscode/
.idea/
.DS_Store
Thumbs.db
+247
View File
@@ -0,0 +1,247 @@
// oc-retry-guard —— 模型严重错误自动重试 server 插件(2026-10-19,路线B 自动重发)
//
// 背景:OC 二进制里流式解析层会抛 "OpenAI Chat received content after the
// finish reason" 这类严重错误(错误发生在响应体流解析阶段,HTTP 层是 200,
// 因此 http.response 钩子必须自己读 response body 来识别错误特征)。
// 用户要求(路线B):模型出这种错时,插件自动替它重发请求(补一条 nudge),
// 事不过三 —— 2 分钟为周期,周期内最多重试 3 次,3 次都失败才真正停下;
// 超过 2 分钟计数器归零。
//
// 技术链路(OC 2.0.27 二进制反查实锤):
// http.response 钩子 payload = {...D, request, response}
// - request: 原始 fetch Request(含 url / headers / method)
// - response: Response 对象(可 .clone().text() 读 body 识别错误特征)
// 钩子 return 改写后的 { ...D, request, response } 后,OC 用
// Sy(we, ot.response) 消费改后的 response。
// ⇒ 命中错误时:插件自己用 request 的 url+headers+缓存 body 重新 fetch 一次
// (把 nudge 拼进 messages 末尾),再把重发成功的 response 替换掉原 response
// 回传给 OC —— 实现「模型出错后自动续上,用户无感知」。
//
// 计数语义(事不过三 + 2 分钟周期):
// retryState: Map<sessionID, { count, firstAt }>
// 命中错误时:
// now = Date.now()
// if (!state || now - state.firstAt > 120000) state = {count:0, firstAt:now}
// if (state.count >= 3) → 停下(替换 response 为原错误 response,记 stopped 日志)
// else state.count++ → 自动重发,记 retried 日志
// 2 分钟后(now - firstAt > 120000)下一轮命中时自动归零重置。
//
// 契约(与 ai-mcp / compaction-cn 同款 V2 插件铁律,勿破坏):
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
//
// 部署(三份同步 + node --experimental-strip-types 校验):
// 源码真源 : D:\桌面\源码\oc_session\oc-retry-guard\oc-retry-guard.ts
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\oc-retry-guard.ts
// WSL 生效 : /root/.config/opencode/plugins/oc-retry-guard.ts
import { appendFileSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
const LOG_FILE = join(homedir(), ".config", "opencode", "oc-retry-guard.log")
// 严重错误特征(命中任一即触发重试判定)
const ERR_SIGS = [
"content after the finish reason",
"stream ended without finish_reason",
"received content after the finish",
"unexpected finish_reason",
]
// 仅对 LLM 聊天端点生效(避免误伤 /api/session、config 等非模型请求)
const CHAT_URL_RE = /\/(chat|completions|messages|generate)\/?|\/v1\//
// nudge 文案(英文,对任何模型都通用;要求保持原语言、不译代码/路径/URL)
const NUDGE =
"<retry-nudge>\n" +
"Your previous output was cut off by an upstream error " +
'("received content after the finish reason"). Please CONTINUE from where you ' +
"stopped, or re-output the incomplete part in full. Keep your original reply " +
"language. Do not translate code, file paths, commands, URLs or error text.\n" +
"</retry-nudge>"
// 窗口与上限
const WINDOW_MS = 120000 // 2 分钟
const MAX_RETRY = 3 // 事不过三
// 单次重发预算(避免上游卡死拖垮 TUI)
const RETRY_FETCH_TIMEOUT_MS = 90000
// sessionID → 上一条聊天请求的原始 body(供命中错误时重建)
const lastReqBody = new Map<string, { body: string; at: number }>()
// sessionID → { count, firstAt }
const retryState = new Map<string, { count: number; firstAt: number }>()
function fileLog(msg: string) {
try {
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch {
/* 日志失败绝不阻塞主链路 */
}
}
function msgText(m: any): string {
try {
if (!m) return ""
if (typeof m.content === "string") return m.content
if (Array.isArray(m.content)) {
return m.content.map((c: any) => (c && typeof c.text === "string" ? c.text : "")).join("")
}
return ""
} catch {
return ""
}
}
// 给请求体末尾追加一条 nudge user 消息(保持原语言、不译代码/路径/URL)
function appendNudge(body: string): string {
try {
const obj = JSON.parse(body)
if (!obj || !Array.isArray(obj.messages)) return body
obj.messages.push({ role: "user", content: NUDGE })
return JSON.stringify(obj)
} catch {
return body
}
}
// 命中错误时自动重发:用同一 url+headers 重发(body 追加 nudge),
// 超时/异常返回 null(回退到原 response)
async function retryFetch(url: string, headers: any, body: string, timeoutMs: number): Promise<Response | null> {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(headers && headers instanceof Headers
? Object.fromEntries(Array.from(headers.entries()))
: headers && typeof headers === "object"
? headers
: {}),
},
body,
signal: ctrl.signal,
})
clearTimeout(timer)
return res
} catch {
return null
}
}
export const OcRetryGuard: any = async (_ctx: any) => (await ({} as any), undefined as any)
export default {
id: "oc-retry-guard",
async setup(ctx: any): Promise<void> {
try {
const session = ctx && ctx.session
if (!session || typeof session.hook !== "function") {
fileLog("session.hook 不可用,未注册")
return
}
// ① http.request:缓存每个 session 的上一条聊天请求体(命中错误时重建用)
await session.hook("http.request", (f: any) => {
try {
if (!f || !f.request || !f.request.url) return
const url = String(f.request.url)
if (!CHAT_URL_RE.test(url)) return
const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "")
if (ct && ct.indexOf("json") < 0) return
const sid = String(f.sessionID || "?")
// 仅缓存成功发出的请求 body(供后续命中错误时重发)
// request 钩子在请求出网前触发,body 还在 Request 里;不消费流,
// 改为在 response 钩子命中时用 f.request 缓存的 body 字段。
// 这里只做占位:OC 在 request 钩子后可改写 f.request,
// 真正的 body 缓存放在 response 钩子(那里 request 已被 OC 定型)。
void sid
return
} catch {
/* 吞掉,绝不阻塞 */
}
})
// ② http.response:识别严重错误 → 按「事不过三 + 2 分钟周期」决定自动重发
await session.hook("http.response", async (f: any) => {
try {
if (!f || !f.request || !f.response) return
const url = String(f.request.url || "")
if (!CHAT_URL_RE.test(url)) return
const resp: any = f.response
if (!resp || typeof resp.clone !== "function") return
// 缓存原始 body(供本 session 后续命中错误时重建;用 request 的 body)
const sid = String(f.sessionID || "?")
let origBody = ""
try {
origBody = f.request && f.request.clone ? await f.request.clone().text() : ""
} catch {
origBody = ""
}
if (origBody && origBody.length > 0) {
lastReqBody.set(sid, { body: origBody, at: Date.now() })
}
// 读响应体识别错误特征(HTTP 层是 200,错误藏在 body 文本里)
let bodyText = ""
try {
bodyText = await resp.clone().text()
} catch {
return
}
const isErrSig = ERR_SIGS.some((s) => bodyText.indexOf(s) >= 0)
const isHttpErr = (resp.status || 0) >= 500
if (!isErrSig && !isHttpErr) return // 成功响应,原样放行
// 事不过三 + 2 分钟周期计数
const now = Date.now()
let st = retryState.get(sid)
if (!st || now - st.firstAt > WINDOW_MS) {
st = { count: 0, firstAt: now }
}
if (st.count >= MAX_RETRY) {
fileLog(
`STOP session=${sid}(2 分钟内已重试 ${st.count} 次,模型确实出问题,本轮不再补救)status=${resp.status} sig=${isErrSig}`,
)
retryState.set(sid, st)
return // 回退到原 response(让 OC 显示原始错误)
}
st.count += 1
retryState.set(sid, st)
// 自动重发:用缓存 body + nudge 重新 POST 同一端点
fileLog(
`RETRY session=${sid} 第${st.count}/${MAX_RETRY}次 status=${resp.status} sig=${isErrSig}(自动重发中…)`,
)
const cached = lastReqBody.get(sid)
const newBody = cached ? appendNudge(cached.body) : origBody ? appendNudge(origBody) : ""
if (!newBody) {
fileLog(`RETRY session=${sid} 无可用 body,跳过重发`)
return
}
const hdrs = f.request.headers
const newResp = await retryFetch(url, hdrs, newBody, RETRY_FETCH_TIMEOUT_MS)
if (!newResp) {
fileLog(`RETRY session=${sid} 重发失败(超时/网络),回退原 response`)
return
}
// 重发成功:把新 response 替换掉原 response 回传给 OC(OC 消费 ot.response)
f.response = newResp
fileLog(
`RETRY session=${sid} 第${st.count}次重发成功 status=${newResp.status}(已替换 response,模型自动续上)`,
)
} catch (e: any) {
fileLog("http.response 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
})
fileLog("oc-retry-guard 已注册(http.request 缓存 + http.response 自动重发,事不过三 2分钟周期)")
} catch (e: any) {
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
}
// setup 必须返回 void
return
},
}