codex-model-change 1.0.0

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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/bin/cx.js +23 -0
  4. package/cx.py +411 -0
  5. package/package.json +37 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 git-sgg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # cx — Codex 模型直连切换器
2
+
3
+ 让 OpenAI Codex(桌面 App / CLI)**直连 DeepSeek**(或切回原生 ChatGPT),无需任何代理软件。
4
+
5
+ `cx` 是一个单文件 Python CLI,无常驻进程、无 launchd 服务依赖(仅一个可选的开机环境变量注入)。
6
+
7
+ ## 工作原理
8
+
9
+ 新版 Codex 已移除 `wire_api = "chat"`,但其 `model_provider` 机制原生支持 **Responses API**。DeepSeek 官方 API(`https://api.deepseek.com/v1`)已支持 Responses 协议,因此:
10
+
11
+ ```
12
+ Codex ──直连──> api.deepseek.com (cx 配置一条 model_provider)
13
+ Codex ──原生──> ChatGPT 登录态 (cx 注释掉 model_provider 即回落)
14
+ ```
15
+
16
+ 中间没有任何协议翻译层,保持协议原生匹配。
17
+
18
+ ## 安装
19
+
20
+ **方式一:npm 安装(推荐)**
21
+
22
+ ```bash
23
+ npm install -g codex-model-change
24
+ ```
25
+
26
+ 包内是零依赖的 Python 脚本(macOS 自带 python3),Node 只做入口转发,装完即可用 `cx` 命令。
27
+
28
+ **方式二:源码安装**
29
+
30
+ ```bash
31
+ # 1. 下载或 clone 本仓库
32
+ git clone https://github.com/git-sgg/codex-model-change.git
33
+ cd codex-model-change
34
+
35
+ # 2. 运行安装脚本(复制到 PATH)
36
+ ./install.sh # 默认装到 /usr/local/bin/cx
37
+ # 或指定位置: ./install.sh /opt/homebrew/bin/cx
38
+ ```
39
+
40
+ 前置要求:已安装 [OpenAI Codex CLI](https://github.com/openai/codex)(`brew install codex`)并至少成功运行过一次(`~/.codex/config.toml` 存在)。
41
+
42
+ ## 命令
43
+
44
+ | 命令 | 作用 |
45
+ |---|---|
46
+ | `cx status` | 查看默认模型 / provider / 最近会话各自用的模型 |
47
+ | `cx use deepseek` | 切换到 DeepSeek 直连(改写 `~/.codex/config.toml`,自动备份) |
48
+ | `cx use gpt` | 切回原生 ChatGPT(走 ChatGPT 登录态,无需 API key) |
49
+ | `cx key <API_KEY>` | 保存 DeepSeek key 并注入 GUI 环境(桌面 App 需要) |
50
+ | `cx doctor` | 体检:key 有效性 / 直连连通性 / 会话健康 |
51
+ | `cx fix <会话ID>` | 修复某个打不开/报 404 的会话(ID 取 `cx status` 里显示的前 8 位即可) |
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 个会话 |
55
+
56
+ **关于老会话**:每个会话记录着自己创建时的模型,`cx use` 只影响新会话——老会话继续用原模型,互不干扰。想把老会话搬到新模型:单个用 `cx fix`,全部用 `cx fix-all`(会先备份数据库和会话文件,确认后执行)。
57
+
58
+ 每次 `cx use` 都会自动备份 `config.toml`(`config.toml.bak.cx-<时间戳>`),随时可手动回滚。
59
+
60
+ ## 从零添加 DeepSeek(Codex 里还没有 DeepSeek 时)
61
+
62
+ 如果你的 Codex 从未配置过 DeepSeek(`~/.codex/config.toml` 里没有 `[model_providers.deepseek]`),按下面四步走,**全程不需要手改任何配置文件**:
63
+
64
+ **第 1 步:获取 DeepSeek API key**
65
+
66
+ 到 [platform.deepseek.com](https://platform.deepseek.com) 注册/登录,在「API Keys」页面创建一个 key(`sk-` 开头),并确保账户有余额。
67
+
68
+ **第 2 步:安装 cx**(见上方「安装」)
69
+
70
+ **第 3 步:保存 key 并切换**
71
+
72
+ ```bash
73
+ cx key sk-你的key
74
+ cx use deepseek
75
+ ```
76
+
77
+ `cx key` 会把 key 存到本机 `~/.cx/deepseek.key` 并注入 GUI 环境;`cx use deepseek` 会自动在 `~/.codex/config.toml` 末尾写入完整的 provider 配置:
78
+
79
+ ```toml
80
+ # --- added by cx (direct deepseek) ---
81
+ [model_providers.deepseek]
82
+ name = "DeepSeek"
83
+ base_url = "https://api.deepseek.com/v1"
84
+ env_key = "DEEPSEEK_API_KEY"
85
+ wire_api = "responses"
86
+ ```
87
+
88
+ 并把顶部改为 `model_provider = "deepseek"` / `model = "deepseek-chat"`,同时生成模型目录 `~/.codex/cx-catalog.json`。
89
+
90
+ **第 4 步:完成桌面 App 配置**(见下方「桌面 App 的一次性配置」),然后完全退出并重开 App。
91
+
92
+ 验证:跑 `cx doctor`,三项全 ✅ 即配置成功;或在 App 里发条消息试试。
93
+
94
+ > 如果 `cx use deepseek` 报「找不到 config.toml」,说明你还没运行过 codex CLI——先随便跑一次 `codex "hello"` 让它生成配置文件,再执行上面的步骤。
95
+
96
+ ## 桌面 App 的一次性配置
97
+
98
+ Codex 桌面 App 由 launchd 拉起,读不到 shell 里的环境变量,需要把 key 注入 GUI 环境:
99
+
100
+ ```bash
101
+ launchctl setenv DEEPSEEK_API_KEY "$(cat ~/.cx/deepseek.key)"
102
+ ```
103
+
104
+ `cx key` 会自动装一个 LaunchAgent(`~/Library/LaunchAgents/com.cx.codex-setenv.plist`),**下次重启登录时自动完成上述注入**,所以手动只需执行一次。
105
+
106
+ 之后**完全退出并重开 Codex App** 即可。App 的模型选择器里会出现 DeepSeek Chat / DeepSeek Reasoner。
107
+
108
+ > **从其它代理工具迁移过来的用户**:如果你的老会话记录的是代理别名(如 `deepseek/deepseek-v4-flash`),直连后无法续聊。先完全退出 App,然后执行 `cx fix-all deepseek` 一次性把所有会话迁移为直连格式。
109
+
110
+ ## 常见问题
111
+
112
+ **Q: 切换/迁移后 App 里还是报错?**
113
+ 桌面 App 启动时读取配置,必须**完全退出**(⌘Q,不是关窗口)再打开。
114
+
115
+ **Q: 终端里 codex 能用,桌面 App 报 key 错误?**
116
+ App 没读到 `DEEPSEEK_API_KEY`。执行上面「桌面 App 的一次性配置」后重启 App。
117
+
118
+ **Q: 老会话续聊报 `Model metadata not found` 或 404?**
119
+ 会话自带的模型 id 与当前配置不一致。用 `cx fix <会话UUID>`(可在 `cx status` 里看到会话 UUID)。
120
+
121
+ **Q: `launchctl setenv` 报 `Not privileged to set domain environment`?**
122
+ 必须在你自己登录会话的终端(Terminal.app)里执行,不能通过 ssh/部分自动化环境执行。
123
+
124
+ **Q: 提示 `deepseek 拒绝了 exec 自定义工具` 之类的 400?**
125
+ 模型目录(`~/.codex/cx-catalog.json`)被改坏了,重跑一次 `cx use deepseek` 会重新生成。
126
+
127
+ ## 隐私说明
128
+
129
+ - API key 仅保存在本机 `~/.cx/deepseek.key`(权限 600)和 launchd 环境中,**不写入任何配置文件明文**,不外发。
130
+ - 本工具不收集、不上报任何数据;唯一的网络请求是 `cx doctor` 对 `api.deepseek.com` 的 key 有效性检查。
131
+ - 请求链路:Codex → `api.deepseek.com` 直连(或 Codex 原生链路),无中间人。
132
+
133
+ ## 兼容性
134
+
135
+ - macOS(依赖 `launchctl` 做 GUI 环境变量注入;纯 CLI 使用则不依赖)
136
+ - Codex CLI / ChatGPT 桌面 App(内含 codex 的版本)
137
+ - DeepSeek `deepseek-chat` / `deepseek-reasoner`(Responses API)
138
+
139
+ > 注意:DeepSeek 的上下文窗口(128K 级)与 GPT 不同,cx 生成的模型目录已按 DeepSeek 实际参数配置(截断策略、auto-compact 阈值等),请勿手工改坏后不带备份地覆盖。
140
+
141
+ ## License
142
+
143
+ MIT
package/bin/cx.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cx 的 npm 入口: Node 只负责被 npm 安装和转发参数,
4
+ * 实际逻辑在 cx.py(Python, macOS 自带),无需任何依赖。
5
+ */
6
+ const { spawn } = require("child_process");
7
+ const path = require("path");
8
+
9
+ const script = path.join(__dirname, "..", "cx.py");
10
+ const child = spawn("python3", [script, ...process.argv.slice(2)], {
11
+ stdio: "inherit",
12
+ });
13
+
14
+ child.on("error", (err) => {
15
+ if (err.code === "ENOENT") {
16
+ console.error("❌ 未找到 python3(macOS 自带,若缺失请安装: brew install python3)");
17
+ } else {
18
+ console.error("❌ 启动失败:", err.message);
19
+ }
20
+ process.exit(1);
21
+ });
22
+
23
+ child.on("exit", (code) => process.exit(code ?? 1));
package/cx.py ADDED
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env python3
2
+ """cx — Codex 模型直连切换器
3
+
4
+ 无代理、无常驻进程。Codex 直连 deepseek(Responses API) / 原生 ChatGPT。
5
+ 命令:
6
+ cx status 查看当前模型配置与最近会话
7
+ cx use deepseek|gpt 切换默认模型(改写 ~/.codex/config.toml, 自动备份)
8
+ cx doctor 体检: key/直连/会话健康
9
+ cx key <API_KEY> 保存 deepseek key 并注入 GUI 环境(launchctl setenv)
10
+ cx fix <会话ID> 修复单个会话(续跑一轮写入正确模型)
11
+ cx fix-all <目标> 把所有老会话批量切换到目标模型(需退 App)
12
+ 例: cx fix-all deepseek / cx fix-all gpt --limit 20
13
+ cx migrate-sessions 把旧代理时代的 deepseek 会话迁移为直连 id(需退 App)
14
+ """
15
+ import json, os, re, glob, sqlite3, subprocess, sys, shutil, time, urllib.request, urllib.error
16
+
17
+ CODEX_HOME = os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex"))
18
+ CONFIG = os.path.join(CODEX_HOME, "config.toml")
19
+ CX_DIR = os.path.expanduser("~/.cx")
20
+ KEY_FILE = os.path.join(CX_DIR, "deepseek.key")
21
+ CATALOG = os.path.join(CODEX_HOME, "cx-catalog.json")
22
+ CODEX_BIN = "/opt/homebrew/bin/codex"
23
+ APP_PGREP = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"
24
+
25
+ DS_MODEL = "deepseek-chat"
26
+ DS_PROVIDER_BLOCK = """
27
+ # --- added by cx (direct deepseek) ---
28
+ [model_providers.deepseek]
29
+ name = "DeepSeek"
30
+ base_url = "https://api.deepseek.com/v1"
31
+ env_key = "DEEPSEEK_API_KEY"
32
+ wire_api = "responses"
33
+ """
34
+
35
+ GPT_MODEL = "gpt-5.6-sol"
36
+ DS_IDS = ("deepseek/deepseek-v4-flash", "deepseek-v4-flash")
37
+
38
+ HELP = """cx — Codex 模型直连切换器
39
+
40
+ 用法: cx <命令> [参数]
41
+
42
+ 命令一览:
43
+ status 查看默认模型/provider/最近会话(不带参数执行 cx 等同于此)
44
+ use deepseek|gpt 切换默认模型(改写 config.toml,自动备份)
45
+ key <API_KEY> 保存 deepseek key 并注入 GUI 环境(桌面 App 需要)
46
+ doctor 体检: key 有效性/直连连通性/会话健康
47
+ fix <会话ID|last> 修复单个会话(ID 取 status 里显示的前 8 位即可)
48
+ fix-all <目标> 批量把所有老会话切换到目标模型(需退 App)
49
+ 用法: cx fix-all deepseek|gpt [--limit N]
50
+ migrate-sessions 旧代理会话迁移(遗留命令,一般用 fix-all 即可)
51
+ help 显示本帮助
52
+
53
+ 要点:
54
+ - 切换只影响新会话;老会话各自保持原模型,想搬运用 fix / fix-all
55
+ - 改过配置后,桌面 App 必须完全退出再重开才会生效
56
+ - 仓库: https://github.com/git-sgg/codex-model-change
57
+ """
58
+
59
+ def err(m): print("❌ " + m); sys.exit(1)
60
+ def ok(m): print("✅ " + m)
61
+ def info(m): print("ℹ️ " + m)
62
+
63
+ def read_cfg():
64
+ if not os.path.exists(CONFIG): err(f"找不到 {CONFIG}")
65
+ return open(CONFIG, encoding="utf-8").read()
66
+
67
+ def backup_cfg(tag=""):
68
+ ts = time.strftime("%Y%m%d-%H%M%S")
69
+ dst = f"{CONFIG}.bak.cx-{tag}{ts}"
70
+ shutil.copy2(CONFIG, dst)
71
+ ok(f"已备份: {dst}")
72
+ return dst
73
+
74
+ def load_key():
75
+ if not os.path.exists(KEY_FILE): return None
76
+ return open(KEY_FILE).read().strip() or None
77
+
78
+ # ---------- 目录文件 ----------
79
+ def _ds_entry(slug, name, desc, priority):
80
+ return {
81
+ "slug": slug, "display_name": name, "description": desc,
82
+ "default_reasoning_level": "high",
83
+ "supported_reasoning_levels": [
84
+ {"effort": "low", "description": "Fast responses with lighter reasoning"},
85
+ {"effort": "high", "description": "Greater reasoning depth for complex problems"}],
86
+ "shell_type": "default", "visibility": "list", "supported_in_api": True,
87
+ "priority": priority,
88
+ "base_instructions": f"You are a coding agent powered by {slug}.",
89
+ "include_skills_usage_instructions": False,
90
+ "default_reasoning_summary": "none", "support_verbosity": False, "default_verbosity": "low",
91
+ "apply_patch_tool_type": "freeform", "web_search_tool_type": "text",
92
+ "truncation_policy": {"mode": "tokens", "limit": 10000},
93
+ "supports_parallel_tool_calls": True,
94
+ "supports_image_detail_original": False, "comp_hash": "cx1",
95
+ "effective_context_window_percent": 95,
96
+ "experimental_supported_tools": [], "input_modalities": ["text"],
97
+ "supports_search_tool": False, "tool_mode": "default",
98
+ "node_repl_disabled": True, "node_repl_auto_review_required": False,
99
+ "include_plugin_usage_instructions": False, "include_apps_usage_instructions": False,
100
+ "supports_reasoning_summaries": False, "context_window": 131072,
101
+ "max_context_window": 131072, "auto_compact_token_limit": 118000,
102
+ "auto_review_model_override": None,
103
+ }
104
+
105
+ CATALOG_MODELS = [
106
+ _ds_entry("deepseek-chat", "DeepSeek Chat", "DeepSeek 直连 (deepseek-chat)。", 5),
107
+ _ds_entry("deepseek-reasoner", "DeepSeek Reasoner", "DeepSeek 直连 (deepseek-reasoner)。", 4),
108
+ ]
109
+ for slug in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4"):
110
+ e = _ds_entry(slug, slug, slug, 1)
111
+ e["visibility"] = "hide"
112
+ e["input_modalities"] = ["text", "image"]
113
+ e["context_window"] = e["max_context_window"] = 400000
114
+ e["auto_compact_token_limit"] = 360000
115
+ e["supports_reasoning_summaries"] = True
116
+ CATALOG_MODELS.append(e)
117
+
118
+ def write_catalog():
119
+ with open(CATALOG, "w", encoding="utf-8") as f:
120
+ json.dump({"models": CATALOG_MODELS}, f, ensure_ascii=False, indent=1)
121
+
122
+ # ---------- config.toml 编辑(基于行锚点,不做完整 TOML 解析) ----------
123
+ def ensure_provider_block(text):
124
+ if "[model_providers.deepseek]" in text:
125
+ # 移除旧块再重写,保证与模板一致
126
+ text = re.sub(r"\n?# --- added by cx \(direct deepseek\) ---\n\[model_providers\.deepseek\][^\[]*", "\n", text)
127
+ return text.rstrip() + "\n" + DS_PROVIDER_BLOCK
128
+
129
+ def set_active_model(text, model, provider):
130
+ text = re.sub(r"(?m)^model\s*=.*$", f'model = "{model}"', text, count=1)
131
+ if "model = " not in text:
132
+ text = f'model = "{model}"\n' + text
133
+ if provider == "openai":
134
+ # 注释掉 model_provider,回落到内置 openai(ChatGPT 登录态)
135
+ text = re.sub(r"(?m)^model_provider\s*=.*$", "# model_provider = \"openai\" # cx: 原生 gpt", text, count=1)
136
+ else:
137
+ if re.search(r"(?m)^model_provider\s*=", text):
138
+ text = re.sub(r"(?m)^model_provider\s*=.*$", f'model_provider = "{provider}"', text, count=1)
139
+ else:
140
+ text = text.replace('model = ', f'model_provider = "{provider}"\nmodel = ', 1)
141
+ return text
142
+
143
+ def clean_ocx_injections(text):
144
+ lines, out = text.split("\n"), []
145
+ for ln in lines:
146
+ s = ln.strip()
147
+ if s.startswith("openai_base_url ="): continue # ocx 注入:指回原生
148
+ if s.startswith("experimental_realtime_ws_base_url ="): continue
149
+ if s.startswith("service_tier ="): continue # deepseek 不支持,纯噪音
150
+ if s.startswith("model_catalog_json ="):
151
+ out.append(f'model_catalog_json = "{CATALOG}"'); continue
152
+ out.append(ln)
153
+ return "\n".join(out)
154
+
155
+ # ---------- 命令 ----------
156
+ def cmd_use(which):
157
+ if which not in ("deepseek", "gpt"): err("用法: cx use deepseek|gpt")
158
+ backup_cfg(f"use-{which}-")
159
+ text = read_cfg()
160
+ if which == "deepseek":
161
+ text = ensure_provider_block(text)
162
+ text = set_active_model(text, DS_MODEL, "deepseek")
163
+ else:
164
+ text = set_active_model(text, GPT_MODEL, "openai")
165
+ text = clean_ocx_injections(text)
166
+ write_catalog()
167
+ open(CONFIG, "w", encoding="utf-8").write(text)
168
+ ok(f"已切换到 {which}: model 已写入,备份在同目录")
169
+ if which == "deepseek" and not load_key():
170
+ info("未保存 deepseek key,先运行: cx key <API_KEY>")
171
+ info("桌面 App 需完全退出重开才会重读配置")
172
+
173
+ def cmd_key():
174
+ key = None
175
+ if len(sys.argv) > 2 and sys.argv[2].strip():
176
+ key = sys.argv[2].strip()
177
+ else:
178
+ # 尝试从 ocx 配置迁移
179
+ ocx = os.path.expanduser("~/.opencodex/config.json")
180
+ if os.path.exists(ocx):
181
+ try:
182
+ key = json.load(open(ocx))["providers"]["deepseek"]["apiKey"]
183
+ info(f"从旧代理配置(~/.opencodex)读取到 key({key[:8]}...)")
184
+ except Exception: pass
185
+ if not key: err("用法: cx key <DEEPSEEK_API_KEY>")
186
+ os.makedirs(CX_DIR, exist_ok=True)
187
+ os.chmod(CX_DIR, 0o700)
188
+ open(KEY_FILE, "w").write(key); os.chmod(KEY_FILE, 0o600)
189
+ subprocess.run(["launchctl", "setenv", "DEEPSEEK_API_KEY", key])
190
+ plist = os.path.expanduser("~/Library/LaunchAgents/com.cx.codex-setenv.plist")
191
+ # 不把 key 写进 plist,运行时从 key 文件现读,避免明文多存一份
192
+ pl = f"""<?xml version="1.0" encoding="UTF-8"?>
193
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
194
+ <plist version="1.0"><dict>
195
+ <key>Label</key><string>com.cx.codex-setenv</string>
196
+ <key>ProgramArguments</key><array>
197
+ <string>/bin/sh</string><string>-c</string>
198
+ <string>[ -f "{KEY_FILE}" ] &amp;&amp; launchctl setenv DEEPSEEK_API_KEY "$(cat {KEY_FILE})"</string>
199
+ </array>
200
+ <key>RunAtLoad</key><true/>
201
+ </dict></plist>"""
202
+ open(plist, "w").write(pl); os.chmod(plist, 0o600)
203
+ ok("key 已保存(~/.cx/deepseek.key)并注入 GUI 环境(重启后由 LaunchAgent 自动恢复)")
204
+ info("正在运行的 App 需重启才能看到该环境变量")
205
+
206
+ def app_running():
207
+ return subprocess.run(["pgrep", "-f", APP_PGREP], capture_output=True).returncode == 0
208
+
209
+ def cmd_status():
210
+ text = read_cfg()
211
+ m = re.search(r'(?m)^model\s*=\s*"([^"]+)"', text)
212
+ p = re.search(r'(?m)^model_provider\s*=\s*"([^"]+)"', text)
213
+ print(f"默认模型 : {m.group(1) if m else '(未设置)'}")
214
+ print(f"默认provider: {p.group(1) if p else 'openai(原生)'}")
215
+ print(f"key : {'已保存' if load_key() else '未保存(cx key <KEY>)'}")
216
+ try:
217
+ db = sqlite3.connect(f"file:{CODEX_HOME}/state_5.sqlite?mode=ro", uri=True)
218
+ rows = db.execute("SELECT substr(id,1,8), model, substr(title,1,20) FROM threads "
219
+ "ORDER BY updated_at DESC LIMIT 6").fetchall()
220
+ print("\n最近会话:")
221
+ for r in rows: print(f" {r[0]} {str(r[1]):<28} {r[2]}")
222
+ bad = db.execute("SELECT COUNT(*) FROM threads WHERE model LIKE 'deepseek/%' OR model='deepseek-v4-flash'").fetchone()[0]
223
+ if bad: print(f"\n⚠️ {bad} 个会话仍是旧格式模型 id(可运行 cx fix-all deepseek 统一迁移)")
224
+ except Exception as e:
225
+ print("(会话库读取失败: %s)" % e)
226
+
227
+ def cmd_doctor():
228
+ print("== cx doctor ==")
229
+ key = load_key()
230
+ print(("✅" if key else "❌") + " deepseek key: " + ("已保存 " + key[:8] + "..." if key else "未保存"))
231
+ if key:
232
+ req = urllib.request.Request("https://api.deepseek.com/v1/models",
233
+ headers={"Authorization": f"Bearer {key}"})
234
+ try:
235
+ urllib.request.urlopen(req, timeout=10); ok("deepseek API 直连: key 有效")
236
+ except urllib.error.HTTPError as e:
237
+ err(f"deepseek API 返回 {e.code}(key 可能无效)")
238
+ except Exception as e:
239
+ err(f"deepseek API 连不上: {e}")
240
+ text = read_cfg()
241
+ ok("config.toml 读取正常") if "[model_providers.deepseek]" in text or "model_provider" in text else info("config 尚未配置 provider")
242
+ cmd_status()
243
+
244
+ def cmd_fix():
245
+ if len(sys.argv) < 3: err("用法: cx fix <会话UUID|last>")
246
+ sid = sys.argv[2]
247
+ if sid == "last":
248
+ db = sqlite3.connect(f"file:{CODEX_HOME}/state_5.sqlite?mode=ro", uri=True)
249
+ r = db.execute("SELECT id FROM threads ORDER BY updated_at DESC LIMIT 1").fetchone()
250
+ if not r: err("没有会话")
251
+ sid = r[0]
252
+ if app_running(): err("请先完全退出 ChatGPT/Codex App(会话被锁)")
253
+ print(f"修复会话 {sid} ...")
254
+ r = subprocess.run([CODEX_BIN, "exec", "resume", "--skip-git-repo-check",
255
+ "-c", f'model="{DS_MODEL}"', "-c", 'model_provider="deepseek"',
256
+ sid, "系统测试:请只回复两个字[正常]"],
257
+ capture_output=True, text=True, timeout=300)
258
+ tail = "\n".join((r.stdout or "").split("\n")[-6:])
259
+ print(tail)
260
+ ok("修复完成") if r.returncode == 0 else err(f"修复失败 exit={r.returncode}")
261
+
262
+ def is_ds(model):
263
+ return isinstance(model, str) and (model in DS_IDS or model.startswith("deepseek"))
264
+
265
+ # ---------- 批量切换老会话 ----------
266
+ def sanitize_rollout(path):
267
+ """移除旧代理时代 OpenAI 不兼容的 reasoning 条目(id 为 rs_ocx_*,或带 content 字段)。
268
+ OpenAI 对这类条目会报 Invalid input[..].content / Item not found。返回移除数量。"""
269
+ removed = 0
270
+ out = []
271
+ for line in open(path, encoding="utf-8"):
272
+ try:
273
+ d = json.loads(line)
274
+ pl = d.get("payload", {})
275
+ if (d.get("type") == "response_item" and pl.get("type") == "reasoning"
276
+ and (str(pl.get("id", "")).startswith("rs_ocx_") or pl.get("content"))):
277
+ removed += 1
278
+ continue
279
+ except Exception:
280
+ pass
281
+ out.append(line)
282
+ if removed:
283
+ open(path, "w", encoding="utf-8").writelines(out)
284
+ return removed
285
+
286
+ def rewrite_rollout(path, model, provider):
287
+ """把 rollout 文件里记录的模型/provider 统一改写为指定值,返回是否修改"""
288
+ changed = False
289
+ new_lines = []
290
+ for line in open(path, encoding="utf-8"):
291
+ if '"turn_context"' in line or '"session_meta"' in line:
292
+ try:
293
+ d = json.loads(line)
294
+ pl = d.get("payload", {})
295
+ if d.get("type") == "session_meta":
296
+ if pl.get("model") != model: pl["model"] = model; changed = True
297
+ if pl.get("model_provider") != provider: pl["model_provider"] = provider; changed = True
298
+ elif d.get("type") == "turn_context":
299
+ if pl.get("model") != model: pl["model"] = model; changed = True
300
+ st = (pl.get("collaboration_mode") or {}).get("settings") or {}
301
+ if st.get("model") and st.get("model") != model: st["model"] = model; changed = True
302
+ if changed: line = json.dumps(d, ensure_ascii=False) + "\n"
303
+ except Exception: pass
304
+ new_lines.append(line)
305
+ if changed:
306
+ open(path, "w", encoding="utf-8").writelines(new_lines)
307
+ return changed
308
+
309
+ def cmd_fix_all():
310
+ args = sys.argv[2:]
311
+ which = args[0] if args else ""
312
+ limit = None
313
+ if "--limit" in args:
314
+ i = args.index("--limit")
315
+ limit = int(args[i + 1]) if len(args) > i + 1 else None
316
+ if which not in ("deepseek", "gpt"):
317
+ err("用法: cx fix-all deepseek|gpt [--limit N] (--limit N 只改最近 N 个,默认全部)")
318
+ model, provider = (DS_MODEL, "deepseek") if which == "deepseek" else (GPT_MODEL, "openai")
319
+ if app_running():
320
+ err("请先完全退出 ChatGPT/Codex App(会话有写入锁)。单个会话可用 cx fix <ID>")
321
+ dbpath = os.path.join(CODEX_HOME, "state_5.sqlite")
322
+ db = sqlite3.connect(dbpath)
323
+ q = ("SELECT id, model, model_provider, rollout_path FROM threads "
324
+ "ORDER BY updated_at DESC")
325
+ if limit: q += f" LIMIT {limit}"
326
+ rows = db.execute(q).fetchall()
327
+ todo = []
328
+ for tid, m, p, rp in rows:
329
+ needs_model = (m != model or p != provider)
330
+ needs_sanitize = (provider == "openai" and rp and os.path.exists(rp)
331
+ and "rs_ocx_" in open(rp, encoding="utf-8", errors="ignore").read(2_000_000))
332
+ if needs_model or needs_sanitize:
333
+ todo.append((tid, m, p, rp, needs_model))
334
+ if not todo:
335
+ ok(f"所有会话已经是 {model},无需切换"); db.close(); return
336
+ print(f"待切换 {len(todo)} / {len(rows)} 个会话 → {model} ({provider})")
337
+ if input("确认执行? [y/N] ").strip().lower() != "y":
338
+ info("已取消"); db.close(); return
339
+ ts = time.strftime("%Y%m%d-%H%M%S")
340
+ bak = os.path.join(CODEX_HOME, f"state_5.sqlite.bak.cx-fixall-{ts}")
341
+ shutil.copy2(dbpath, bak)
342
+ ok(f"数据库已备份: {bak}")
343
+ n = 0
344
+ for tid, m, p, rp, needs_model in todo:
345
+ if rp and os.path.exists(rp):
346
+ bakj = rp + f".bak.cx-fixall-{ts}"
347
+ if not os.path.exists(bakj): shutil.copy2(rp, bakj)
348
+ if needs_model: rewrite_rollout(rp, model, provider)
349
+ if provider == "openai": sanitize_rollout(rp)
350
+ if needs_model:
351
+ db.execute("UPDATE threads SET model=?, model_provider=? WHERE id=?", (model, provider, tid))
352
+ n += 1
353
+ db.commit(); db.close()
354
+ ok(f"已切换 {n} 个会话 → {model}。重开 App 生效")
355
+
356
+ def cmd_migrate():
357
+ force = "--yes" in sys.argv
358
+ if app_running() and not force:
359
+ err("请先完全退出 ChatGPT/Codex App(或加 --yes 跳过检查,不推荐)")
360
+ db = sqlite3.connect(os.path.join(CODEX_HOME, "state_5.sqlite"))
361
+ rows = db.execute("SELECT id, model, rollout_path FROM threads WHERE model LIKE 'deepseek%'").fetchall()
362
+ if not rows: ok("没有需要迁移的会话"); return
363
+ ts = time.strftime("%Y%m%d-%H%M%S")
364
+ bak = os.path.join(CODEX_HOME, f"state_5.sqlite.bak.cx-migrate-{ts}")
365
+ shutil.copy2(os.path.join(CODEX_HOME, "state_5.sqlite"), bak)
366
+ ok(f"数据库已备份: {bak}")
367
+ n = 0
368
+ for tid, model, path in rows:
369
+ if os.path.exists(path):
370
+ bakj = path + f".bak.cx-{ts}"
371
+ if not os.path.exists(bakj): shutil.copy2(path, bakj)
372
+ new_lines = []
373
+ changed = False
374
+ for line in open(path, encoding="utf-8"):
375
+ if '"turn_context"' in line or '"session_meta"' in line:
376
+ try:
377
+ d = json.loads(line)
378
+ pl = d.get("payload", {})
379
+ if d.get("type") == "session_meta":
380
+ if is_ds(pl.get("model")): pl["model"] = DS_MODEL; changed = True
381
+ if pl.get("model_provider") == "openai": pl["model_provider"] = "deepseek"; changed = True
382
+ elif d.get("type") == "turn_context":
383
+ if is_ds(pl.get("model")): pl["model"] = DS_MODEL; changed = True
384
+ cm = pl.get("collaboration_mode") or {}
385
+ st = cm.get("settings") or {}
386
+ if is_ds(st.get("model")): st["model"] = DS_MODEL; changed = True
387
+ if changed: line = json.dumps(d, ensure_ascii=False) + "\n"
388
+ except Exception: pass
389
+ new_lines.append(line)
390
+ if changed:
391
+ open(path, "w", encoding="utf-8").writelines(new_lines); n += 1
392
+ db.execute("UPDATE threads SET model=?, model_provider='deepseek' WHERE model LIKE 'deepseek%'", (DS_MODEL,))
393
+ db.commit()
394
+ ok(f"已迁移 {n} 个 rollout / {len(rows)} 条记录 → deepseek-chat")
395
+ db.close()
396
+
397
+ def main():
398
+ if len(sys.argv) < 2: cmd_status(); return
399
+ c = sys.argv[1]
400
+ if c == "status": cmd_status()
401
+ elif c == "use": cmd_use(sys.argv[2] if len(sys.argv) > 2 else "")
402
+ elif c == "key": cmd_key()
403
+ elif c == "doctor": cmd_doctor()
404
+ elif c == "fix": cmd_fix()
405
+ elif c == "fix-all": cmd_fix_all()
406
+ elif c == "migrate-sessions": cmd_migrate()
407
+ elif c in ("-h", "--help", "help"): print(HELP)
408
+ else: err(f"未知命令: {c}(cx help 查看用法)")
409
+
410
+ if __name__ == "__main__":
411
+ main()
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "codex-model-change",
3
+ "version": "1.0.0",
4
+ "description": "Switch OpenAI Codex (CLI / desktop app) between DeepSeek direct-connect and native ChatGPT. No proxy, no daemon.",
5
+ "bin": {
6
+ "cx": "bin/cx.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "cx.py",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "keywords": [
15
+ "codex",
16
+ "deepseek",
17
+ "chatgpt",
18
+ "openai",
19
+ "model-switcher",
20
+ "cli"
21
+ ],
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=14"
25
+ },
26
+ "os": [
27
+ "darwin"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/git-sgg/codex-model-change.git"
32
+ },
33
+ "homepage": "https://github.com/git-sgg/codex-model-change#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/git-sgg/codex-model-change/issues"
36
+ }
37
+ }