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

333 lines
13 KiB
Python

# -*- 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"),
]
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: 返回 (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 生效份插件 -> {dst_path: base64}。"""
blob = {}
for src, dst in PLUGINS:
if not os.path.exists(src):
continue
try:
blob[dst] = base64.b64encode(open(src, "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'")
cur.execute("INSERT INTO kv (key, value, time_updated) VALUES (?,?,?)",
("models-dev:catalog", catalog_raw, int(time.time())))
res["steps"].append("catalog-cleared+overwritten")
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 = c
cols = [k for k in row.keys() if k in cred_cols]
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)