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

386 lines
16 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 配置同步(替代 model-manager 压制清单)──
# cc-switch 把活跃 provider(如 agnes)注入到 opencode.json.2;
# 把整个 opencode.json.2 推给 WSL,让 WSL OC 读到一致的 provider 状态。
# 同步后 WSL 侧的 model-manager 不再需要 removed/disabled 清单压制 agnes,
# 因为 cc-switch 删 agnes → Win opencode.json.2 更新 → WSL 同步 → agnes 消失。
(os.path.join(HOME, ".config", "opencode", "opencode.json.2"),
"/root/.config/opencode/opencode.json.2"),
(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.2 -> {dst_path: base64}。
额外:把 WSL 侧 model-manager 压制清单(removed/disabled/stoplist.bak)重置为空,
因为 opencode.json.2 里 provider 已同步到 WSL,不再需要靠压制清单删厂商。
若 Win 侧清单非空(比如用户还在用 model-manager 手动压制某厂商),
则把 Win 的清单同步过去,保持两边压制行为一致。
"""
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
# 把 model-manager 压制清单也同步(Win 非空就推,空就推空,保证 WSL 与 Win 对齐)
for fname in ["model-manager.removed.json", "model-manager.disabled.json",
"model-manager.stoplist.bak.json"]:
src = os.path.join(HOME, ".config", "opencode", fname)
dst = "/root/.config/opencode/" + fname
if os.path.exists(src):
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'")
# ★ 按 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)