main: 移除 oc-retry-guard 跟踪,拉黑该子目录(专属分支独立维护)
- 移除 oc-retry-guard/oc-retry-guard.ts 的 main 跟踪(该插件只在 oc-retry-guard 分支维护) - .gitignore 增加 oc-retry-guard/ 拉黑 - 保留 tui.js v11 排版改动 + sync_wsl.py ITEMS 增加 oc-retry-guard 项
This commit is contained in:
@@ -7,6 +7,7 @@ src/
|
|||||||
ai-mcp/
|
ai-mcp/
|
||||||
wsl-round-mcp/
|
wsl-round-mcp/
|
||||||
compaction-cn/
|
compaction-cn/
|
||||||
|
oc-retry-guard/
|
||||||
|
|
||||||
# Python 运行产物
|
# Python 运行产物
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -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<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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user