codex-model-change 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/cx.py +74 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,8 +50,8 @@ cd codex-model-change
50
50
  | `cx doctor` | 体检:key 有效性 / 直连连通性 / 会话健康 |
51
51
  | `cx fix <会话ID>` | 修复某个打不开/报 404 的会话(ID 取 `cx status` 里显示的前 8 位即可) |
52
52
  | `cx fix last` | 修复最近一个会话 |
53
- | `cx fix-all <目标>` | **批量把所有老会话切换到目标模型**(`deepseek` 或 `gpt`,需退 App);切到 gpt 时自动清理旧代理遗留的不兼容历史条目(`reasoning.content` 等),避免续聊报 `Invalid 'input[..].content'` |
54
- | `cx fix-all deepseek --limit 20` | 只批量切换最近 20 个会话 |
53
+ | `cx fix-all <目标> [--limit N]` | **批量把所有老会话切换到目标模型**(`deepseek` 或 `gpt`,需退 App);切到 gpt 时自动清理旧代理遗留的不兼容历史条目(`reasoning.content` 等),避免续聊报 `Invalid 'input[..].content'` |
54
+ | `cx fix-all deepseek --limit 10` | 只处理最近 10 个会话(`--limit` 可按需调整,省略则处理全部) |
55
55
 
56
56
  **关于老会话**:每个会话记录着自己创建时的模型,`cx use` 只影响新会话——老会话继续用原模型,互不干扰。想把老会话搬到新模型:单个用 `cx fix`,全部用 `cx fix-all`(会先备份数据库和会话文件,确认后执行)。
57
57
 
package/cx.py CHANGED
@@ -9,12 +9,11 @@
9
9
  cx key <API_KEY> 保存 deepseek key 并注入 GUI 环境(launchctl setenv)
10
10
  cx fix <会话ID> 修复单个会话(续跑一轮写入正确模型)
11
11
  cx fix-all <目标> 把所有老会话批量切换到目标模型(需退 App)
12
- 例: cx fix-all deepseek / cx fix-all gpt --limit 20
13
- cx migrate-sessions 把旧代理时代的 deepseek 会话迁移为直连 id(需退 App)
12
+ 例: cx fix-all deepseek --limit 10 (只改最近 10 个)
14
13
  """
15
14
  import json, os, re, glob, sqlite3, subprocess, sys, shutil, time, urllib.request, urllib.error
16
15
 
17
- CX_VERSION = "1.0.3"
16
+ CX_VERSION = "1.0.5"
18
17
 
19
18
  CODEX_HOME = os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex"))
20
19
  CONFIG = os.path.join(CODEX_HOME, "config.toml")
@@ -35,7 +34,6 @@ wire_api = "responses"
35
34
  """
36
35
 
37
36
  GPT_MODEL = "gpt-5.6-sol"
38
- DS_IDS = ("deepseek/deepseek-v4-flash", "deepseek-v4-flash")
39
37
 
40
38
  HELP = """cx — Codex 模型直连切换器
41
39
 
@@ -49,7 +47,7 @@ HELP = """cx — Codex 模型直连切换器
49
47
  fix <会话ID|last> 修复单个会话(ID 取 status 里显示的前 8 位即可)
50
48
  fix-all <目标> 批量把所有老会话切换到目标模型(需退 App)
51
49
  用法: cx fix-all deepseek|gpt [--limit N]
52
- migrate-sessions 旧代理会话迁移(遗留命令,一般用 fix-all 即可)
50
+ 例: cx fix-all deepseek --limit 10 (只改最近 10 个)
53
51
  version 显示 cx 版本
54
52
  help 显示本帮助
55
53
 
@@ -155,6 +153,62 @@ def clean_ocx_injections(text):
155
153
  out.append(ln)
156
154
  return "\n".join(out)
157
155
 
156
+
157
+ # ---------- App 投影缓存对齐 ----------
158
+ # 背景(踩过的坑,务必保留):
159
+ # rollout 每条记录带顶层 "ordinal" 字段,且 App 要求它从 0 起、逐条 +1 连续。
160
+ # cx fix-all 会两处改动 rollout:
161
+ # 1) rewrite_rollout 重新序列化 turn_context/session_meta → 整文件字节偏移漂移;
162
+ # 2) sanitize_rollout 删除 reasoning 行 → ordinal 出现缺口。
163
+ # 而 App 的投影(thread_history_1.sqlite)按 ordinal + 字节偏移增量推进,一旦遇到缺口
164
+ # 就永久卡死("expected ordinal N, got N+1"),新内容再也进不了投影缓存 ——
165
+ # 表现就是"跑完 fix-all 后重开 App,今天聊的内容消失了"(live 会话内存里其实还在)。
166
+ # 因此 fix-all 必须:先重编号 ordinal 消除缺口,再清掉该会话的投影缓存,
167
+ # 让 App 下次打开时从(已连续的)rollout 完整重建。重建是唯一 100% 安全的做法。
168
+ import re as _re
169
+ _ORD_PAT = _re.compile(rb'"ordinal":\s*(\d+)')
170
+ _ORD_SUB = _re.compile(rb'"ordinal":\s*\d+')
171
+
172
+ def read_ordinals(path):
173
+ """按行序读出每行顶层 ordinal 值(缺失为 None)"""
174
+ out = []
175
+ for ln in open(path, "rb"):
176
+ m = _ORD_PAT.search(ln)
177
+ out.append(int(m.group(1)) if m else None)
178
+ return out
179
+
180
+ def renumber_ordinals(path):
181
+ """把每行 ordinal 重写为行号,消除删行造成的缺口。返回改写行数。
182
+ 正常 rollout 每行都有 ordinal 且原本 ordinal==行号,故以行号为准可完美复原连续性。"""
183
+ lines = open(path, "rb").readlines()
184
+ changed = 0
185
+ for i, ln in enumerate(lines):
186
+ new = _ORD_SUB.sub(b'"ordinal":' + str(i).encode(), ln, count=1)
187
+ if new != ln:
188
+ lines[i] = new; changed += 1
189
+ if changed:
190
+ open(path, "wb").writelines(lines)
191
+ return changed
192
+
193
+ def reset_app_projection(thread_id):
194
+ """清掉 App 对某会话的投影缓存,强制其下次打开时从 rollout 重建。
195
+ rollout 被改写/删行后这是唯一安全做法:避免 ordinal 缺口与字节偏移错位残留。
196
+ (thread_history_projection_state 上有 DELETE 触发器,会连带清 thread_realtime_items)"""
197
+ db_path = os.path.join(CODEX_HOME, "thread_history_1.sqlite")
198
+ if not (thread_id and os.path.exists(db_path)):
199
+ return False
200
+ db = sqlite3.connect(db_path)
201
+ try:
202
+ has = lambda t: db.execute(
203
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (t,)).fetchone()
204
+ for t in ("thread_items", "thread_turns", "thread_history_projection_state"):
205
+ if has(t):
206
+ db.execute(f"DELETE FROM {t} WHERE thread_id=?", (thread_id,))
207
+ db.commit()
208
+ return True
209
+ finally:
210
+ db.close()
211
+
158
212
  # ---------- 命令 ----------
159
213
  def cmd_use(which):
160
214
  if which not in ("deepseek", "gpt"): err("用法: cx use deepseek|gpt")
@@ -263,9 +317,6 @@ def cmd_fix():
263
317
  print(tail)
264
318
  ok("修复完成") if r.returncode == 0 else err(f"修复失败 exit={r.returncode}")
265
319
 
266
- def is_ds(model):
267
- return isinstance(model, str) and (model in DS_IDS or model.startswith("deepseek"))
268
-
269
320
  # ---------- 批量切换老会话 ----------
270
321
  def sanitize_rollout(path):
271
322
  """移除旧代理时代 OpenAI 不兼容的 reasoning 条目(id 为 rs_ocx_*,或带 content 字段)。
@@ -312,11 +363,14 @@ def rewrite_rollout(path, model, provider):
312
363
 
313
364
  def cmd_fix_all():
314
365
  args = sys.argv[2:]
315
- which = args[0] if args else ""
366
+ which = args[0] if args and not args[0].startswith("-") else ""
316
367
  limit = None
317
- if "--limit" in args:
318
- i = args.index("--limit")
319
- limit = int(args[i + 1]) if len(args) > i + 1 else None
368
+ for i, a in enumerate(args):
369
+ if a == "--limit" and i + 1 < len(args):
370
+ limit = int(args[i + 1]) if args[i + 1].isdigit() else None
371
+ elif a.startswith("--limit="):
372
+ v = a.split("=", 1)[1]
373
+ limit = int(v) if v.isdigit() else None
320
374
  if which not in ("deepseek", "gpt"):
321
375
  err("用法: cx fix-all deepseek|gpt [--limit N] (--limit N 只改最近 N 个,默认全部)")
322
376
  model, provider = (DS_MODEL, "deepseek") if which == "deepseek" else (GPT_MODEL, "openai")
@@ -337,7 +391,8 @@ def cmd_fix_all():
337
391
  todo.append((tid, m, p, rp, needs_model))
338
392
  if not todo:
339
393
  ok(f"所有会话已经是 {model},无需切换"); db.close(); return
340
- print(f"待切换 {len(todo)} / {len(rows)} 个会话 {model} ({provider})")
394
+ scope = f"最近 {limit} 个中" if limit else ""
395
+ print(f"{scope}待切换 {len(todo)} / {len(rows)} 个会话 → {model} ({provider})")
341
396
  if input("确认执行? [y/N] ").strip().lower() != "y":
342
397
  info("已取消"); db.close(); return
343
398
  ts = time.strftime("%Y%m%d-%H%M%S")
@@ -349,55 +404,20 @@ def cmd_fix_all():
349
404
  if rp and os.path.exists(rp):
350
405
  bakj = rp + f".bak.cx-fixall-{ts}"
351
406
  if not os.path.exists(bakj): shutil.copy2(rp, bakj)
407
+ old_ords = read_ordinals(rp) # 改写前快照(仅用于诊断是否有缺口)
352
408
  if needs_model: rewrite_rollout(rp, model, provider)
353
409
  if provider == "openai": sanitize_rollout(rp)
410
+ # 关键:删行会产生 ordinal 缺口 + 重排会移动字节偏移;必须重编号消除缺口,
411
+ # 并清投影缓存让 App 从 rollout 重建,否则重开 App 后该会话内容会"消失"
412
+ fixed = renumber_ordinals(rp)
413
+ reset_app_projection(tid)
414
+ if fixed: print(f" · {tid[:8]} 重编号 {fixed} 行并重置 App 投影")
354
415
  if needs_model:
355
416
  db.execute("UPDATE threads SET model=?, model_provider=? WHERE id=?", (model, provider, tid))
356
417
  n += 1
357
418
  db.commit(); db.close()
358
419
  ok(f"已切换 {n} 个会话 → {model}。重开 App 生效")
359
420
 
360
- def cmd_migrate():
361
- force = "--yes" in sys.argv
362
- if app_running() and not force:
363
- err("请先完全退出 ChatGPT/Codex App(或加 --yes 跳过检查,不推荐)")
364
- db = sqlite3.connect(os.path.join(CODEX_HOME, "state_5.sqlite"))
365
- rows = db.execute("SELECT id, model, rollout_path FROM threads WHERE model LIKE 'deepseek%'").fetchall()
366
- if not rows: ok("没有需要迁移的会话"); return
367
- ts = time.strftime("%Y%m%d-%H%M%S")
368
- bak = os.path.join(CODEX_HOME, f"state_5.sqlite.bak.cx-migrate-{ts}")
369
- shutil.copy2(os.path.join(CODEX_HOME, "state_5.sqlite"), bak)
370
- ok(f"数据库已备份: {bak}")
371
- n = 0
372
- for tid, model, path in rows:
373
- if os.path.exists(path):
374
- bakj = path + f".bak.cx-{ts}"
375
- if not os.path.exists(bakj): shutil.copy2(path, bakj)
376
- new_lines = []
377
- changed = False
378
- for line in open(path, encoding="utf-8"):
379
- if '"turn_context"' in line or '"session_meta"' in line:
380
- try:
381
- d = json.loads(line)
382
- pl = d.get("payload", {})
383
- if d.get("type") == "session_meta":
384
- if is_ds(pl.get("model")): pl["model"] = DS_MODEL; changed = True
385
- if pl.get("model_provider") == "openai": pl["model_provider"] = "deepseek"; changed = True
386
- elif d.get("type") == "turn_context":
387
- if is_ds(pl.get("model")): pl["model"] = DS_MODEL; changed = True
388
- cm = pl.get("collaboration_mode") or {}
389
- st = cm.get("settings") or {}
390
- if is_ds(st.get("model")): st["model"] = DS_MODEL; changed = True
391
- if changed: line = json.dumps(d, ensure_ascii=False) + "\n"
392
- except Exception: pass
393
- new_lines.append(line)
394
- if changed:
395
- open(path, "w", encoding="utf-8").writelines(new_lines); n += 1
396
- db.execute("UPDATE threads SET model=?, model_provider='deepseek' WHERE model LIKE 'deepseek%'", (DS_MODEL,))
397
- db.commit()
398
- ok(f"已迁移 {n} 个 rollout / {len(rows)} 条记录 → deepseek-chat")
399
- db.close()
400
-
401
421
  def main():
402
422
  if len(sys.argv) < 2: cmd_status(); return
403
423
  c = sys.argv[1]
@@ -407,7 +427,6 @@ def main():
407
427
  elif c == "doctor": cmd_doctor()
408
428
  elif c == "fix": cmd_fix()
409
429
  elif c == "fix-all": cmd_fix_all()
410
- elif c == "migrate-sessions": cmd_migrate()
411
430
  elif c in ("version", "-V", "--version"): print(f"cx {CX_VERSION}")
412
431
  elif c in ("-h", "--help", "help"): print(HELP)
413
432
  else: err(f"未知命令: {c}(cx help 查看用法)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-model-change",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Switch OpenAI Codex (CLI / desktop app) between DeepSeek direct-connect and native ChatGPT. No proxy, no daemon.",
5
5
  "bin": {
6
6
  "cx": "bin/cx.js"