Files
oc_session/tools/oc-model-sync.py
T

400 lines
17 KiB
Python
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.
# -*- coding: utf-8 -*-
"""
oc-model-sync.py - 模型数据同步: 本机(Win) -> WSL。
收尸链路 step 6a 调用(在 session-sync 之前)。设计(2026-10-02; 2026-10-12; 2026-10-20 二次重定位):
★ 2026-10-20 二次修正(用户实锤「大错特错」): 上一版把数据推到 WSL 的 *config provider 文件*
(.oc-model-sync.json / opencode.json), 但 OC 真正读的是三处不同数据源:
① /models 主列表 <- DB catalog (models-dev:catalog)
② /manage 面板 <- config provider (opencode.json) ∩ catalog ∩ credential
③ xiaomi 原厂模型 <- credential 直连(active 的 integration), OC 动态拉取
推错树 = /models 不变、/manage 只显示 1/localhost 两个 config 源、xiaomi 读不出。
且「只增不删」合并让你 cc-switch 已移除的模型在 WSL 残留。
本版(彻底对齐 Win 现状):
- 数据源 = Win 的 opencode.db(唯一真源):
catalog: kv 表 key='models-dev:catalog'(OC 动态拉取的全部 provider/models)
credential: credential 表全量(含 xiaomi 原厂密钥, 决定 /manage 能否命中 xiaomi)
model.json: ~/.local/state/opencode/model.json (recent/favorite 使用历史)
- 传输: ① catalog(6.6MB) 推远端 ② credential(小) 推远端 ③ model.json 推远端
④ 插件 blob(tui.js v12 / model-manager.ts / mcpcfg.txt) 推远端
- WSL import(★ 覆盖式, 删旧加新):
* 备份 WSL db -> /root/.local/share/opencode/opencode.db.bak-modelsync
* 覆盖 WSL 的 models-dev:catalog = Win catalog(WSL /models 与 Win 完全一致, 已删的厂商消失)
* 覆盖 WSL credential = Win credential(保留 WSL 独有的 active credential? 否: 镜像 Win)
* 写 WSL model.json = Win model.json(recent/favorite 对齐)
* 写插件到 WSL 生效路径(修 /mcpon /mcpoff /manage 失效)
* 任何一步失败 -> 回滚(用备份恢复 WSL db), 绝不写坏 WSL
- 幂等: 指纹 = Win catalog sha + credential sha + model.json sha + 插件 sha, 无变化秒退
- 预算 150s; 异常吞掉只写日志([model-sync] 前缀), 绝不阻塞退出链路
"""
import base64
import hashlib
import io
import json
import os
import sqlite3
import subprocess
import sys
import time
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
TAG = "[model-sync]"
HOME = os.path.expanduser("~")
WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db")
WIN_STATE = os.path.join(HOME, ".local", "state", "opencode", "model.json")
STATE = os.path.join(HOME, ".config", "opencode", ".oc_model_state.json")
LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log")
DISTRO = "Ubuntu-24.04"
BUDGET = 150
REMOTE_CATALOG = "/root/.oc_catalog.json"
REMOTE_CREDS = "/root/.oc_creds.json"
REMOTE_STATE = "/root/.oc_state.json"
REMOTE_PLUGINS = "/root/.oc_plugins.json"
REMOTE_RESULT = "/root/.oc_model_result.json"
# Win 生效份插件 -> WSL 生效路径(修「插件对不上」: WSL 落后导致 /mcpon /mcpoff /manage 失效)
PLUGINS = [
(os.path.join(HOME, ".config", "opencode", "plugins", "oc-session-all", "tui.js"),
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
(os.path.join(HOME, ".config", "opencode", "plugins", "model-manager.ts"),
"/root/.config/opencode/plugins/model-manager.ts"),
(os.path.join(HOME, ".config", "opencode", "mcpcfg.txt"),
"/root/.config/opencode/mcpcfg.txt"),
# ── 2026-10-20 治本:provider + MCP 配置同步(替代 model-manager 压制清单)──
# 事实定论(用户纠正):
# ① cc-switch 只往 opencode.json 写(增删模型时写 provider 字段),完全不碰 opencode.json.2
# ② opencode.json.2 是用户自己从 opencode.json 复制来的副本,给 ai-mcp 对调机制用,非 cc-switch 管
# ③ MCP server 手动加在 opencode.json,不能用 cc-switch 添加
# 同步策略:
# 推 opencode.json -> /root/.config/opencode/opencode.json(provider + MCP 完整同步)
# cc-switch 删/加模型改 opencode.json.provider -> 收尸同步 -> WSL 一致,无需手动压制
# 注意:opencode.json 不能进 build_plugin_blob 的「Win 路径转 WSL 路径」逻辑(provider 是相对 key,无绝对路径)
(os.path.join(HOME, ".config", "opencode", "opencode.json"),
"/root/.config/opencode/opencode.json"),
(os.path.join(HOME, ".config", "opencode", "model-manager.cfg.json"),
"/root/.config/opencode/model-manager.cfg.json"),
]
def log(m):
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m))
except Exception:
pass
def fingerprint():
"""Win 侧数据源 + 插件 的内容指纹(全量 sha, 任何变都触发重推)。"""
h = hashlib.sha256()
try:
h.update(hashlib.sha256(open(WIN_DB, "rb").read()).digest())
except Exception:
h.update(b"no-win-db")
try:
h.update(hashlib.sha256(open(WIN_STATE, "rb").read()).digest())
except Exception:
h.update(b"no-win-state")
for src, _ in PLUGINS:
try:
h.update(hashlib.sha256(open(src, "rb").read()).digest())
except Exception:
h.update(b"no-plugin")
return h.hexdigest()
def read_win_data():
"""只读 Win db + opencode.json.2:返回 (catalog_value, credentials_json, n_creds)。"""
import sqlite3 as _s
con = _s.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
try:
cur = con.cursor()
row = cur.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone()
catalog = row[0] if row else None
cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
cre_rows = cur.execute("SELECT * FROM credential").fetchall()
creds = [dict(zip(cols, r)) for r in cre_rows]
finally:
con.close()
return catalog, creds
def build_plugin_blob():
"""Win 生效份插件 + opencode.json -> {dst_path: base64}。
mcpcfg.txt 特殊处理:把 Win 绝对路径(反斜杠)转 WSL 绝对路径。
model-manager 压制清单:Win 原样推(Win 空则 WSL 也空)。
注意:opencode.json 的 provider 是相对 key(如 '1'),无绝对路径,直接 base64 推。
"""
blob = {}
# Win 侧 mcpcfg.txt 里是 C://Users//ypshidifu//.config//opencode// 前缀(单反斜杠)
win_prefix_bk = "C:" + chr(92) + "Users" + chr(92) + "ypshidifu" + chr(92) + ".config" + chr(92) + "opencode" + chr(92)
win_prefix_sl = "C:/Users/ypshidifu/.config/opencode/"
wsl_prefix = "/root/.config/opencode/"
for src, dst in PLUGINS:
if not os.path.exists(src):
continue
try:
data = open(src, "rb").read()
# mcpcfg.txt:Win 路径前缀 -> WSL 路径前缀
if os.path.basename(src) == "mcpcfg.txt":
text = data.decode("utf-8")
text = text.replace(win_prefix_bk, wsl_prefix)
text = text.replace(win_prefix_sl, wsl_prefix)
data = text.encode("utf-8")
blob[dst] = base64.b64encode(data).decode("ascii")
except Exception:
pass
# model-manager 压制清单(Win 原样推,Win 空则 WSL 也空)
for fname in ["model-manager.removed.json", "model-manager.disabled.json",
"model-manager.stoplist.bak.json"]:
fsrc = os.path.join(HOME, ".config", "opencode", fname)
fdst = "/root/.config/opencode/" + fname
if os.path.exists(fsrc):
try:
blob[fdst] = base64.b64encode(open(fsrc, "rb").read()).decode("ascii")
except Exception:
pass
return blob
def wsl_push(data_bytes, remote_path, timeout=150):
cmd = 'cat > "%s"' % remote_path
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
input=data_bytes,
capture_output=True,
timeout=timeout,
)
return r.returncode
def wsl_py(src, timeout=150):
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "python3", "-"],
input=src.encode("utf-8"),
capture_output=True,
timeout=timeout,
)
return r.returncode, r.stdout
def wsl_cat(remote_path, timeout=60):
r = subprocess.run(
["wsl.exe", "-d", DISTRO, "--", "cat", remote_path],
capture_output=True,
timeout=timeout,
)
raw = r.stdout or b""
if raw[:1] == b"{":
return raw.decode("utf-8", errors="replace")
try:
t = raw.decode("utf-16-le")
if "{" in t:
return t[t.index("{"):]
except Exception:
pass
return raw.decode("utf-8", errors="replace")
# ── WSL 侧 import 脚本(★ 覆盖式 + 备份回滚)────────────────────────────────
IMPORT_SRC = r'''
import json, os, sqlite3, shutil, time, base64
res = {"ok": False, "steps": []}
try:
WSL_DB = "/root/.local/share/opencode/opencode.db"
WSL_STATE = "/root/.local/state/opencode/model.json"
BACKUP = WSL_DB + ".bak-modelsync"
catalog_raw = open("/root/.oc_catalog.json", encoding="utf-8").read()
creds = json.load(open("/root/.oc_creds.json", encoding="utf-8"))
state = open("/root/.oc_state.json", encoding="utf-8").read()
plugin_blob = json.load(open("/root/.oc_plugins.json", encoding="utf-8"))
# ① 备份 WSL db(覆盖写前必做)
if os.path.exists(WSL_DB):
shutil.copy2(WSL_DB, BACKUP)
res["steps"].append("backup:" + BACKUP)
else:
res["steps"].append("no-wsl-db")
# ② 覆盖 catalog + credential(删旧加新, 镜像 Win)
con = sqlite3.connect(WSL_DB)
cur = con.cursor()
# 表缺失才建(按 OC 真实 schema: credential.id 是 TEXT, 不是 INTEGER)
cur.execute("""CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY, value TEXT, time_updated INTEGER)""")
cur.execute("""CREATE TABLE IF NOT EXISTS credential (
id TEXT, integration_id TEXT, label TEXT, value TEXT,
connector_id TEXT, method_id TEXT, active INTEGER,
time_created INTEGER, time_updated INTEGER)""")
if catalog_raw:
cur.execute("DELETE FROM kv WHERE key='models-dev:catalog'")
# ★ 按 WSL kv 表实际列动态插入(补 time_created 等, 避免 NOT NULL 约束失败)
kv_cols = [r[1] for r in cur.execute("PRAGMA table_info(kv)")]
now = int(time.time())
row = {"key": "models-dev:catalog", "value": catalog_raw,
"time_created": now, "time_updated": now}
cols = [k for k in row.keys() if k in kv_cols]
# WSL kv 有但上面没给的列 -> 补 NULL/0
for k in kv_cols:
if k not in row:
row[k] = now if k.startswith("time_") else None
cols.append(k)
cur.execute(
"INSERT INTO kv (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
[row[k] for k in cols])
res["steps"].append("catalog-cleared+overwritten(cols=%s)" % cols)
else:
res["steps"].append("catalog-missing-skip")
# credential: 并集语义 —— Win 的 credential 整份灌入, 但 WSL 独有的 integration_id
# 保留不动(不破坏 WSL 本地连的别家厂商); Win 同名的按 Win 覆盖(值/active 以 Win 为准)。
cred_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
win_by_integ = {}
for c in creds:
win_by_integ[c.get("integration_id")] = c
existing = cur.execute("SELECT * FROM credential").fetchall()
ex_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
wsl_only_integs = []
for r in existing:
ed = dict(zip(ex_cols, r))
integ = ed.get("integration_id")
if integ in win_by_integ:
# WSL 同名的删掉, 下面用 Win 值重灌
cur.execute("DELETE FROM credential WHERE id=?", (ed.get("id"),))
else:
wsl_only_integs.append(integ) # WSL 独有, 保留
for integ, c in win_by_integ.items():
row = dict(c)
cols = [k for k in row.keys() if k in cred_cols]
# WSL credential 有但 Win 没给的列 -> 按类型补默认值, 避免 NOT NULL 约束失败
for k in cred_cols:
if k not in row:
if k.startswith("time_"):
row[k] = int(time.time())
elif k == "active":
row[k] = 0
elif k == "id":
# 无 id 或 id 缺失时生成稳定 id(避免 PRIMARY KEY 冲突与 NOT NULL)
row[k] = "win_" + (integ or "unknown").replace("/", "_")
else:
row[k] = None
cols.append(k)
# label 是 NOT NULL:Win 可能给空串/None,确保非 None
if row.get("label") is None:
row["label"] = row.get("id") or row.get("integration_id") or "win"
if not cols:
continue
# 防重复: 若 WSL 里已存在同名(可能上面没删净), 先清
cur.execute("DELETE FROM credential WHERE integration_id=?", (integ,))
cur.execute(
"INSERT INTO credential (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
[row.get(k) for k in cols])
con.commit()
con.close()
res["steps"].append("credential-merged(win=%d, wsl_only_kept=%s)" %
(len(win_by_integ), wsl_only_integs))
# ③ 写 model.json(recent/favorite 对齐)
try:
os.makedirs(os.path.dirname(WSL_STATE), exist_ok=True)
open(WSL_STATE, "w", encoding="utf-8").write(state)
res["steps"].append("model.json-overwritten")
except Exception as e:
res["steps"].append("model.json-err:" + str(e))
# ④ 写插件(修 /mcpon /mcpoff /manage 失效)
for dst, b64 in plugin_blob.items():
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, "wb").write(base64.b64decode(b64))
except Exception as e:
res["steps"].append("plugin-err:" + dst + ":" + str(e))
res["steps"].append("plugins-written(%d)" % len(plugin_blob))
res["ok"] = True
except Exception as e:
# 任何一步失败 -> 回滚 WSL db
res["err"] = str(e)
try:
if os.path.exists(BACKUP) and os.path.exists(WSL_DB):
shutil.copy2(BACKUP, WSL_DB)
res["rolled_back"] = True
res["steps"].append("ROLLBACK:" + BACKUP)
except Exception as e2:
res["rollback_err"] = str(e2)
open("/root/.oc_model_result.json", "w", encoding="utf-8").write(json.dumps(res))
'''
def main():
t0 = time.time()
fp = fingerprint()
try:
state = json.load(open(STATE, encoding="utf-8")).get("fp")
except Exception:
state = None
if state == fp:
log("unchanged, skip (fp=%s)" % fp[:12])
return
if not os.path.exists(WIN_DB):
log("win db missing: " + WIN_DB)
return
catalog, creds = read_win_data()
log("export: catalog=%dB credentials=%d (fp=%s)" %
((len(catalog) if catalog else 0), len(creds), fp[:12]))
if not catalog:
log("no catalog in win db, nothing to push")
return
# 推 catalog(raw 字符串)
try:
rc = wsl_push(catalog.encode("utf-8"), REMOTE_CATALOG, timeout=200)
if rc != 0:
log("catalog push rc=%d" % rc)
return
# 推 credentials
rc = wsl_push(json.dumps(creds, ensure_ascii=False).encode("utf-8"),
REMOTE_CREDS, timeout=120)
if rc != 0:
log("creds push rc=%d" % rc)
return
# 推 model.json
rc = 0
if os.path.exists(WIN_STATE):
rc = wsl_push(open(WIN_STATE, "rb").read(), REMOTE_STATE, timeout=120)
if rc != 0:
log("state push rc=%d" % rc)
return
# 推插件 blob
rc = wsl_push(json.dumps(build_plugin_blob(), ensure_ascii=False).encode("utf-8"),
REMOTE_PLUGINS, timeout=120)
if rc != 0:
log("plugins push rc=%d" % rc)
return
# 跑 import(覆盖 WSL db + model.json + 插件, 带备份回滚)
rc, _ = wsl_py(IMPORT_SRC, timeout=200)
if rc != 0:
log("import rc=%d" % rc)
return
res = json.loads(wsl_cat(REMOTE_RESULT))
log("import result: " + json.dumps(res, ensure_ascii=False)[:400])
json.dump({"fp": fp}, open(STATE, "w", encoding="utf-8"))
log("state updated in %ds" % int(time.time() - t0))
except Exception as e:
log("exception: " + str(e))
if __name__ == "__main__":
try:
main()
except Exception as e:
log("FATAL: " + str(e))
sys.exit(0)