diff --git a/.gitignore b/.gitignore index 90e6474..370e391 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ src/ ai-mcp/ wsl-round-mcp/ compaction-cn/ +oc-retry-guard/ # Python 运行产物 __pycache__/ diff --git a/oc-retry-guard/oc-retry-guard.ts b/oc-retry-guard/oc-retry-guard.ts deleted file mode 100644 index 11e63c6..0000000 --- a/oc-retry-guard/oc-retry-guard.ts +++ /dev/null @@ -1,247 +0,0 @@ -// 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 -// 命中错误时: -// 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 = - "\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" + - "" - -// 窗口与上限 -const WINDOW_MS = 120000 // 2 分钟 -const MAX_RETRY = 3 // 事不过三 -// 单次重发预算(避免上游卡死拖垮 TUI) -const RETRY_FETCH_TIMEOUT_MS = 90000 - -// sessionID → 上一条聊天请求的原始 body(供命中错误时重建) -const lastReqBody = new Map() -// sessionID → { count, firstAt } -const retryState = new Map() - -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 { - 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 { - 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 - }, -}