Files
oc_session/model-manager.ts
T

834 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// model-manager —— 模型目录管理 server 插件(2026-10-19)
//
// 背景:OC 新版把模型目录存在 SQLite 里:
// 数据库 : ~/.local/share/opencode/opencode.db (WAL 模式)
// 表 : kv
// key : models-dev:catalog
// value : JSON 字符串 { updatedAt, digest, body }
// body : 嵌套 JSON 字符串,结构 = provider → { id, npm, api, models: { modelID: entry } }
// /models 命令读的就是这份 catalog。
//
// 用户要求:选中某个模型后,能 ① 停用(不在 /models 显示) ② 完全删除(恢复出厂)。
//
// ── 技术难点 ──────────────────────────────────────────────────────────────
// OC 进程自己持有 opencode.db 的 SQLite 写锁(WAL 模式)。插件跑在 OC 内部,
// 若直接 require('better-sqlite3') 写同一文件 → 死锁 / SQLITE_BUSY。
// 因此采用「外部子进程执行 DB 写」方案:
// 1. OC 内部 daemon 每 5s 轮询 outbox 队列文件(普通 JSON 文件,不碰 DB)
// 2. 命中操作 → 把 SQLite 写 fork 给外部 Python 子进程执行(独立连接,安全)
// 3. 队列排空 → 写 flush 标记 → 由 TUI 侧 tui.js 执行 location.reload 热重载
// (2026-10-19 实测定论:V2 server 插件 ctx 没有 client.location / serverUrl,
// 进程内 reload 无通道 —— ai-mcp 2026-09-24 也报过同款「没有可用的 reload 通道」;
// 而 TUI 插件 ctx 的 client.location 实测可用,见 oc-session-all.log 的
// 「location.reload OK」。所以热重载的正主在 tui.js,本插件只负责写 DB + 发标记)
//
// ── 操作语义 ──────────────────────────────────────────────────────────────
// disable(provider, modelID) = 从 catalog body 里 DELETE provider.models[modelID]
// (/models 不再显示;数据仍在 catalog 备份可恢复)
// enable(provider, modelID) = 把 modelID 加回 provider.models(恢复显示)
// reset(provider) = 恢复出厂:把该 provider 的 models 换回出厂默认
// (出厂默认从 opencode.db.backup 读,或保留一份 pristine)
//
// ── 契约(与 ai-mcp / compaction-cn / oc-retry-guard 同款 V2 插件铁律)────
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
//
// 部署(三份同步 + node --experimental-strip-types 校验):
// 源码真源 : D:\桌面\源码\oc_session\model-manager\model-manager.ts
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\model-manager.ts
// WSL 生效 : /root/.config/opencode/plugins/model-manager.ts
import {
appendFileSync,
existsSync,
readFileSync,
writeFileSync,
readdirSync,
} from "node:fs"
import { spawn } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
const LOG_FILE = join(homedir(), ".config", "opencode", "model-manager.log")
// outbox 队列(OC daemon 轮询;外部脚本 / 用户也可手动写这里触发操作)
const OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl")
// 出厂备份(reset 用:首次操作前自动把 pristine catalog 存一份)
const PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json")
// flush 标记:daemon 把队列排空并写完 DB 后落盘 { at, lastOp, serverReload };
// tui.js 常驻 poller 轮询到新 at 就执行 location.reload(TUI 侧才有 reload 通道)
const FLUSH = join(homedir(), ".config", "opencode", "model-manager.last-flush.json")
// ── 持久化停用配置 cfg.json(2026-10-19 用户指路引进)──────────────────────
// 运行时路径:~/.config/opencode/model-manager.cfg.json(源码仓根目录 cfg.json 为模板)
// 只认一个键:「以下是停用列表」(键名不区分大小写),值 = provider 级数组,支持 * 通配;
// 其余键全是说明文档,全部忽略、写回时原样保留。
// 防御:opencode 更新可能删除我们的配置文件 → ensureCfg() 检测到丢失/损坏立即释放新配置,
// 并从镜像 STOPLIST_MIRROR 恢复停用列表(停用状态不因 OC 更新丢失)。
const CFG_PATH = join(homedir(), ".config", "opencode", "model-manager.cfg.json")
const STOPLIST_MIRROR = join(homedir(), ".config", "opencode", "model-manager.stoplist.bak.json")
const STOP_KEY = "以下是停用列表"
const CFG_TEMPLATE = `{
"停用清单": "持久化标记停用模型",
"以下是停用列表": [],
"配置格式": "一行一个,比如Xiaomi,deepseek等,*表示通配符,比如xiaomi-*则表示停用小米家族的全部模型(Xiaomi、Xiaomi-token-plan等)",
"示例参数":[
"xiaomi",
"DEEPSEEK",
"longcat-*"
]
}`
// modelID 级镜像清单(单模型停用的持久化;整厂级走 cfg 的停用列表)
const DISABLED_MIRROR = join(homedir(), ".config", "opencode", "model-manager.disabled.json")
// 已彻底删除的厂商列表(remove_provider 标记;面板据此显示「已删除·可找回」)
const REMOVED_LIST = join(homedir(), ".config", "opencode", "model-manager.removed.json")
// 数据库路径
const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db")
// SQLite 写执行器(Python,OC 外部子进程跑,绕 OC 自身 DB 写锁)
const DB_EXEC = join(homedir(), ".config", "opencode", "model-manager-db-exec.py")
// 热重载通道见 doReload()(抄 ai-mcp:SDK client.location.reload 三级降级 + ctx.serverUrl 兜底,
// 不再写死 4096 —— 用户环境 4096 无监听导致 reload 全挂、/models 不刷新)
function fileLog(msg: string) {
try {
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch {
/* 日志失败绝不阻塞 */
}
}
// ── cfg.json:键名大小写不敏感定位「以下是停用列表」,其它键只保留不解读 ─────
function stopKeyOf(obj: any): string | null {
if (!obj || typeof obj !== "object") return null
const target = STOP_KEY.toLowerCase()
for (const k of Object.keys(obj)) {
if (String(k).toLowerCase() === target) return k
}
return null
}
// 防御性保证:cfg 存在且可解析、含停用列表键;否则立即释放新配置
function ensureCfg(): void {
try {
if (existsSync(CFG_PATH)) {
const raw = readFileSync(CFG_PATH, "utf-8")
let j: any = null
try {
j = JSON.parse(raw)
} catch {
j = null
}
if (j && typeof j === "object" && !Array.isArray(j)) {
if (!stopKeyOf(j)) {
j[STOP_KEY] = []
writeFileSync(CFG_PATH, JSON.stringify(j, null, 2), "utf-8")
fileLog("cfg 配置缺少「" + STOP_KEY + "」键,已补上(其余说明键原样保留)")
}
return
}
// 损坏:备份后重建
try {
writeFileSync(CFG_PATH + ".bad", raw, "utf-8")
} catch {}
fileLog("cfg 配置损坏,已备份 .bad,立即释放新配置")
} else {
fileLog("cfg 配置文件丢失(可能被 opencode 更新清除),立即释放新配置: " + CFG_PATH)
}
let restored: string[] = []
try {
if (existsSync(STOPLIST_MIRROR)) {
const m = JSON.parse(readFileSync(STOPLIST_MIRROR, "utf-8"))
if (Array.isArray(m)) restored = m.filter((x: any) => typeof x === "string")
}
} catch {}
const tpl: any = JSON.parse(CFG_TEMPLATE)
tpl[STOP_KEY] = restored
writeFileSync(CFG_PATH, JSON.stringify(tpl, null, 2), "utf-8")
fileLog(
"新配置已释放" + (restored.length ? ",停用列表从镜像恢复 " + restored.length + " 项" : "(停用列表为空)"),
)
} catch (e: any) {
fileLog("ensureCfg 异常: " + (e && e.message ? e.message : String(e)))
}
}
// 读停用列表(只认目标键,大小写不敏感;其它键全部忽略)
function readStopList(): string[] {
ensureCfg()
try {
const j = JSON.parse(readFileSync(CFG_PATH, "utf-8"))
const k = stopKeyOf(j)
const v = k ? j[k] : null
if (Array.isArray(v)) return v.filter((x: any) => typeof x === "string" && x.trim())
if (typeof v === "string") return v.split(/[\n,]/).map((s: string) => s.trim()).filter(Boolean)
return []
} catch {
return []
}
}
// 写回停用列表(只改目标键,其余说明键原样保留)+ 同步镜像(防 OC 更新删文件丢状态)
function writeStopList(list: string[]): void {
try {
ensureCfg()
const j = JSON.parse(readFileSync(CFG_PATH, "utf-8"))
const k = stopKeyOf(j) || STOP_KEY
j[k] = list
writeFileSync(CFG_PATH, JSON.stringify(j, null, 2), "utf-8")
try {
writeFileSync(STOPLIST_MIRROR, JSON.stringify(list, null, 2), "utf-8")
} catch {}
} catch (e: any) {
fileLog("停用列表写回失败: " + (e && e.message ? e.message : String(e)))
}
}
// 通配匹配(大小写不敏感):pattern 支持 * → 正则 fullmatch
function patMatch(pattern: string, text: string): boolean {
const p = String(pattern).toLowerCase()
const t = String(text || "").toLowerCase()
if (!p) return false
if (p === t) return true
if (p.includes("*")) {
const re = new RegExp("^" + p.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i")
return re.test(t)
}
return false
}
// 该 provider 是否被停用列表命中(id 或显示名 任一被精确/通配命中)
function stopListHits(list: string[], id: string, name?: string): boolean {
for (const pat of list) {
if (patMatch(pat, id)) return true
if (name && patMatch(pat, name)) return true
}
return false
}
// ── modelID 级镜像清单(单模型停用的持久化载体;与 cfg 的 provider 级列表互补)──
type MirrorMap = Record<string, string[]>
function loadMirror(): MirrorMap {
try {
if (!existsSync(DISABLED_MIRROR)) return {}
const j = JSON.parse(readFileSync(DISABLED_MIRROR, "utf-8"))
const out: MirrorMap = {}
for (const k of Object.keys(j || {})) {
if (Array.isArray(j[k])) out[k] = j[k].filter((x: any) => typeof x === "string")
}
return out
} catch {
return {}
}
}
function saveMirror(map: MirrorMap): void {
try {
writeFileSync(DISABLED_MIRROR, JSON.stringify(map, null, 2), "utf-8")
} catch (e: any) {
fileLog("停用镜像写入失败: " + (e && e.message ? e.message : String(e)))
}
}
function mirrorMark(provider: string, modelID: string): void {
const map = loadMirror()
const arr = map[provider] || []
if (!arr.includes(modelID)) {
arr.push(modelID)
map[provider] = arr.sort()
saveMirror(map)
fileLog(`停用镜像已记录 ${provider}/${modelID}(共 ${arr.length} 项,重启/刷新后自动压制)`)
}
}
function mirrorUnmark(provider: string, modelID: string): void {
const map = loadMirror()
if (!map[provider]) return
map[provider] = map[provider].filter((x) => x !== modelID)
if (!map[provider].length) delete map[provider]
saveMirror(map)
fileLog(`停用镜像已移除 ${provider}/${modelID}`)
}
// 已删除厂商列表(面板显示「已删除·可找回」用)
function loadRemoved(): string[] {
try {
if (!existsSync(REMOVED_LIST)) return []
const j = JSON.parse(readFileSync(REMOVED_LIST, "utf-8"))
return Array.isArray(j) ? j.filter((x: any) => typeof x === "string") : []
} catch {
return []
}
}
function saveRemoved(list: string[]): void {
try {
writeFileSync(REMOVED_LIST, JSON.stringify(list, null, 2), "utf-8")
} catch (e: any) {
fileLog("已删除厂商列表写入失败: " + (e && e.message ? e.message : String(e)))
}
}
// 读 catalog(只读 SQLite,OC 自身持锁但只读不冲突;用 Python 子进程读保证一致)
async function readCatalog(): Promise<any | null> {
try {
const py = process.platform === "win32" ? "python" : "python3"
const r = spawn(
py,
[
"-c",
`
import sqlite3, json, sys, os
db = os.path.join(os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db")
con = sqlite3.connect(db)
row = con.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone()
con.close()
if not row:
sys.stdout.buffer.write(json.dumps({"error": "catalog row missing"}).encode("utf-8"))
sys.exit(0)
sys.stdout.buffer.write(row[0].encode("utf-8"))
`,
],
{
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" },
},
)
let out = ""
r.stdout.on("data", (d) => (out += d.toString("utf-8")))
await new Promise((res) => r.on("close", res))
if (!out.trim()) return null
return JSON.parse(out.trim())
} catch (e: any) {
fileLog("readCatalog 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
return null
}
}
// 执行 DB 写(fork Python 子进程,独立 SQLite 连接)
async function execDbWrite(op: string, payload: any): Promise<string> {
return new Promise((resolve) => {
const r = spawn("python", [DB_EXEC, op, JSON.stringify(payload)], {
stdio: ["ignore", "pipe", "pipe"],
})
let out = ""
let err = ""
r.stdout.on("data", (d) => (out += d))
r.stderr.on("data", (d) => (err += d))
r.on("close", (code) => {
resolve(out.trim() || `rc=${code}` + (err ? " err=" + err.trim() : ""))
})
r.on("error", (e) => resolve("spawn-fail " + e.message))
})
}
// 热重载(进程内尽力通道,2026-10-19 实测:V2 server 插件 ctx 两条通道都没有,
// 保留尝试只为将来宿主补上 client/serverUrl 时能自动生效):
// 进程内 SDK client.location.reload 三种调用形态 → HTTP POST ctx.serverUrl /api/location/reload
// 正常路径见 processOutbox 尾部:写 flush 标记 → tui.js 用 TUI ctx 的 client.location 热重载
// V2 上下文可能有 client 属性:优先 ctx.client(若宿主提供),否则回退 ctx 本身(抄 ai-mcp pickClient)
function pickClient(ctx: any) {
if (!ctx) return ctx
if (ctx.client && (ctx.client.location || ctx.client.config)) return ctx.client
return ctx
}
async function doReload(ctx: any): Promise<boolean> {
const attempts: Array<() => Promise<any>> = []
const client = pickClient(ctx)
if (client && client.location && typeof client.location.reload === "function") {
attempts.push(() => client.location.reload({ location: undefined as any }))
attempts.push(() => client.location.reload({}))
attempts.push(() => client.location.reload())
}
const serverUrl = ctx && (ctx as any).serverUrl
if (serverUrl) {
attempts.push(async () => {
const res = await fetch(new URL("/api/location/reload", serverUrl), {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res
})
}
let lastErr: any = null
for (const fn of attempts) {
try {
await fn()
fileLog("hot reload 已触发(/models 应立即生效)")
return true
} catch (e: any) {
lastErr = e
}
}
const msg = lastErr ? lastErr.message : "进程内无 reload 通道(V2 server ctx 无 client.location/serverUrl,已实测)"
fileLog("进程内 hot reload 未成功: " + msg + " → 接下来写 flush 标记,由 TUI 侧 tui.js 热重载(看 oc-session-all.log)")
return false
}
// 队列深度(只数行,不改文件)
function queueDepth(): number {
try {
if (!existsSync(OUTBOX)) return 0
return readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim()).length
} catch {
return -1 // 读失败时按「未知」处理,宁可多等一轮
}
}
// 从 outbox 消费一条操作并执行
async function processOutbox(ctx: any): Promise<void> {
if (!existsSync(OUTBOX)) return
let lines: string[]
try {
lines = readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim())
} catch {
return
}
if (lines.length === 0) return
// 一次只处理第一条(避免并发写 DB)
const line = lines.shift()!
writeFileSync(OUTBOX, lines.join("\n")) // 清空已消费行
let op: string
let payload: any
try {
const obj = JSON.parse(line)
op = obj.op
payload = obj.payload || {}
} catch {
fileLog("outbox 行解析失败(已跳过): " + line.slice(0, 80))
return
}
fileLog(`outbox 命中 op=${op} payload=${JSON.stringify(payload).slice(0, 120)}`)
// reset 前确保有 pristine 备份(恢复厂商要靠它把出厂模型拉回来)
if (op === "reset" || op === "remove_provider") {
if (!existsSync(PRISTINE)) {
const cat = await readCatalog()
if (cat) writeFileSync(PRISTINE, JSON.stringify(cat))
fileLog("pristine 备份已建立")
}
}
const res = await execDbWrite(op, payload)
const depth = queueDepth()
fileLog(`DB 写结果 op=${op}: ${res},队列剩余=${depth}`)
// ── 堵假成功(2026-10-20 修):DB 写失败时不维护状态、不写「成功 flush」──────
// 否则 TUI 侧照常热重载并 toast「已重载」,但 catalog 压根没改(模型安然无恙)。
const s = String(res)
const failed =
s.startsWith("unknown op") ||
s.startsWith("spawn-fail") ||
s.startsWith("Traceback") ||
/^rc=[1-9]/.test(s)
if (failed) {
fileLog(`✗ DB 写未生效 op=${op}: ${s.slice(0, 300)}`)
if (s.startsWith("unknown op")) {
ensureDbExec()
fileLog("已按当前插件版本重写 DB 执行器(磁盘为旧版本缺新 op),请重新执行该操作")
}
try {
writeFileSync(
FLUSH,
JSON.stringify({ at: Date.now(), lastOp: op, serverReload: false, error: `DB 写未生效: ${s.slice(0, 160)}` }),
)
fileLog("已写「失败 flush」标记(TUI 会提示失败,不报已重载): tag=" + op)
} catch (e: any) {
fileLog("失败 flush 标记写入失败: " + (e && e.message ? e.message : String(e)))
}
return
}
// ── 维护持久化停用状态(只有 DB 真实进入目标态才动)────────────────────
// provider 级 → cfg.json 的「以下是停用列表」(用户可见可手改)
// modelID 级 → model-manager.disabled.json 镜像
try {
const prov = payload.provider
if (op === "disable_all" && (s.startsWith("disabled_all ") || s.startsWith("already empty"))) {
const list = readStopList()
if (!list.some((p) => patMatch(p, prov))) {
list.push(prov)
writeStopList(list)
fileLog(`停用列表已记录整厂 ${prov}(cfg 共 ${list.length} 项,持久化)`)
}
} else if (op === "disable" && (s.startsWith("disabled ") || s.startsWith("model not present"))) {
mirrorMark(prov, payload.modelID)
} else if (op === "enable" && s.startsWith("enabled ")) {
mirrorUnmark(prov, payload.modelID)
} else if (op === "enable" && s.startsWith("model not in pristine")) {
mirrorUnmark(prov, payload.modelID)
} else if (op === "remove_provider" && (s.startsWith("removed_provider ") || s.startsWith("provider not found"))) {
// 恢复出厂设置(格式化)= 彻底删除该厂 → 记进 cfg 停用列表,OC 刷新 catalog 回潮时自动再压
const list = readStopList()
if (!list.some((p) => patMatch(p, prov))) {
list.push(prov)
writeStopList(list)
fileLog(`已删除厂商 ${prov},并记入 cfg 停用列表(cfg 共 ${list.length} 项,持久化)`)
}
const rm = loadRemoved()
if (!rm.includes(prov)) {
rm.push(prov)
saveRemoved(rm)
fileLog(`已删除厂商列表已记录 ${prov}(面板可显示「已删除·可找回」)`)
}
} else if (op === "reset" && (s.startsWith("reset ") || s.startsWith("provider not in pristine"))) {
// 恢复该厂商(撤销删除/停用):从停用列表摘除,单模型镜像清空
const list = readStopList()
const kept = list.filter((p) => !patMatch(p, prov))
const removed = list.length - kept.length
if (removed) {
writeStopList(kept)
fileLog(`恢复出厂 ${prov}:停用列表移除 ${removed} 项(命中该厂的通配项一并移除)`)
}
const map = loadMirror()
if (map[prov]) {
delete map[prov]
saveMirror(map)
fileLog(`恢复出厂 ${prov}:单模型停用镜像已清空`)
}
const rm = loadRemoved()
if (rm.includes(prov)) {
saveRemoved(rm.filter((x) => x !== prov))
fileLog(`恢复出厂 ${prov}:已从「已删除厂商」列表移除(可再被 /manage 看到)`)
}
}
} catch (e: any) {
fileLog("停用状态维护异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
if (depth !== 0) {
// 还有排队的操作(如逐模型停用多条):先不 reload,等全部写完一次性热重载
fileLog("队列未排空,暂不 reload(等最后一条写完再一次性热重载)")
return
}
await finishBatch(ctx, op)
}
// 队列排空后的收尾:进程内 reload 尝试 + 落 flush 标记(TUI 常驻 poller 据此热重载)
async function finishBatch(ctx: any, tag: string): Promise<void> {
const serverReload = await doReload(ctx)
try {
writeFileSync(FLUSH, JSON.stringify({ at: Date.now(), lastOp: tag, serverReload: !!serverReload }))
fileLog("flush 标记已写(TUI 侧将据此触发热重载): tag=" + tag + " serverReload=" + !!serverReload)
} catch (e: any) {
fileLog("flush 标记写入失败: " + (e && e.message ? e.message : String(e)))
}
}
// SQLite 写执行器(Python,子进程独立连接)
// 注意:Windows 控制台默认 GBK,provider/modelID 可能含非 GBK 字符 → print 会 UnicodeEncodeError。
// 所有 stdout 输出统一走 sys.stdout.buffer.write(s.encode('utf-8'))。
const DB_EXEC_SRC = `
import sqlite3, json, sys, os, hashlib
from datetime import datetime
def out(s):
sys.stdout.buffer.write(str(s).encode("utf-8"))
DB = os.path.join(os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db")
KEY = "models-dev:catalog"
PRISTINE = os.path.join(os.path.expanduser("~"), ".config", "opencode", "model-manager.pristine.json")
def load():
con = sqlite3.connect(DB, timeout=30)
row = con.execute("SELECT value FROM kv WHERE key=?", (KEY,)).fetchone()
con.close()
if not row:
out("catalog row missing"); sys.exit(0)
return json.loads(row[0])
def save(cat):
cat["updatedAt"] = int(datetime.now().timestamp() * 1000)
import hashlib as h
cat["digest"] = h.sha256(cat["body"].encode("utf-8")).hexdigest()
con = sqlite3.connect(DB, timeout=30)
con.execute(
"UPDATE kv SET value=?, time_updated=? WHERE key=?",
(json.dumps(cat, ensure_ascii=False, separators=(",", ":")), cat["updatedAt"], KEY),
)
con.commit()
con.close()
def get_provider_models(cat, provider):
body = json.loads(cat["body"])
p = body.get(provider)
if not p or "models" not in p:
return body, None
return body, p
op = sys.argv[1]
payload = json.loads(sys.argv[2])
provider = payload.get("provider", "")
model_id = payload.get("modelID", "")
if op == "disable":
cat = load()
body, p = get_provider_models(cat, provider)
if not p:
out("provider not found: " + provider); sys.exit(0)
if model_id in p["models"]:
p["models"].pop(model_id, None)
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
save(cat)
out("disabled " + provider + "/" + model_id)
else:
out("model not present (already disabled?): " + model_id)
elif op == "disable_all":
# 整厂下架:models 清空(恢复走 reset/enable 从 pristine 拉回)
cat = load()
body, p = get_provider_models(cat, provider)
if not p:
out("provider not found: " + provider); sys.exit(0)
n = len(p["models"])
if n == 0:
out("already empty: " + provider)
else:
p["models"] = {}
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
save(cat)
out("disabled_all " + provider + " n=" + str(n))
elif op == "enable":
# 从 pristine 恢复该 model
cat = load()
body, p = get_provider_models(cat, provider)
prist = json.load(open(PRISTINE, encoding="utf-8"))
pbody = json.loads(prist["body"])
pmodels = pbody.get(provider, {}).get("models", {})
if model_id in pmodels:
if p is None:
body[provider] = {"id": provider, "models": {model_id: pmodels[model_id]}}
else:
p["models"][model_id] = pmodels[model_id]
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
save(cat)
out("enabled " + provider + "/" + model_id)
else:
out("model not in pristine: " + model_id)
elif op == "reset":
# 整个 provider 的 models 换回 pristine
cat = load()
prist = json.load(open(PRISTINE, encoding="utf-8"))
body = json.loads(cat["body"])
pbody = json.loads(prist["body"])
if provider in pbody:
body[provider] = pbody[provider]
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
save(cat)
out("reset " + provider)
else:
out("provider not in pristine: " + provider)
elif op == "remove_provider":
# 真删除:把整个 provider 节点从 catalog body 里摘掉(/models 里该厂彻底消失)
# 恢复靠 reset(pristine 里有完整节点)
cat = load()
body = json.loads(cat["body"])
if provider not in body:
out("provider not found: " + provider); sys.exit(0)
n = len(body[provider].get("models", {}))
body.pop(provider, None)
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
save(cat)
out("removed_provider " + provider + " models=" + str(n))
else:
out("unknown op: " + op)
`
// 把 DB 执行器落盘(首次 setup 时写一次,幂等)
function ensureDbExec(): void {
// 内容感知落盘(2026-10-20 修):旧版只判 existsSync → 插件升级后磁盘残留旧执行器,
// 新 op(disable_all/remove_provider)打过去报 unknown op,DB 没写却照报「已重载」= 假成功。
try {
let cur: string | null = null
if (existsSync(DB_EXEC)) {
try {
cur = readFileSync(DB_EXEC, "utf-8")
} catch {
cur = null
}
}
if (cur !== DB_EXEC_SRC) {
writeFileSync(DB_EXEC, DB_EXEC_SRC, "utf-8")
fileLog(cur === null ? "DB 执行器已落盘 " + DB_EXEC : "DB 执行器已更新(磁盘为旧版本,已强制重写)")
}
} catch (e: any) {
fileLog("DB 执行器落盘失败: " + (e && e.message ? e.message : String(e)))
}
}
// 出厂备份:setup 时就拍一次(幂等)。
// 为什么提前拍:若等到第一次 reset/remove_provider 才拍,那时的 catalog 可能已被
// 改过(模型被删过),拍到的就不是「出厂态」,恢复出来的会缺模型。
async function ensurePristine(): Promise<void> {
try {
if (existsSync(PRISTINE)) return
const cat = await readCatalog()
if (!cat || !cat.body) {
fileLog("pristine 暂未建立(catalog 读不到,稍后重试)")
return
}
writeFileSync(PRISTINE, JSON.stringify(cat))
fileLog("pristine 出厂备份已建立(setup 阶段)")
} catch (e: any) {
fileLog("pristine 建立失败(已吞掉): " + (e && e.message ? e.message : String(e)))
}
}
// 读 catalog 的 time_updated(单值,便宜;用于判断 OC 是否刷新覆盖过 catalog)
async function readCatalogTime(): Promise<number> {
return new Promise((resolve) => {
try {
const py = process.platform === "win32" ? "python" : "python3"
const script =
"import sqlite3,os,sys;" +
"db=os.path.join(os.path.expanduser('~'),'.local','share','opencode','opencode.db');" +
"con=sqlite3.connect(db);" +
"row=con.execute(\"SELECT time_updated FROM kv WHERE key='models-dev:catalog'\").fetchone();" +
"con.close();" +
"sys.stdout.buffer.write(str(row[0] if row else 0).encode())"
const r = spawn(py, ["-c", script], { stdio: ["ignore", "pipe", "pipe"] })
let out = ""
r.stdout.on("data", (d) => (out += d.toString("utf-8")))
r.on("close", () => resolve(parseInt(out.trim(), 10) || 0))
r.on("error", () => resolve(0))
} catch {
resolve(0)
}
})
}
// ── 对账重放:持久化停用的正主 ────────────────────────────────────────────
// OC 会从 models.dev 重新拉取并整体覆盖 kv catalog(2026-10-19 18:05 实测:
// updatedAt 被刷新、xiaomi 9 模型全部回潮)。本函数按
// ① cfg 的「以下是停用列表」(provider 级,通配)② modelID 级镜像
// 把回潮的停用项重新压掉,写完再 finishBatch 触发 TUI 热重载。
let recLastTime = -1
let recTicks = 0
async function reconcile(ctx: any): Promise<void> {
ensureCfg()
const stopList = readStopList()
const mirror = loadMirror()
const hasMirror = Object.values(mirror).some((ids) => ids.length > 0)
if (stopList.length === 0 && !hasMirror) return
recTicks++
const t = await readCatalogTime()
const force = recTicks % 12 === 0 // 每 ~60s 强制全量对账一次,兜底 time_updated 不可靠
if (!force && t === recLastTime && t !== 0) return // catalog 没被写过 → 不花 6.5MB 解析
recLastTime = t
const cat = await readCatalog()
if (!cat || !cat.body) return
let body: any
try {
body = typeof cat.body === "string" ? JSON.parse(cat.body) : cat.body
} catch {
return
}
const plan: Array<{ op: string; payload: any }> = []
for (const id of Object.keys(body)) {
const p = body[id] || {}
const name = p.name || ""
const models = p.models || {}
// cfg 停用列表命中 → 真删除厂商节点(不能只清空 models:OC 刷新 catalog 会把
// provider 节点连同模型一起加回来,只有摘掉节点才算真删除)
if (stopList.length && stopListHits(stopList, id, name)) {
plan.push({ op: "remove_provider", payload: { provider: id } })
continue
}
// modelID 级镜像命中 → 只摘掉那一个模型
const ids = mirror[id]
if (ids && ids.length) {
for (const mid of ids) {
if (models[mid]) plan.push({ op: "disable", payload: { provider: id, modelID: mid } })
}
}
}
if (plan.length === 0) return
fileLog(`回潮检测:OC 刷新 catalog 使 ${plan.length} 项停用失效,开始重放压制(cfg=${stopList.length} 项 provider 级 + 镜像 modelID 级)`)
for (const step of plan) {
const r = await execDbWrite(step.op, step.payload)
fileLog(`重放 ${step.op} ${JSON.stringify(step.payload)}: ${r}`)
}
recLastTime = await readCatalogTime()
await finishBatch(ctx, "reconcile")
}
export const ModelManager: any = async (_ctx: any) => (await ({} as any), undefined as any)
export default {
id: "model-manager",
async setup(ctx: any): Promise<void> {
try {
fileLog("model-manager setup 启动")
// ctx 形态诊断(一次性):回答「为什么进程内 reload 无通道」——
// 2026-10-19 实测 keys 里没有 client/serverUrl/location,热重载只能靠 TUI 侧
try {
const c: any = ctx
fileLog(
"setup ctx 诊断: keys=" +
JSON.stringify(Object.keys(c || {})) +
" | client=" + typeof (c && c.client) +
" | location=" + typeof (c && c.location) +
" | serverUrl=" + typeof (c && c.serverUrl) +
" | app=" + typeof (c && c.app),
)
} catch {
/* 诊断失败不影响功能 */
}
ensureDbExec()
ensureCfg() // 防御:OC 更新删掉 cfg 时立即释放新配置(含停用列表镜像恢复)
await ensurePristine() // 出厂备份提前拍,避免首次操作时 catalog 已被改
// 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁)+ 对账压制回潮
let stopped = false
const daemon = setInterval(async () => {
if (stopped) return
try {
await processOutbox(ctx)
} catch (e: any) {
fileLog("daemon tick 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
try {
await reconcile(ctx)
} catch (e: any) {
fileLog("reconcile tick 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
}, 5000)
;(ctx as any).__modelManagerDaemon = daemon // 挂到 ctx,便于外部清理
// 暴露操作入口(供 TUI 插件 / 外部脚本调用)
;(ctx as any).modelManager = {
async disable(provider: string, modelID: string) {
const line = JSON.stringify({ op: "disable", payload: { provider, modelID }, at: Date.now() })
appendFileSync(OUTBOX, line + "\n")
fileLog("disable 入队 " + provider + "/" + modelID)
},
async disableAll(provider: string) {
const line = JSON.stringify({ op: "disable_all", payload: { provider }, at: Date.now() })
appendFileSync(OUTBOX, line + "\n")
fileLog("disable_all 入队 " + provider)
},
async enable(provider: string, modelID: string) {
const line = JSON.stringify({ op: "enable", payload: { provider, modelID }, at: Date.now() })
appendFileSync(OUTBOX, line + "\n")
fileLog("enable 入队 " + provider + "/" + modelID)
},
async reset(provider: string) {
const line = JSON.stringify({ op: "reset", payload: { provider }, at: Date.now() })
appendFileSync(OUTBOX, line + "\n")
fileLog("reset 入队 " + provider)
},
}
fileLog("model-manager daemon 已启动(outbox 轮询 5s)")
} catch (e: any) {
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
}
// setup 必须返回 void
return
},
}