@nvae/llmswitch 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 llm-switch contributors
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,303 @@
1
+ <p align="center">
2
+ <img src="./assets/readme/hero.svg" width="100%" alt="llmswitch:为 Claude Code、Codex 和 OpenCode 切换供应商与模型,并按需转换 API 协议">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <strong>用一套 CLI 管理 Claude Code、Codex 与 OpenCode 的供应商、模型、代理和启动流程。</strong>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <strong>简体中文</strong> · <a href="./README_EN.md">English</a>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="#快速开始">快速开始</a> ·
15
+ <a href="#兼容性">兼容性</a> ·
16
+ <a href="#命令参考">命令参考</a> ·
17
+ <a href="#配置与安全">配置与安全</a> ·
18
+ <a href="./LICENSE">MIT License</a>
19
+ </p>
20
+
21
+ ## llmswitch 是什么?
22
+
23
+ `llmswitch` 是一个面向 **Claude Code、Codex 和 OpenCode** 的本地命令行工具。它使用彼此独立的 profile 管理每种工具的 API 地址、密钥、模型和代理,并将选中的 profile 合并写入目标工具的原生配置。
24
+
25
+ 原生协议可以直接连接;当 Claude Code 或 Codex 需要访问仅兼容 OpenAI Chat Completions 的上游时,`llmswitch` 会自动启动一个仅监听本机的 bridge,完成常用请求、响应和 SSE 事件的协议转换。
26
+
27
+ ```text
28
+ ┌─ Claude Code ── Anthropic Messages
29
+ provider profiles ─ llms ──┼─ Codex ──────── OpenAI Responses
30
+ └─ OpenCode ────── 原生 provider
31
+
32
+ 协议不兼容时启用 local bridge
33
+
34
+ OpenAI Chat API
35
+ ```
36
+
37
+ ## 核心能力
38
+
39
+ - **统一操作三种工具**:使用一致的命令管理 Claude Code、Codex 和 OpenCode。
40
+ - **配置相互隔离**:每种工具独立保存 profile、默认项和当前启用项,不会混用供应商配置。
41
+ - **一条命令切换并启动**:选择 profile 和模型、更新配置,然后启动目标 CLI。
42
+ - **按需转换协议**:在 Messages / Responses 与 Chat Completions 之间转换常用文本、工具调用、工具结果及流式事件。
43
+ - **保留现有配置**:只合并受管理字段,并在修改已有目标文件前创建时间戳备份。
44
+ - **支持模型发现**:尝试从上游 `/models` 获取模型;失败时可手动输入模型 ID。
45
+ - **支持上游代理**:每个 profile 可独立配置 HTTP、HTTPS 或 ALL proxy。
46
+ - **便于脚本集成**:查询、切换、启动计划和 bridge 状态等命令支持 JSON 输出。
47
+
48
+ ## 快速开始
49
+
50
+ ### 安装
51
+
52
+ 需要 Node.js 20+。
53
+
54
+ ```bash
55
+ npm install -g @nvae/llmswitch
56
+ # 或
57
+ bun add -g @nvae/llmswitch
58
+ ```
59
+
60
+ 安装后可直接使用 `llms`(或 `llm-switch`)命令。本地开发可用:
61
+
62
+ ```bash
63
+ bun run src/index.ts --help
64
+ ```
65
+
66
+ ### 1. 添加供应商
67
+
68
+ 分别为需要使用的工具创建 profile:
69
+
70
+ ```bash
71
+ llms claude provider
72
+ llms codex provider
73
+ llms opencode provider
74
+ ```
75
+
76
+ 交互界面支持添加、查看、编辑、删除、设为默认以及启用/禁用 profile。可从以下模板开始:
77
+
78
+ | 模板 | 默认 API 格式 | 默认 Base URL |
79
+ | --- | --- | --- |
80
+ | 自定义(OpenAI 兼容) | Chat Completions | 手动填写 |
81
+ | OpenAI | Responses | `https://api.openai.com/v1` |
82
+ | Anthropic | Messages | `https://api.anthropic.com` |
83
+
84
+ 其他供应商可通过兼容的 OpenAI 或 Anthropic API 地址接入;这不代表所有厂商、字段或扩展协议都已完整适配。
85
+
86
+ ### 2. 选择模型
87
+
88
+ ```bash
89
+ llms codex model
90
+ # 为指定 profile 选择模型
91
+ llms codex model --profile my-provider
92
+ ```
93
+
94
+ 有 API Key 时,`llmswitch` 会尝试获取上游模型列表。使用空格多选、回车确认,然后指定默认模型;请求失败或上游没有兼容的模型端点时,可手动输入模型 ID。
95
+
96
+ ### 3. 启用 profile
97
+
98
+ ```bash
99
+ llms codex use my-provider
100
+ llms codex current
101
+ ```
102
+
103
+ `use` 会备份现有目标配置、合并新的供应商设置,并将该 profile 记录为当前启用项。
104
+
105
+ ### 4. 切换模型并启动
106
+
107
+ ```bash
108
+ llms launch codex gpt-4.1
109
+
110
+ # 等价写法
111
+ llms launch codex --model gpt-4.1
112
+
113
+ # 显式指定 profile
114
+ llms launch claude --profile my-provider --model claude-sonnet-4
115
+
116
+ # run 是 launch 的别名
117
+ llms run opencode my-model
118
+ ```
119
+
120
+ 未指定 `--profile` 时,profile 的选择顺序为:
121
+
122
+ 1. 模型列表中包含目标模型的 profile;
123
+ 2. 当前启用的 profile;
124
+ 3. 默认 profile。
125
+
126
+ 模型匹配会忽略大小写和常见分隔符。例如,`gpt4.1` 可匹配 `gpt-4.1`。如果输入的模型不在 profile 中,它会被加入模型列表并设为该 profile 的默认模型。
127
+
128
+ ### 5. 预览或只写配置
129
+
130
+ ```bash
131
+ # 只查看执行计划,不修改配置、不启动 CLI
132
+ llms launch codex gpt-4.1 --dry-run
133
+
134
+ # 输出 JSON 计划
135
+ llms launch codex gpt-4.1 --dry-run --json
136
+
137
+ # 写入配置,但不启动目标 CLI
138
+ llms launch opencode my-model --print-only
139
+
140
+ # 将额外参数透传给目标 CLI
141
+ llms launch opencode my-model -- --resume
142
+ ```
143
+
144
+ 如目标 CLI 不在 `PATH` 中,可指定可执行文件:
145
+
146
+ ```bash
147
+ export CLAUDE_BIN=/path/to/claude
148
+ export CODEX_BIN=/path/to/codex
149
+ export OPENCODE_BIN=/path/to/opencode
150
+ ```
151
+
152
+ ## 兼容性
153
+
154
+ | 目标工具 | Anthropic Messages | OpenAI Chat Completions | OpenAI Responses |
155
+ | --- | :---: | :---: | :---: |
156
+ | Claude Code | 直连 | 通过 bridge | 不支持 |
157
+ | Codex | 不支持 | 通过 bridge | 直连 |
158
+ | OpenCode | 直连 | 直连 | 直连 |
159
+
160
+ OpenCode 会根据 API 格式写入对应的 AI SDK provider:
161
+
162
+ - Anthropic:`@ai-sdk/anthropic`
163
+ - OpenAI Responses:`@ai-sdk/openai`
164
+ - OpenAI Chat:`@ai-sdk/openai-compatible`
165
+
166
+ OpenAI 格式的 Base URL 会规范为末尾恰好一个 `/v1`;Anthropic 格式只移除末尾多余的 `/`。如果某个特殊网关不使用 `/v1` 路径,它可能无法直接使用当前的 OpenAI 格式 profile。
167
+
168
+ ## 本地 bridge
169
+
170
+ Claude Code 和 Codex 共用一个 bridge 进程,但两侧上游配置相互隔离,可以同时连接不同供应商。默认监听 `127.0.0.1:17890`:
171
+
172
+ ```text
173
+ Claude Code POST /v1/messages ─┐
174
+ ├─ local bridge ─→ POST /v1/chat/completions
175
+ Codex POST /v1/responses ─┘
176
+ ```
177
+
178
+ 当启用需要 bridge 的 profile 时,进程会自动启动;切回原生格式后会清理对应侧上游,两侧均不再使用 bridge 时会停止进程。
179
+
180
+ 常用管理命令:
181
+
182
+ ```bash
183
+ llms bridge status
184
+ llms bridge status --json
185
+ llms bridge start
186
+ llms bridge stop
187
+ llms bridge reload claude
188
+ llms bridge reload codex --profile my-provider
189
+
190
+ # 前台运行,便于排查问题
191
+ llms bridge serve
192
+ ```
193
+
194
+ Bridge 提供以下本地端点:
195
+
196
+ | 方法 | 路径 | 用途 |
197
+ | --- | --- | --- |
198
+ | `GET` | `/health`、`/v1/health` | 查看服务及两侧上游状态 |
199
+ | `GET` | `/models`、`/v1/models` | 合并已配置上游的模型列表 |
200
+ | `POST` | `/messages`、`/v1/messages` | Anthropic Messages → Chat |
201
+ | `POST` | `/responses`、`/v1/responses` | OpenAI Responses → Chat/Completions |
202
+
203
+ ```bash
204
+ curl http://127.0.0.1:17890/health
205
+ ```
206
+
207
+ > [!WARNING]
208
+ > Bridge 本身没有身份认证、限流或请求体大小限制。请保持默认回环地址,不要将其作为公网或多租户生产网关使用。
209
+
210
+ ## 上游代理
211
+
212
+ 添加或编辑 profile 时可以配置:
213
+
214
+ - `HTTP_PROXY`
215
+ - `HTTPS_PROXY`
216
+ - `ALL_PROXY`,例如 `socks5h://127.0.0.1:1080`
217
+
218
+ ```bash
219
+ llms claude provider
220
+ # 选择“添加”或“编辑”,然后填写代理字段
221
+ ```
222
+
223
+ 原生直连 profile 会将适用的代理变量合并到目标工具配置;bridge profile 则由 bridge 使用对应上游的代理设置。实际代理行为仍取决于目标 CLI、Node.js 网络环境和代理协议,建议在真实环境中验证。
224
+
225
+ ## 命令参考
226
+
227
+ 统一形式为 `llms <tool> <action>`,其中 `<tool>` 为 `claude`、`codex` 或 `opencode`。
228
+
229
+ | 命令 | 说明 |
230
+ | --- | --- |
231
+ | `llms <tool> provider` | 交互式管理 profile;`--json` 可查看列表 |
232
+ | `llms <tool> use [name]` | 启用已有 profile;支持 `--json` |
233
+ | `llms <tool> current` | 查看默认项和当前启用项;API Key 会脱敏 |
234
+ | `llms <tool> model [--profile name]` | 获取、选择并保存模型;支持 `--json` |
235
+ | `llms launch\|run <tool> [model]` | 切换 profile/模型并启动目标 CLI |
236
+ | `llms bridge status` | 查看 bridge 进程和两侧上游状态 |
237
+ | `llms bridge start\|stop` | 手动启动或停止 bridge |
238
+ | `llms bridge reload [tool]` | 从当前或指定 profile 刷新 bridge 上游 |
239
+ | `llms bridge serve` | 在前台运行 bridge |
240
+ | `llms path` | 显示 llmswitch 本地数据目录 |
241
+
242
+ 查看完整参数:
243
+
244
+ ```bash
245
+ llms --help
246
+ llms launch --help
247
+ llms bridge --help
248
+ llms codex --help
249
+ ```
250
+
251
+ ## 配置与安全
252
+
253
+ ### 配置位置
254
+
255
+ | 数据 | 默认位置 | 覆盖方式 |
256
+ | --- | --- | --- |
257
+ | llmswitch(macOS/Linux) | `~/.config/llm-switch/` | `LLM_SWITCH_HOME` 或 `XDG_CONFIG_HOME` |
258
+ | llmswitch(Windows) | `%APPDATA%\llm-switch\` | `LLM_SWITCH_HOME` |
259
+ | Claude Code | `~/.claude/settings.json` | `CLAUDE_CONFIG_DIR` |
260
+ | Codex | `~/.codex/config.toml`、`~/.codex/.env` | `CODEX_HOME` |
261
+ | OpenCode 配置 | `~/.config/opencode/opencode.json` | `OPENCODE_CONFIG_DIR` 或 `XDG_CONFIG_HOME` |
262
+ | OpenCode认证 | `~/.local/share/opencode/auth.json` | `OPENCODE_DATA_DIR` 或 `XDG_DATA_HOME` |
263
+
264
+ 为兼容当前程序和已有配置,llmswitch 的默认数据目录仍使用 `llm-switch` 目录名。Windows 下,OpenCode 认证默认位于 `%LOCALAPPDATA%\opencode\auth.json`。运行以下命令可查看当前环境中的实际数据目录:
265
+
266
+ ```bash
267
+ llms path
268
+ ```
269
+
270
+ ### 备份策略
271
+
272
+ 修改或禁用配置前,已存在的目标文件会备份到对应工具目录的 `backups/` 中,文件名包含时间戳。首次创建的文件没有可备份内容。写入过程使用同目录临时文件再重命名,降低配置只写入一部分的风险。
273
+
274
+ ### API Key
275
+
276
+ API Key 会以**明文**保存在本机 profile、目标工具认证文件或 bridge 上游配置中。llmswitch 会尝试将新写文件权限设置为 `0600`,但不会使用系统钥匙串,也不会加密密钥。
277
+
278
+ 请保护配置目录,不要将其中内容提交到版本控制,不要在不受信任的设备上保存长期密钥。JSON 查询结果会对 API Key 脱敏,但磁盘文件仍包含真实值。
279
+
280
+ ## 已知限制
281
+
282
+ - Bridge 是面向本地使用的协议适配器,不保证 OpenAI 与 Anthropic 协议 100% 等价。
283
+ - Responses 中的加密 reasoning 内容不会转发;未识别的工具类型可能被忽略。
284
+ - Anthropic 转换主要覆盖文本、工具调用和工具结果,图片等非文本内容可能无法完整保留。
285
+ - Codex 的 Completions 模式是降级路径:对话会被展平为 prompt,复杂工具调用或 agent 流程可能表现不佳。
286
+ - Codex Chat 模式仅在上游返回 HTTP `404` 或 `405` 时回退到 Completions;认证、网络及其他错误不会触发回退。
287
+ - 托管 `web_search` 会转换为普通客户端函数,不会自动获得上游的托管搜索能力。
288
+ - 自动发现模型需要 API Key 和兼容的 `/models` 端点;失败时需手动输入模型。
289
+
290
+ ## 参与贡献
291
+
292
+ 欢迎通过 Issue 报告兼容性问题,或提交 Pull Request 改进配置适配、协议转换、测试和文档。
293
+
294
+ 提交新供应商或协议适配时,建议同时提供:
295
+
296
+ - 目标工具与 API 格式;
297
+ - 可复现的请求和响应行为;
298
+ - 普通响应与流式响应的测试;
299
+ - 工具调用、工具结果和错误响应等边界场景。
300
+
301
+ ## License
302
+
303
+ 本项目基于 [MIT License](./LICENSE) 开源。
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { emptyProxy } from "../types.js";
3
+ import { assertCompatible } from "../formats/compatibility.js";
4
+ import { clearBridgeUpstream, ensureBridgeForProfile, profileNeedsBridge, } from "../bridge/manager.js";
5
+ import { backupFile, atomicWriteFile } from "../utils/fs.js";
6
+ import { applyProxyToEnvRecord, clearProxyEnvKeys, } from "../utils/proxy.js";
7
+ import { getBackupsDir, getClaudeSettingsPath, } from "../utils/paths.js";
8
+ import { setActiveProfile } from "../store/profiles.js";
9
+ /** Env keys managed by llm-switch for Claude Code. */
10
+ export const CLAUDE_MANAGED_ENV_KEYS = [
11
+ "ANTHROPIC_BASE_URL",
12
+ "ANTHROPIC_AUTH_TOKEN",
13
+ "ANTHROPIC_API_KEY",
14
+ "ANTHROPIC_MODEL",
15
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
16
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
17
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
18
+ "ANTHROPIC_SMALL_FAST_MODEL",
19
+ "HTTP_PROXY",
20
+ "HTTPS_PROXY",
21
+ "ALL_PROXY",
22
+ "http_proxy",
23
+ "https_proxy",
24
+ "all_proxy",
25
+ ];
26
+ export function readClaudeSettings(path = getClaudeSettingsPath()) {
27
+ if (!existsSync(path))
28
+ return {};
29
+ return JSON.parse(readFileSync(path, "utf8"));
30
+ }
31
+ function stripManagedEnv(env) {
32
+ const next = { ...env };
33
+ for (const key of CLAUDE_MANAGED_ENV_KEYS) {
34
+ delete next[key];
35
+ }
36
+ return next;
37
+ }
38
+ export function buildClaudeSettings(existing, profile, effectiveBaseUrl) {
39
+ assertCompatible("claude", profile.apiFormat);
40
+ const env = stripManagedEnv({ ...(existing.env || {}) });
41
+ clearProxyEnvKeys(env);
42
+ const needsBridge = profileNeedsBridge(profile);
43
+ env.ANTHROPIC_BASE_URL = effectiveBaseUrl || profile.baseUrl;
44
+ if (profile.apiKey) {
45
+ env.ANTHROPIC_AUTH_TOKEN = profile.apiKey;
46
+ }
47
+ env.ANTHROPIC_MODEL = profile.models.default;
48
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = profile.models.default;
49
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = profile.models.default;
50
+ if (profile.models.fast) {
51
+ env.ANTHROPIC_SMALL_FAST_MODEL = profile.models.fast;
52
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.models.fast;
53
+ }
54
+ else {
55
+ env.ANTHROPIC_SMALL_FAST_MODEL = profile.models.default;
56
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.models.default;
57
+ }
58
+ // When bridging, upstream proxy is applied inside the bridge process.
59
+ if (!needsBridge && !emptyProxy(profile.proxy)) {
60
+ applyProxyToEnvRecord(env, profile.proxy);
61
+ }
62
+ for (const [k, v] of Object.entries(env)) {
63
+ if (v === undefined || v === null)
64
+ delete env[k];
65
+ }
66
+ return {
67
+ ...existing,
68
+ env,
69
+ model: profile.models.default,
70
+ };
71
+ }
72
+ export async function applyClaudeProfile(profile) {
73
+ assertCompatible("claude", profile.apiFormat);
74
+ const configPath = getClaudeSettingsPath();
75
+ const existing = readClaudeSettings(configPath);
76
+ const backupPath = backupFile(configPath, getBackupsDir("claude"), "settings");
77
+ let effectiveBaseUrl = profile.baseUrl;
78
+ let bridgeNote = "";
79
+ if (profileNeedsBridge(profile)) {
80
+ effectiveBaseUrl = await ensureBridgeForProfile(profile, "claude");
81
+ bridgeNote = `已启动本地 Anthropic↔Chat 适配桥 → ${effectiveBaseUrl}(上游 ${profile.baseUrl})。`;
82
+ }
83
+ else {
84
+ await clearBridgeUpstream("claude");
85
+ }
86
+ const next = buildClaudeSettings(existing, profile, effectiveBaseUrl);
87
+ atomicWriteFile(configPath, JSON.stringify(next, null, 2) + "\n");
88
+ setActiveProfile("claude", profile.name);
89
+ return {
90
+ tool: "claude",
91
+ profile: profile.name,
92
+ configPath,
93
+ backupPath,
94
+ restartHint: bridgeNote
95
+ ? `${bridgeNote}Claude Code 通常会热加载配置;若未生效,请新开一个会话后再试。`
96
+ : "Claude Code 通常会热加载配置;若未生效,请新开一个会话后再试。",
97
+ };
98
+ }
99
+ export async function deactivateClaudeProfile() {
100
+ const configPath = getClaudeSettingsPath();
101
+ const existing = readClaudeSettings(configPath);
102
+ const backupPath = backupFile(configPath, getBackupsDir("claude"), "settings");
103
+ const env = stripManagedEnv({ ...(existing.env || {}) });
104
+ clearProxyEnvKeys(env);
105
+ const next = { ...existing, env };
106
+ if (Object.keys(env).length === 0)
107
+ delete next.env;
108
+ atomicWriteFile(configPath, JSON.stringify(next, null, 2) + "\n");
109
+ await clearBridgeUpstream("claude");
110
+ return {
111
+ tool: "claude",
112
+ profile: "",
113
+ configPath,
114
+ backupPath,
115
+ restartHint: "已清除由本工具写入的 Claude 连接配置。若未生效,请新开一个会话后再试。",
116
+ };
117
+ }
@@ -0,0 +1,229 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { parse, stringify } from "smol-toml";
3
+ import { emptyProxy } from "../types.js";
4
+ import { assertCompatible } from "../formats/compatibility.js";
5
+ import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
6
+ import { atomicWriteFile, backupFile, ensureDir } from "../utils/fs.js";
7
+ import { buildProxyEnv } from "../utils/proxy.js";
8
+ import { getBackupsDir, getCodexConfigPath, getCodexEnvPath, getCodexHome, } from "../utils/paths.js";
9
+ import { setActiveProfile } from "../store/profiles.js";
10
+ import { clearBridgeUpstream, ensureBridgeForProfile, profileNeedsBridge, } from "../bridge/manager.js";
11
+ function providerKey(name) {
12
+ return name.replace(/[^a-zA-Z0-9_]/g, "_");
13
+ }
14
+ export function envKeyName(profileName) {
15
+ const key = providerKey(profileName).toUpperCase();
16
+ return `LLM_SWITCH_${key}_API_KEY`;
17
+ }
18
+ export function readCodexConfig(path = getCodexConfigPath()) {
19
+ if (!existsSync(path))
20
+ return {};
21
+ return parse(readFileSync(path, "utf8"));
22
+ }
23
+ export function buildCodexConfig(existing, profile, effectiveBaseUrl) {
24
+ assertCompatible("codex", profile.apiFormat);
25
+ const id = providerKey(profile.name);
26
+ const providers = {
27
+ ...(existing.model_providers || {}),
28
+ };
29
+ const providerBlock = {
30
+ name: profile.displayName || profile.name,
31
+ base_url: effectiveBaseUrl,
32
+ wire_api: "responses",
33
+ env_key: envKeyName(profile.name),
34
+ requires_openai_auth: false,
35
+ };
36
+ if (profile.headers && Object.keys(profile.headers).length > 0) {
37
+ // When using bridge, upstream headers live in bridge upstream.json;
38
+ // still pass through for native responses providers.
39
+ if (!profileNeedsBridge(profile)) {
40
+ providerBlock.http_headers = { ...profile.headers };
41
+ }
42
+ }
43
+ providers[id] = providerBlock;
44
+ return {
45
+ ...existing,
46
+ model: profile.models.default,
47
+ model_provider: id,
48
+ model_providers: providers,
49
+ };
50
+ }
51
+ export function buildCodexEnvFile(existingContent, profile) {
52
+ const lines = existingContent ? existingContent.split(/\r?\n/) : [];
53
+ const map = new Map();
54
+ const order = [];
55
+ for (const line of lines) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed || trimmed.startsWith("#"))
58
+ continue;
59
+ const eq = trimmed.indexOf("=");
60
+ if (eq <= 0)
61
+ continue;
62
+ const k = trimmed.slice(0, eq).trim();
63
+ let v = trimmed.slice(eq + 1).trim();
64
+ if ((v.startsWith('"') && v.endsWith('"')) ||
65
+ (v.startsWith("'") && v.endsWith("'"))) {
66
+ v = v.slice(1, -1);
67
+ }
68
+ if (!map.has(k))
69
+ order.push(k);
70
+ map.set(k, v);
71
+ }
72
+ const keyName = envKeyName(profile.name);
73
+ if (!map.has(keyName))
74
+ order.push(keyName);
75
+ // When bridging, Codex talks to local bridge; key can be placeholder.
76
+ // Bridge uses upstream.apiKey from its own config. Still write real key
77
+ // so native responses profiles keep working.
78
+ map.set(keyName, profile.apiKey || "llm-switch-bridge");
79
+ const proxyKeys = [
80
+ "HTTP_PROXY",
81
+ "HTTPS_PROXY",
82
+ "ALL_PROXY",
83
+ "http_proxy",
84
+ "https_proxy",
85
+ "all_proxy",
86
+ ];
87
+ for (const k of proxyKeys)
88
+ map.delete(k);
89
+ // Proxy for Codex process itself is usually unnecessary when talking to localhost bridge;
90
+ // upstream proxy is applied inside the bridge. For native responses, keep profile proxy.
91
+ if (!profileNeedsBridge(profile) && !emptyProxy(profile.proxy)) {
92
+ const env = buildProxyEnv(profile.proxy);
93
+ for (const [k, v] of Object.entries(env)) {
94
+ if (!map.has(k))
95
+ order.push(k);
96
+ map.set(k, v);
97
+ }
98
+ }
99
+ const seen = new Set();
100
+ const out = [
101
+ "# Managed in part by llm-switch — proxy and API keys for Codex",
102
+ ];
103
+ for (const k of order) {
104
+ if (!map.has(k))
105
+ continue;
106
+ out.push(`${k}=${escapeEnv(map.get(k))}`);
107
+ seen.add(k);
108
+ }
109
+ for (const [k, v] of map) {
110
+ if (seen.has(k))
111
+ continue;
112
+ out.push(`${k}=${escapeEnv(v)}`);
113
+ }
114
+ out.push("");
115
+ return out.join("\n");
116
+ }
117
+ function escapeEnv(value) {
118
+ if (/[\s#"']/.test(value) || value.includes("=")) {
119
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
120
+ }
121
+ return value;
122
+ }
123
+ export async function applyCodexProfile(profile) {
124
+ assertCompatible("codex", profile.apiFormat);
125
+ ensureDir(getCodexHome());
126
+ let effectiveBaseUrl = normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl);
127
+ let bridgeNote = "";
128
+ if (profileNeedsBridge(profile)) {
129
+ effectiveBaseUrl = (await ensureBridgeForProfile(profile, "codex")).replace(/\/+$/, "");
130
+ bridgeNote = `已启动本地 Responses 适配桥 → ${effectiveBaseUrl}(上游 ${normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl)},模式 ${profile.bridgeMode || "chat"})。`;
131
+ }
132
+ const configPath = getCodexConfigPath();
133
+ const envPath = getCodexEnvPath();
134
+ const existing = readCodexConfig(configPath);
135
+ const backupPath = backupFile(configPath, getBackupsDir("codex"), "config");
136
+ backupFile(envPath, getBackupsDir("codex"), "env");
137
+ const next = buildCodexConfig(existing, profile, effectiveBaseUrl);
138
+ atomicWriteFile(configPath, stringify(next) + "\n");
139
+ const prevEnv = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
140
+ atomicWriteFile(envPath, buildCodexEnvFile(prevEnv, profile));
141
+ setActiveProfile("codex", profile.name);
142
+ return {
143
+ tool: "codex",
144
+ profile: profile.name,
145
+ configPath,
146
+ backupPath,
147
+ restartHint: bridgeNote
148
+ ? `${bridgeNote}请新开终端或重新启动 Codex 使配置生效。`
149
+ : "请新开终端或重新启动 Codex,使 config.toml 与 .env 中的代理/密钥生效。",
150
+ };
151
+ }
152
+ export async function deactivateCodexProfile(profileName) {
153
+ ensureDir(getCodexHome());
154
+ const configPath = getCodexConfigPath();
155
+ const envPath = getCodexEnvPath();
156
+ const existing = readCodexConfig(configPath);
157
+ const backupPath = backupFile(configPath, getBackupsDir("codex"), "config");
158
+ backupFile(envPath, getBackupsDir("codex"), "env");
159
+ const next = { ...existing };
160
+ const providers = {
161
+ ...(existing.model_providers || {}),
162
+ };
163
+ if (profileName) {
164
+ const id = providerKey(profileName);
165
+ delete providers[id];
166
+ if (next.model_provider === id) {
167
+ delete next.model_provider;
168
+ delete next.model;
169
+ }
170
+ }
171
+ else if (next.model_provider) {
172
+ delete next.model_provider;
173
+ delete next.model;
174
+ }
175
+ next.model_providers = providers;
176
+ atomicWriteFile(configPath, stringify(next) + "\n");
177
+ const prevEnv = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
178
+ atomicWriteFile(envPath, stripCodexManagedEnv(prevEnv, profileName));
179
+ await clearBridgeUpstream("codex");
180
+ return {
181
+ tool: "codex",
182
+ profile: profileName || "",
183
+ configPath,
184
+ backupPath,
185
+ restartHint: "已禁用供应商并清除 Codex 桥上游。请新开终端或重新启动 Codex。",
186
+ };
187
+ }
188
+ function stripCodexManagedEnv(existingContent, profileName) {
189
+ const lines = existingContent ? existingContent.split(/\r?\n/) : [];
190
+ const proxyKeys = new Set([
191
+ "HTTP_PROXY",
192
+ "HTTPS_PROXY",
193
+ "ALL_PROXY",
194
+ "http_proxy",
195
+ "https_proxy",
196
+ "all_proxy",
197
+ ]);
198
+ const keyToRemove = profileName ? envKeyName(profileName) : null;
199
+ const out = [];
200
+ for (const line of lines) {
201
+ const trimmed = line.trim();
202
+ if (!trimmed || trimmed.startsWith("#")) {
203
+ // Drop our managed header comment; keep other comments.
204
+ if (trimmed.startsWith("# Managed in part by llm-switch"))
205
+ continue;
206
+ out.push(line);
207
+ continue;
208
+ }
209
+ const eq = trimmed.indexOf("=");
210
+ if (eq <= 0) {
211
+ out.push(line);
212
+ continue;
213
+ }
214
+ const k = trimmed.slice(0, eq).trim();
215
+ if (proxyKeys.has(k))
216
+ continue;
217
+ if (keyToRemove && k === keyToRemove)
218
+ continue;
219
+ if (!keyToRemove && k.startsWith("LLM_SWITCH_") && k.endsWith("_API_KEY")) {
220
+ continue;
221
+ }
222
+ out.push(line);
223
+ }
224
+ while (out.length > 0 && out[out.length - 1] === "")
225
+ out.pop();
226
+ if (out.length > 0)
227
+ out.push("");
228
+ return out.join("\n");
229
+ }