// router.js —— 本地路由/中转代理:Codex 直连本地,默认指令经 activeId 转发; // 若请求显式携带已登记的 model,则按 model 路由到对应分组,支持总控/子 Agent 分组并行。 const http = require("http"); const https = require("https"); const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { URL } = require("url"); const { app, safeStorage } = require("electron"); const translate = require("./translate"); const mediaUnderstanding = require("./media-understanding"); let CODEX_HOME = path.join(os.homedir(), ".codex"); const CLAUDE_HOME = path.join(os.homedir(), ".claude"); const PROVIDER_ID = "local-router"; // config.toml 里的 provider 名(走本地路由进程时用) const DIRECT_PROVIDER_ID = "td-direct"; // config.toml 里的 provider 名(直连中转、不经路由时用) function setCodexDataDir(dir) { if (typeof dir !== "string" || !dir.trim()) return; CODEX_HOME = path.resolve(dir); } function cfgPath() { return path.join(app.getPath("userData"), "router.json"); } const RETRYABLE_WRITE_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]); function sleepSync(ms) { if (!ms) return; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function retryWriteOperation(fn) { const delays = [0, 10, 25, 50, 100, 200]; let lastError; for (const delay of delays) { if (delay) sleepSync(delay); try { return fn(); } catch (e) { lastError = e; if (!RETRYABLE_WRITE_CODES.has(e && e.code)) throw e; } } throw lastError; } // Windows 上 Codex/安全软件可能短暂占用配置文件。先同目录原子替换,失败时重试, // 再退化为带重试的直接覆盖;最终回读校验,绝不把写入失败伪装成切换成功。 function safeWriteFileSync(file, content, encoding) { fs.mkdirSync(path.dirname(file), { recursive: true }); const data = Buffer.isBuffer(content) ? content : Buffer.from(String(content), encoding || "utf-8"); const tmp = `${file}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString("hex")}.tmp`; const before = readFileState(file); let targetMayHaveChanged = false; try { retryWriteOperation(() => fs.writeFileSync(tmp, data)); try { retryWriteOperation(() => fs.renameSync(tmp, file)); targetMayHaveChanged = true; } catch (renameError) { if (!RETRYABLE_WRITE_CODES.has(renameError && renameError.code)) throw renameError; targetMayHaveChanged = true; retryWriteOperation(() => fs.writeFileSync(file, data)); } const actual = retryWriteOperation(() => fs.readFileSync(file)); if (!actual.equals(data)) { const error = new Error(`写入后校验失败:${file}`); error.code = "EVERIFY"; throw error; } } catch (e) { let rollbackError = null; if (targetMayHaveChanged) { try { if (before.exists) retryWriteOperation(() => fs.writeFileSync(file, before.data)); else retryWriteOperation(() => fs.rmSync(file, { force: true })); } catch (restoreError) { rollbackError = restoreError; } } const detail = e && e.code ? `${e.code}: ${e.message}` : String((e && e.message) || e); const rollbackDetail = rollbackError ? `;原内容恢复失败:${rollbackError.code || "ERROR"}: ${rollbackError.message}` : ""; throw new Error(`无法写入配置文件 ${file}(${detail})${rollbackDetail}`); } finally { try { fs.rmSync(tmp, { force: true }); } catch {} } } function readFileState(file) { try { return { exists: true, data: retryWriteOperation(() => fs.readFileSync(file)) }; } catch (e) { if (e && e.code === "ENOENT") return { exists: false, data: null }; throw e; } } function restoreFileState(file, state) { if (state.exists) { safeWriteFileSync(file, state.data); return; } retryWriteOperation(() => fs.rmSync(file, { force: true })); } function writeCodexConfigPair(configText, authText) { const configFile = path.join(CODEX_HOME, "config.toml"); const authFile = path.join(CODEX_HOME, "auth.json"); const beforeConfig = readFileState(configFile); const beforeAuth = readFileState(authFile); try { safeWriteFileSync(configFile, configText, "utf-8"); safeWriteFileSync(authFile, authText, "utf-8"); try { fs.chmodSync(authFile, 0o600); } catch {} } catch (writeError) { const rollbackErrors = []; try { restoreFileState(configFile, beforeConfig); } catch (e) { rollbackErrors.push(`config.toml: ${(e && e.message) || e}`); } try { restoreFileState(authFile, beforeAuth); } catch (e) { rollbackErrors.push(`auth.json: ${(e && e.message) || e}`); } if (rollbackErrors.length) { throw new Error(`${writeError.message};回滚失败:${rollbackErrors.join(";")}`); } throw writeError; } } function readJsonForUpdate(file) { const text = readTextForUpdate(file); if (!text.trim()) return {}; try { const value = JSON.parse(text); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } catch (e) { throw new Error(`配置文件 JSON 解析失败 ${file}:${e.message}`); } } function readTextForUpdate(file) { try { return retryWriteOperation(() => fs.readFileSync(file, "utf-8")); } catch (e) { if (e && e.code === "ENOENT") return ""; throw e; } } // —— 加解密(复用 safeStorage)—— function enc(s) { if (!s) return ""; try { if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64"); } catch {} return "b64:" + Buffer.from(s, "utf-8").toString("base64"); } function dec(s) { if (!s) return ""; try { if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64")); if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8"); } catch {} return ""; } // —— 配置持久化 —— config: { port, routerKey, activeId, activeClaudeId, groups:[{id,name,baseUrl,key,model}] } // 两个激活槽,按协议族各留一个,让 Codex/GPT 与 Claude Code 能同时激活、互不顶替: // activeId —— OpenAI 协议族(Codex/GPT:mode = responses / chat,含默认分组) // activeClaudeId —— Anthropic 协议族(Claude Code:mode = claude) let config = null; function defaultConfig() { return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, activeClaudeId: null, groups: [] }; } // 分组所属协议族:claude 模式 = Anthropic 族,其余(responses/chat/默认)= OpenAI 族 function isClaudeGroup(g) { return !!g && g.mode === "claude"; } // 工具/插件专用分组:只给画布、技能或插件显式调用,不参与 Codex/GPT 默认分组选择。 function isToolOnlyGroup(g) { return !!g && !g.isDefault && (g.codexImage || g.codexVideo || g.stt); } function load() { if (config) return config; try { const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8")); raw.routerKey = raw.routerKey ? dec(raw.routerKey) : defaultConfig().routerKey; raw.groups = (raw.groups || []).map((g) => { const out = { ...g, key: dec(g.key) }; // officialBackup.authJson 含 OAuth tokens,落盘时加密,读时解密 if (out.officialBackup && out.officialBackup.authJson) { out.officialBackup = { ...out.officialBackup, authJson: dec(out.officialBackup.authJson) }; } return out; }); config = { ...defaultConfig(), ...raw }; } catch { config = defaultConfig(); } ensureDefault(); migrateActiveSlots(); return config; } // 老配置只有单一 activeId:若它指向的是 claude 分组,迁移到 activeClaudeId, // 并为空出来的 OpenAI 槽自动选一个非 claude 分组,保证两族各有默认激活。 function migrateActiveSlots() { if (!("activeClaudeId" in config)) config.activeClaudeId = null; const act = config.groups.find((g) => g.id === config.activeId); if (act && isClaudeGroup(act)) { if (!config.activeClaudeId) config.activeClaudeId = act.id; // 优先选真正的中转分组(非默认、非 claude);没有再退到默认分组 const proxy = config.groups.find((g) => !isClaudeGroup(g) && !g.isDefault && !isToolOnlyGroup(g)); const fallback = config.groups.find((g) => !isClaudeGroup(g)); config.activeId = (proxy || fallback || {}).id || null; } const activeOpenAI = config.groups.find((g) => g.id === config.activeId); if (isToolOnlyGroup(activeOpenAI)) { const proxy = config.groups.find((g) => !isClaudeGroup(g) && !g.isDefault && !isToolOnlyGroup(g)); const fallback = config.groups.find((g) => !isClaudeGroup(g) && !isToolOnlyGroup(g)); config.activeId = (proxy || fallback || {}).id || null; save(); } const activeClaude = config.groups.find((g) => g.id === config.activeClaudeId); if (isToolOnlyGroup(activeClaude)) { const cg = config.groups.find((g) => isClaudeGroup(g) && !isToolOnlyGroup(g)); config.activeClaudeId = cg ? cg.id : null; save(); } // 补齐 claude 槽:若从未设过,且存在 claude 分组,默认选第一个 if (!config.activeClaudeId) { const cg = config.groups.find((g) => isClaudeGroup(g)); if (cg) config.activeClaudeId = cg.id; } } function save() { const out = { ...config, routerKey: enc(config.routerKey), groups: config.groups.map((g) => { const o = { ...g, key: enc(g.key) }; if (o.officialBackup && o.officialBackup.authJson) { o.officialBackup = { ...o.officialBackup, authJson: enc(o.officialBackup.authJson) }; } return o; }), }; const p = cfgPath(); safeWriteFileSync(p, JSON.stringify(out, null, 2), "utf-8"); try { fs.chmodSync(p, 0o600); } catch {} } // 取激活分组。wantClaude=true 取 Anthropic 族(activeClaudeId),否则取 OpenAI 族(activeId)。 function activeGroup(wantClaude) { load(); const id = wantClaude ? config.activeClaudeId : config.activeId; return config.groups.find((g) => g.id === id) || null; } function requestModel(body) { try { const parsed = JSON.parse(Buffer.isBuffer(body) ? body.toString("utf-8") : String(body || "")); return String(parsed && parsed.model || "").trim(); } catch { return ""; } } function requestRouteHintModel(body) { let parsed; try { parsed = JSON.parse(Buffer.isBuffer(body) ? body.toString("utf-8") : String(body || "")); } catch { return ""; } const seen = new Set(); const texts = []; const walk = (value) => { if (value == null) return; if (typeof value === "string") { texts.push(value); return; } if (typeof value !== "object" || seen.has(value)) return; seen.add(value); if (Array.isArray(value)) { value.forEach(walk); return; } for (const key of ["instructions", "text", "content", "input", "output", "arguments", "prompt"]) { walk(value[key]); } }; walk(parsed); const text = texts.join("\n"); const m = text.match(/TD_MODEL_ROUTE\s+model=["']?([^"'\]\s;]+)/i); return m ? String(m[1] || "").trim() : ""; } function rewriteRequestModel(body, model) { const target = String(model || "").trim(); if (!target || !body.length) return body; let j; try { j = JSON.parse(body.toString("utf-8")); } catch { return body; } if (!j || typeof j !== "object" || Array.isArray(j) || j.model === target) return body; j.model = target; return Buffer.from(JSON.stringify(j), "utf-8"); } // 返回 { group, byModel }:显式模型命中 routeModels 时不受 activeId 影响。 // routeModels 由员工团队生成时登记,避免用户切换激活分组后子 Agent 跑错上游。 function requestGroup(wantClaude, body) { const active = activeGroup(wantClaude); if (wantClaude) return { group: active, byModel: false }; load(); const candidates = config.groups.filter((g) => !g.isDefault && !isClaudeGroup(g) && !isToolOnlyGroup(g) && g.baseUrl && g.key ); // 只有带明确路由提示的团队/子 Agent 请求才按模型覆盖 activeId。 // 普通 Codex 请求如果仍然带着旧的模型名(例如顶部选择器未刷新,还停留在 // kimi-k3),绝不能因为 exact/routeModels 命中而把用户刚选的 activeId 拽回去。 const hintModel = requestRouteHintModel(body); if (hintModel) { const explicit = candidates.find((g) => Array.isArray(g.routeModels) && g.routeModels.includes(hintModel)); if (explicit) return { group: explicit, byModel: true, routeModel: hintModel }; const exact = candidates.find((g) => g.model && String(g.model) === hintModel); if (exact) return { group: exact, byModel: true, routeModel: hintModel }; } const model = requestModel(body); if (!model) return { group: active, byModel: false }; const explicit = candidates.find((g) => Array.isArray(g.routeModels) && g.routeModels.includes(model)); if (explicit) return { group: explicit, byModel: true, routeModel: model }; return { group: active, byModel: false }; } // 判断一个进来的请求是否是 Anthropic 协议(Claude Code):走 /v1/messages,或带 anthropic 头。 function reqIsAnthropic(req) { const h = req.headers || {}; return /\/v1\/messages/.test(req.url || "") || "anthropic-version" in h || "x-api-key" in h; } // —— 默认分组:客户原本的 Codex 配置(自己账号,直连)—— function readConfigToml() { try { return fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8"); } catch { return ""; } } function currentModelProvider() { const head = readConfigToml().split("\n[")[0]; const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m); return m ? m[1] : ""; } function modelProviderFromText(text) { const head = String(text || "").split("\n[")[0]; const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m); return m ? m[1] : ""; } // 读根级 model(新版桌面 Codex 登录态通常没有;用于复原时精确还原/清除中转写入的 model) function currentRootModel() { const head = readConfigToml().split("\n[")[0]; const m = head.match(/^\s*model\s*=\s*["']([^"']+)/m); return m ? m[1] : ""; } function extractProviderBlock(text, prov) { const esc = prov.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`, "m"); const m = text.match(re); return m ? m[0].trimEnd() + "\n" : ""; } function readAuthKey() { try { return JSON.parse(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8")).OPENAI_API_KEY || ""; } catch { return ""; } } function maskSecret(s) { if (!s) return ""; if (s.length <= 10) return "****"; return s.slice(0, 6) + "..." + s.slice(-4); } function authSummaryFromText(text) { if (!text) return { exists: false, loginMode: "未检测到凭证", hasOpenAIKey: false, keyPreview: "", hasTokens: false }; try { const a = JSON.parse(text); const key = a.OPENAI_API_KEY || ""; const tokens = a.tokens || {}; const hasTokens = !!(tokens.access_token || tokens.id_token || tokens.refresh_token); return { exists: true, loginMode: hasTokens ? "ChatGPT OAuth 登录" : (key ? "API Key" : "未检测到凭证"), hasOpenAIKey: !!key, keyPreview: maskSecret(key), hasTokens, }; } catch { return { exists: true, loginMode: "auth.json 解析失败", hasOpenAIKey: false, keyPreview: "", hasTokens: false }; } } function readAuthSummary() { try { return authSummaryFromText(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8")); } catch { return authSummaryFromText(""); } } // 是否已用自己账号登录 Codex(ChatGPT OAuth)——凭证是 auth.json 的 tokens,而非 API key。 // 这是「我的账号 · 直连」的真实判据:只要 tokens 在,切中转只是覆盖 OPENAI_API_KEY,登录不会丢。 function isLoggedIn() { try { const a = JSON.parse(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8")); return !!(a && a.tokens && (a.tokens.access_token || a.tokens.id_token || a.tokens.refresh_token)); } catch { return false; } } // 从 TOML 文本里删除某个 section(从 [header] 行删到下一个 [ 行之前),按行处理最稳 function removeTomlSection(text, header) { const lines = text.split("\n"); const out = []; let skip = false; for (const line of lines) { const t = line.trim(); if (t === `[${header}]`) { skip = true; continue; } if (skip && t.startsWith("[") && t.endsWith("]")) skip = false; // 遇到下一个 section,停止跳过 if (!skip) out.push(line); } return out.join("\n").replace(/\n{3,}/g, "\n\n"); } function isManagedProviderId(id) { return id === PROVIDER_ID || id === DIRECT_PROVIDER_ID; } function configLooksManaged(text) { const s = String(text || ""); if (!s.trim()) return false; if (isManagedProviderId(modelProviderFromText(s))) return true; return s.includes(`[model_providers.${PROVIDER_ID}]`) || s.includes(`[model_providers.${DIRECT_PROVIDER_ID}]`); } // 当前 Codex 是否仍保留“本地路由”接管痕迹。这个状态只用于页面显示和取消按钮, // 不把直连中转(td-direct)误显示成已启用本地路由。 function codexRouteApplied() { const text = readConfigToml(); if (modelProviderFromText(text) === PROVIDER_ID) return true; if (extractProviderBlock(text, PROVIDER_ID)) return true; try { const auth = JSON.parse(readTextIfExists(path.join(CODEX_HOME, "auth.json")) || "{}"); return auth.OPENAI_API_KEY === config.routerKey; } catch { return false; } } function cleanOfficialConfigToml(text) { let out = String(text || ""); out = removeTomlSection(out, `model_providers.${PROVIDER_ID}`); out = removeTomlSection(out, `model_providers.${DIRECT_PROVIDER_ID}`); out = out.replace(/^\s*model_provider\s*=.*$\n?/m, ""); out = out.replace(/^\s*model\s*=.*$\n?/m, ""); return out.replace(/\n{3,}/g, "\n\n").trimEnd() + (out.trim() ? "\n" : ""); } function authLooksManaged(text) { try { const auth = JSON.parse(text || "{}"); const key = auth.OPENAI_API_KEY || ""; return !!(key && (key === config.routerKey || /^rk-[a-z0-9-]+/i.test(key) || config.groups.some((g) => !g.isDefault && g.key === key))); } catch { return false; } } function cleanOfficialAuthJson(text) { let auth = {}; try { auth = JSON.parse(text || "{}"); } catch {} const tokens = auth.tokens || {}; const hasTokens = !!(tokens.access_token || tokens.id_token || tokens.refresh_token); if (hasTokens || authLooksManaged(text)) delete auth.OPENAI_API_KEY; return JSON.stringify(auth, null, 2); } function readTextIfExists(file) { try { return fs.readFileSync(file, "utf-8"); } catch { return ""; } } function routerConfigBackups() { const dir = path.join(CODEX_HOME, "backups_state", "router"); let names = []; try { names = fs.readdirSync(dir); } catch { return []; } return names .filter((n) => /^config\.toml\.\d+\.bak$/.test(n)) .sort((a, b) => { const ta = Number((a.match(/\.(\d+)\.bak$/) || [])[1] || 0); const tb = Number((b.match(/\.(\d+)\.bak$/) || [])[1] || 0); return ta - tb; }) .map((n) => readTextIfExists(path.join(dir, n))) .filter(Boolean); } function bestOfficialConfigToml(...preferred) { const officialDir = path.join(CODEX_HOME, "backups_state", "router", "official-login"); const candidates = [ readTextIfExists(path.join(officialDir, "config.toml.clean")), readTextIfExists(path.join(officialDir, "config.toml.orig")), ...routerConfigBackups(), ...preferred, ].filter((s) => s && s.trim()); const clean = candidates.find((s) => !configLooksManaged(s)); if (clean) return clean.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; return cleanOfficialConfigToml(candidates[0] || ""); } function bestOfficialAuthJson(...preferred) { const officialDir = path.join(CODEX_HOME, "backups_state", "router", "official-login"); const candidates = [ readTextIfExists(path.join(officialDir, "auth.json.clean")), readTextIfExists(path.join(officialDir, "auth.json.orig")), ...preferred, ].filter((s) => s && s.trim()); const clean = candidates.find((s) => !authLooksManaged(s)); return cleanOfficialAuthJson(clean || candidates[0] || "{}"); } function backupConfig(text) { try { const dir = path.join(CODEX_HOME, "backups_state", "router"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "config.toml." + Date.now() + ".bak"), text || ""); } catch {} } // 首次接管 Codex 配置前,把官方登录信息(含 OAuth tokens 的 auth.json + 当前 config.toml) // 完整快照进默认分组 def.officialBackup,并落一份磁盘副本。幂等:只在第一次真正执行。 // 保证任何时候「切回默认」都能 100% 还原回官方登录态。 function snapshotOfficialLogin(def) { if (!def) return false; let configToml = ""; let authJson = ""; try { configToml = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8"); } catch {} try { authJson = fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8"); } catch {} const backup = def.officialBackup || {}; const backupAuth = backup.authJson || ""; const hasTokens = isLoggedIn() || !!authSummaryFromText(backupAuth).hasTokens || !!authSummaryFromText(authJson).hasTokens; const polluted = configLooksManaged(backup.configToml || "") || authLooksManaged(backup.authJson || "") || isManagedProviderId(def.modelProvider); if (backup.done && !hasTokens && !polluted) return false; // 纯 API key 老用户且快照干净:沿用原始快照。 if (hasTokens || polluted) { const cleanConfigToml = bestOfficialConfigToml(backup.configToml || "", configToml); const cleanAuthJson = bestOfficialAuthJson(backup.authJson || "", authJson); const changed = !backup.done || backup.configToml !== cleanConfigToml || backup.authJson !== cleanAuthJson || def.modelProvider || def.providerBlock || def.authKey || def.origModel; const cleanProvider = modelProviderFromText(cleanConfigToml); def.modelProvider = cleanProvider && !isManagedProviderId(cleanProvider) ? cleanProvider : ""; def.providerBlock = def.modelProvider ? extractProviderBlock(cleanConfigToml, def.modelProvider) : ""; def.authKey = ""; def.origModel = ""; def.officialBackup = { done: true, ts: backup.ts || Date.now(), repairedTs: changed ? Date.now() : backup.repairedTs, configToml: cleanConfigToml, authJson: cleanAuthJson, hadTokens: hasTokens, }; try { const dir = path.join(CODEX_HOME, "backups_state", "router", "official-login"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "config.toml.clean"), cleanConfigToml, "utf-8"); fs.writeFileSync(path.join(dir, "auth.json.clean"), cleanAuthJson, "utf-8"); try { fs.chmodSync(path.join(dir, "auth.json.clean"), 0o600); } catch {} } catch {} return !!changed; } if (backup.done) return false; // 幂等守卫 // 磁盘副本(灾备):仅当不存在时写,避免二次接管覆盖原始官方备份 try { const dir = path.join(CODEX_HOME, "backups_state", "router", "official-login"); fs.mkdirSync(dir, { recursive: true }); const cfgOrig = path.join(dir, "config.toml.orig"); const authOrig = path.join(dir, "auth.json.orig"); if (configToml && !fs.existsSync(cfgOrig)) fs.writeFileSync(cfgOrig, configToml, "utf-8"); if (authJson && !fs.existsSync(authOrig)) { fs.writeFileSync(authOrig, authJson, "utf-8"); try { fs.chmodSync(authOrig, 0o600); } catch {} } } catch {} def.officialBackup = { done: true, ts: Date.now(), configToml, authJson, hadTokens: isLoggedIn(), }; return true; } // 首次使用时,把当前(原始)Codex 配置快照为不可删除的「默认」分组。 // 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态, // 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。 function ensureDefault() { if (!config) return; const loggedIn = isLoggedIn(); const existing = config.groups.find((g) => g.isDefault); if (existing) { let changed = snapshotOfficialLogin(existing); // 幂等:确保已装用户也补上官方登录完整备份,并修复历史污染快照 // 自愈:已用自己账号登录,但历史版本把默认分组错误地快照成了中转 provider → // 纠正为「纯登录态」,保证「切回默认」永远能干净复原自己账号。 if (loggedIn && (existing.modelProvider || existing.providerBlock || existing.authKey)) { existing.modelProvider = ""; existing.providerBlock = ""; existing.authKey = ""; existing.origModel = ""; changed = true; } if (changed) save(); return; } if (loggedIn) { // 已登录自己账号:默认分组 = Codex 原生登录直连(不覆盖 model_provider、无 API key、 // 保留 auth.json 的 tokens)。无论此刻 config.toml 指向哪个中转,都能干净复原。 config.groups.unshift({ id: "default", isDefault: true, name: "默认(我的账号 · 直连)", modelProvider: "", providerBlock: "", authKey: "", origModel: currentRootModel(), // 记住原始根级 model(新版桌面登录态通常无 → ""),复原时精确还原 }); snapshotOfficialLogin(config.groups[0]); if (!config.activeId) config.activeId = "default"; save(); return; } // 未用 OAuth 登录(纯 API key 用户,无 tokens 可保护):沿用原逻辑,快照当前直连 provider。 const prov = currentModelProvider(); if (!prov || isManagedProviderId(prov)) return; // 已是本软件接管 / 无配置 → 不快照 const text = readConfigToml(); config.groups.unshift({ id: "default", isDefault: true, name: "默认(我的账号 · 直连)", modelProvider: prov, providerBlock: extractProviderBlock(text, prov), authKey: readAuthKey(), origModel: currentRootModel(), }); snapshotOfficialLogin(config.groups[0]); if (!config.activeId) config.activeId = "default"; save(); } // 模型目录路径(config.toml 的 model_catalog_json,默认 cc-switch-model-catalog.json) function catalogPath() { const m = readConfigToml().match(/model_catalog_json\s*=\s*["']([^"']+)/); const name = m ? m[1] : "cc-switch-model-catalog.json"; return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name); } // 国产模型的实际可用窗口通常小于 Codex 模板模型。分组未填写时给出保守值, // 让 Codex 在上游拒绝请求前预留足够空间完成自动压缩。用户填写的值始终优先 // (≥1024 即生效;过小的值多半也是有意为之,比如想强制尽早压缩)。 function effectiveContextWindow(group, fallback = 0) { const configured = parseInt(group && group.contextWindow, 10); if (configured >= 1024) return configured; const hint = `${group && group.model || ""} ${group && group.name || ""}`.toLowerCase(); if (/deepseek/.test(hint)) return 65536; if (/kimi/.test(hint)) return 131072; return fallback; } function setCompactionHeadroom(model, window) { // Desktop starts its own compaction from this ratio. The 95% inherited from // GPT templates leaves too little room for the compaction prompt on 64K/128K // third-party models, so the request can hit the provider limit first. if (!model || !window) return; model.effective_context_window_percent = Math.min( Number(model.effective_context_window_percent) || 95, 80 ); } // Codex 只有在 config.toml 显式写了 model_catalog_json 时才会读本地模型目录;不写这行, // 本工具登记的 context_window / 压缩阈值全部无效,Codex 会把未知模型按默认 GPT 模板 // (272000×95%≈258K)处理 → 深度求索/Kimi 真实窗口远小于此 → 永远等不到自动压缩。 // 另外该路径按进程 CWD 解析(桌面端 CWD 不固定),必须写绝对路径。 // 已指向其它目录文件时尊重用户配置不动;目录文件不存在/为空时跳过,避免 Codex 启动报错。 function ensureCatalogLinked(text) { const abs = catalogPath(); try { const cat = JSON.parse(fs.readFileSync(abs, "utf-8")); if (!cat || !Array.isArray(cat.models) || !cat.models.length) return text; } catch { return text; } const m = String(text || "").match(/^\s*model_catalog_json\s*=\s*["']([^"']+)["']/m); if (m) { const existing = m[1]; const resolved = path.isAbsolute(existing) ? existing : path.join(CODEX_HOME, existing); if (resolved !== abs) return text; // 用户指向了自己的目录文件,尊重 if (path.isAbsolute(existing)) return text; // 已是绝对路径且指向同一文件 return text.replace(/^\s*model_catalog_json\s*=.*$/m, `model_catalog_json = "${abs}"`); } return `model_catalog_json = "${abs}"\n${text}`; } // 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩 // 铁律:只要分组指定了 model,就必须保证它在目录里存在(visibility=list)。 // 否则 config.toml 的根级 model 指向一个「目录里没有的模型」,Codex 会以未知模型启动、 // 无法解析出该模型 → 侧栏历史线程还原失败 = 用户看到「切模型后会话记录消失」。 // contextWindow 只是「可选的窗口覆盖」,绝不能作为「是否登记进目录」的前置条件。 function applyModelCatalog(group) { if (!group || group.isDefault || !group.model) return; // 不再要求 contextWindow const p = catalogPath(); let cat; try { cat = JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return; // 无目录文件就不动 } if (!cat.models || !cat.models.length) return; const win = effectiveContextWindow(group); // 未填时为 DeepSeek/Kimi 使用保守窗口 const existing = cat.models.find((m) => m.slug === group.model); if (existing) { if (win) { existing.context_window = win; existing.max_context_window = win; setCompactionHeadroom(existing, win); } existing.visibility = "list"; // 已在目录:无论有无窗口,都确保可见 } else { const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段) // 未指定窗口时,用模板自带窗口兜底(再退到 128000),保证新模型有合法窗口 const w = win || parseInt(tpl.context_window, 10) || 128000; tpl.slug = group.model; tpl.display_name = group.model; tpl.description = group.model; tpl.context_window = w; tpl.max_context_window = w; setCompactionHeadroom(tpl, w); tpl.priority = 900; tpl.visibility = "list"; cat.models.push(tpl); } try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {} safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8"); } // 【根治「切模型丢会话」】把分组中转站 /models 返回的全部文本模型,先登记进 Codex 模型目录。 // 关键:用户是在 Codex 本体的模型选择器里切模型的,那条路径不经过本 App,App 的 applyModelCatalog // 不会触发;若目标 slug 不在目录里,Codex 会以未知模型启动、侧栏历史还原失败 = 会话"消失"。 // 因此必须【先回填】:把该中转能用的模型全部登记为 visibility=list,用户在 Codex 里选哪个都不丢。 // 非文本模型(画图/审阅/语音等)不是对话模型,排除;任何网络/解析失败都不动既有目录(离线安全)。 const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|tts|stt|embed|rerank|review|moderation|vision-ocr)/i; async function syncCatalogFromProvider(group) { try { if (!group || group.isDefault || !group.baseUrl || !group.key) return { ok: false, skipped: true }; if (group.mode === "claude") return { ok: false, skipped: true }; // Claude 族不写 Codex 目录 let ids = []; try { ids = await fetchModels(group.baseUrl, group.key); // 复用既有 /v1/models · /models 探测 } catch (e) { return { ok: false, error: (e && e.message) || String(e) }; // 网络/接口失败 → 不动既有目录 } const textIds = ids.filter((s) => !NON_TEXT_MODEL_RE.test(s)); if (!textIds.length) return { ok: false, error: "无可登记的文本模型" }; const p = catalogPath(); let cat; try { cat = JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return { ok: false, error: "无模型目录文件" }; } if (!cat.models || !cat.models.length) return { ok: false, error: "模型目录为空" }; const tpl0 = cat.models[0]; const w0 = parseInt(tpl0.context_window, 10) || 128000; // 回填窗口用分组的真实窗口(用户填写 > 国产模型保守值 > 模板窗口),并统一压缩阈值; // 否则回填模型继承 GPT 模板的 272000×95%≈258K,Codex 永远等不到自动压缩。 const win = effectiveContextWindow(group, w0); let added = 0; let revealed = 0; let refreshed = 0; for (const slug of textIds) { const ex = cat.models.find((m) => m.slug === slug); if (ex) { if (ex.visibility !== "list") { ex.visibility = "list"; revealed++; } if (win && (ex.context_window !== win || ex.max_context_window !== win)) { ex.context_window = win; ex.max_context_window = win; refreshed++; } setCompactionHeadroom(ex, win); continue; } const t = JSON.parse(JSON.stringify(tpl0)); // 克隆模板(含 base_instructions 等必需字段) t.slug = slug; t.display_name = slug; t.description = slug; t.context_window = win; t.max_context_window = win; setCompactionHeadroom(t, win); t.priority = 900; t.visibility = "list"; cat.models.push(t); added++; } if (added || revealed || refreshed) { try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {} safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8"); } return { ok: true, added, revealed, refreshed, total: textIds.length }; } catch (e) { return { ok: false, error: (e && e.message) || String(e) }; } } // 兜底:确保某个裸 model slug 一定在目录里(visibility=list)。用于 setConfigModel 前的自愈, // 覆盖「config.toml 指向的模型没有对应分组(手改/历史残骸)」这种也会导致侧栏历史还原失败的情况。 function ensureModelInCatalog(model) { if (!model) return; const p = catalogPath(); let cat; try { cat = JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return; // 无目录文件就不动 } if (!cat.models || !cat.models.length) return; const existing = cat.models.find((m) => m.slug === model); if (existing) { if (existing.visibility !== "list") { existing.visibility = "list"; try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {} safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8"); } return; } const tpl = JSON.parse(JSON.stringify(cat.models[0])); const w = parseInt(tpl.context_window, 10) || 128000; tpl.slug = model; tpl.display_name = model; tpl.description = model; tpl.context_window = w; tpl.max_context_window = w; tpl.priority = 900; tpl.visibility = "list"; cat.models.push(tpl); try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {} safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8"); } // 把 config.toml 根级 model 设成指定值(写入前先确保该模型已在目录里,避免 Codex 以未知模型启动) function setConfigModel(model) { if (!model) return; ensureModelInCatalog(model); const cfgFile = path.join(CODEX_HOME, "config.toml"); let text = readTextForUpdate(cfgFile); text = setRootModelInText(text, model); text = ensureCatalogLinked(text); safeWriteFileSync(cfgFile, text, "utf-8"); } function setRootModelInText(text, model) { if (!model) return text; if (/^\s*model\s*=/m.test(text)) return text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`); if (/^\s*model_provider\s*=/m.test(text)) { return text.replace(/^(\s*model_provider\s*=.*)$/m, `$1\nmodel = "${model}"`); } return `model = "${model}"\n${text}`; } // 恢复到默认(把 config.toml/auth.json 还原成客户原始配置,Codex 直连自己账号) function restoreDirect() { const def = config.groups.find((g) => g.isDefault); if (!def) { // 没有默认分组时也必须清理本工具的接管痕迹,不能让“取消路由”变成空操作。 const cfgFile = path.join(CODEX_HOME, "config.toml"); const before = readTextForUpdate(cfgFile); backupConfig(before); const text = cleanOfficialConfigToml(before); const authFile = path.join(CODEX_HOME, "auth.json"); const authText = cleanOfficialAuthJson(readTextForUpdate(authFile)); writeCodexConfigPair(text, authText); return; } if (snapshotOfficialLogin(def)) save(); const cfgFile = path.join(CODEX_HOME, "config.toml"); let text = readTextForUpdate(cfgFile); backupConfig(text); if (def.officialBackup && def.officialBackup.done && def.officialBackup.configToml && !configLooksManaged(def.officialBackup.configToml)) { const authText = def.officialBackup.authJson || JSON.stringify(readJsonForUpdate(path.join(CODEX_HOME, "auth.json")), null, 2); writeCodexConfigPair(def.officialBackup.configToml, authText); return; } // 先移除本地路由段 + 直连中转段(无论哪种复原都要清掉,避免残留污染) text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); if (def.modelProvider) { // 有原始直连 provider(纯 API key 用户,如 Mac 的 custom):设根键 + 追加原始段,避免重复键。 // 此分支完全保持旧行为(不动根级 model 行),确保已在用的 Mac 环境零回归。 if (/^\s*model_provider\s*=/m.test(text)) text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${def.modelProvider}"`); else text = `model_provider = "${def.modelProvider}"\n` + text; if (def.providerBlock) { text = removeTomlSection(text, `model_providers.${def.modelProvider}`); text = text.trimEnd() + "\n\n" + def.providerBlock; } } else { // 纯登录态(Windows 新格式 OAuth):删根级 model_provider 覆盖,回到 Codex 原生登录直连。 text = text.replace(/^\s*model_provider\s*=.*$\n?/m, ""); // 清掉中转写入的根级 model;原始登录态通常无 model,若快照记了 origModel 则精确还原。 text = text.replace(/^\s*model\s*=.*$\n?/m, ""); if (def.origModel) text = `model = "${def.origModel}"\n` + text; } text = text.replace(/\n{3,}/g, "\n\n"); const af = path.join(CODEX_HOME, "auth.json"); let authText; // 优先用官方登录完整备份整体还原(含 OAuth tokens)→ 100% 复原官方登录态 if (def.officialBackup && def.officialBackup.done && def.officialBackup.hadTokens && def.officialBackup.authJson) { authText = def.officialBackup.authJson; } else { const a = readJsonForUpdate(af); // 登录态:清空 OPENAI_API_KEY、保留 tokens → Codex 用 OAuth 直连;纯 key 用户则还原原始 key a.OPENAI_API_KEY = def.authKey || null; authText = JSON.stringify(a, null, 2); } writeCodexConfigPair(text, authText); } function getGroupKey(id) { load(); const g = config.groups.find((x) => x.id === id); return g ? g.key : ""; } // 按 id 取分组(含解密后的 key,供内部功能按指定分组直调中转)。只读,不改激活态/切回逻辑。 function groupById(id) { load(); return config.groups.find((x) => x.id === id) || null; } // 登记一个模型属于哪个 OpenAI 兼容分组。生成团队时调用,运行时按请求 model 分流。 // 只保存模型名和分组归属,不改变当前激活分组,也不触碰 Codex 配置文件。 function registerModelRoute(groupId, model) { load(); const name = String(model || "").trim(); const group = config.groups.find((g) => g.id === groupId); if (!name || !group || group.isDefault || isClaudeGroup(group) || !group.baseUrl || !group.key) { return { ok: false, skipped: true }; } let changed = false; config.groups.forEach((g) => { if (g.id === group.id) return; if (Array.isArray(g.routeModels) && g.routeModels.includes(name)) { g.routeModels = g.routeModels.filter((x) => x !== name); changed = true; } }); group.routeModels = Array.isArray(group.routeModels) ? group.routeModels : []; if (!group.routeModels.includes(name)) { group.routeModels.push(name); changed = true; } if (changed) save(); return { ok: true, groupId: group.id, model: name, changed }; } // 可供画布等内部功能"按需选用"的中转分组列表:排除默认直连分组,需配齐 baseUrl+key。只读。 function usableGroups() { load(); return config.groups.filter((g) => !g.isDefault && g.baseUrl && g.key); } // 给内部功能(如「会话记忆」摘要)用的可调用分组:优先当前激活的中转分组, // 不可用则取第一个配置齐全的中转分组;默认分组(直连账号)不参与。 function llmGroup() { load(); const usable = (g) => g && !g.isDefault && !isToolOnlyGroup(g) && g.baseUrl && g.key && g.model; const act = config.groups.find((g) => g.id === config.activeId); if (usable(act)) return act; return config.groups.find(usable) || null; } // 供「语音输入」转写用:优先当前激活且勾选 STT 的分组,否则取任一勾选 STT 的分组。 // 需配齐 baseUrl+key 且勾了 stt;返回的 group 带 sttModel(如 speech-01 / whisper-1)。 function sttGroup() { load(); const ok = (g) => g && !g.isDefault && g.baseUrl && g.key && g.stt; const act = config.groups.find((g) => g.id === config.activeId); if (ok(act)) return act; return config.groups.find(ok) || null; } function modelFetchBases(baseUrl) { const base = String(baseUrl || "").replace(/\/$/, ""); const bases = [base]; try { const u = new URL(base); if (/\/anthropic$/i.test(u.pathname)) { u.pathname = u.pathname.replace(/\/anthropic$/i, "") || "/"; bases.push(u.href.replace(/\/$/, "")); } } catch {} return Array.from(new Set(bases.filter(Boolean))); } function knownModelsForBase(baseUrl) { try { const u = new URL(String(baseUrl || "")); if (u.hostname === "api.deepseek.com" && /\/anthropic\/?$/i.test(u.pathname)) { return ["deepseek-v4-pro[1m]", "deepseek-v4-flash"]; } } catch {} return []; } // 拉取某个中转 API 的模型列表(试 /v1/models 再 /models) async function fetchModels(baseUrl, key) { if (!baseUrl) throw new Error("请先填 API 地址"); let lastErr = null; for (const base of modelFetchBases(baseUrl)) { for (const p of ["/v1/models", "/models"]) { try { const res = await fetch(base + p, { headers: { Authorization: "Bearer " + (key || ""), "User-Agent": "codex-market" }, }); if (!res.ok) { lastErr = new Error("HTTP " + res.status); continue; } const j = await res.json(); const arr = Array.isArray(j) ? j : j.data || j.models || []; const list = arr.map((m) => (typeof m === "string" ? m : m.id || m.name)).filter(Boolean); if (list.length) return Array.from(new Set(list)); } catch (e) { lastErr = e; } } } const known = knownModelsForBase(baseUrl); if (known.length) return known; throw lastErr || new Error("未获取到模型列表(接口可能不支持 /v1/models)"); } // —— 代理服务器 —— let server = null; // 抓取一次真实请求/响应到 userData/router-capture/(用于开发协议转换) function captureDir() { return path.join(app.getPath("userData"), "router-capture"); } function writeCapture(name, data) { try { fs.mkdirSync(captureDir(), { recursive: true }); fs.writeFileSync(path.join(captureDir(), name), data); } catch {} } function upstreamHeaders(incoming, host, bodyLength) { const headers = { ...(incoming || {}) }; const connectionHeaders = String(headers.connection || "") .split(",") .map((name) => name.trim().toLowerCase()) .filter(Boolean); for (const name of connectionHeaders) delete headers[name]; for (const name of [ "connection", "proxy-connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade", "content-length", ]) { delete headers[name]; } headers.host = host; headers["content-length"] = bodyLength; return headers; } function requestTextForIntent(req) { const parts = []; const walk = (v) => { if (v == null) return; if (typeof v === "string") { parts.push(v); return; } if (Array.isArray(v)) { for (const it of v) walk(it); return; } if (typeof v === "object") { for (const key of ["text", "content", "input", "output", "arguments"]) walk(v[key]); } }; walk(req && req.input); return parts.join("\n"); } function hasExplicitGenImageIntent(req) { return /\$gen[\s_-]*image\b/i.test(requestTextForIntent(req)); } function stripGenImageSkill(instructions) { if (typeof instructions !== "string" || !instructions.includes("gen-image")) return instructions; return instructions .split(/\r?\n/) .filter((line) => !/^\s*-\s+gen-image\s*:/.test(line)) .join("\n"); } function rewriteResponsesBody(body, group, { preserveModel = false } = {}) { if (!body.length) return body; let j; try { j = JSON.parse(body.toString("utf-8")); } catch { return body; } if (!j || typeof j !== "object") return body; let changed = false; if (!preserveModel && group.model && "model" in j && j.model !== group.model) { j.model = group.model; changed = true; } // 普通聊天分组没有 image-generation 权限时,上游会因 gen-image skill 声明误判为生图请求。 // 只有用户显式输入 "$gen image" 才保留该 skill,让真正生图走脚本里的图片接口分组。 if (!hasExplicitGenImageIntent(j)) { const nextInstructions = stripGenImageSkill(j.instructions); if (nextInstructions !== j.instructions) { j.instructions = nextInstructions; changed = true; } } return changed ? Buffer.from(JSON.stringify(j), "utf-8") : body; } function rewriteAnthropicBody(body, group) { const model = group && group.model ? String(group.model).trim() : ""; if (!body.length || !model) return body; let j; try { j = JSON.parse(body.toString("utf-8")); } catch { return body; } if (!j || typeof j !== "object" || Array.isArray(j)) return body; if (j.model === model) return body; j.model = model; return Buffer.from(JSON.stringify(j), "utf-8"); } function handle(req, res) { const wantClaude = reqIsAnthropic(req); // 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来) const auth = req.headers["authorization"] || ""; if (config.routerKey && auth !== "Bearer " + config.routerKey) { // 放行 localhost 但不匹配时也继续(避免 key 变动导致不可用),仅记录 } const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", async () => { const rawBody = Buffer.concat(chunks); const routed = requestGroup(wantClaude, rawBody); const g = routed.group; if (!g) { res.writeHead(503, { "content-type": "text/plain; charset=utf-8" }); res.end(wantClaude ? "本地路由:未选择 Claude Code 分组" : "本地路由:未选择中转分组"); return; } let body = rawBody; const doCapture = /responses/.test(req.url || ""); if (routed.routeModel) body = rewriteRequestModel(body, routed.routeModel); if (doCapture) body = rewriteResponsesBody(body, g, { preserveModel: routed.byModel }); if (wantClaude) body = rewriteAnthropicBody(body, g); // —— chat 模式:Responses ⇄ Chat Completions 协议转换(DeepSeek/GLM/Kimi 等)—— if (g.mode === "chat" && /responses/.test(req.url || "")) { let responsesReq = {}; try { responsesReq = JSON.parse(body.toString("utf-8")); } catch {} if (g.multimodal) { try { await mediaUnderstanding.enrichResponsesRequest(responsesReq, { instruction: requestTextForIntent(responsesReq), }); } catch {} } const chatReq = translate.responsesToChat(responsesReq, routed.byModel ? responsesReq.model : (g.model || responsesReq.model), { // 图片已由 MiniMax 预处理为证据,故只移除会读取原图的 view_image; // shell/文件等编码工具仍必须保留,否则模型只能回复一句文字而无法执行任务。 omitVisualTools: !!g.multimodal, }); const chatBody = Buffer.from(JSON.stringify(chatReq), "utf-8"); // 拼 chat 端点:多数网关/厂商(DeepSeek/GLM/Kimi/one-api)是 /v1/chat/completions const cbase = g.baseUrl.replace(/\/$/, ""); let chatEndpoint; if (/\/chat\/completions$/.test(cbase)) chatEndpoint = cbase; // 用户已填全 else if (/\/v\d+$/.test(cbase)) chatEndpoint = cbase + "/chat/completions"; // 已含 /v1 else chatEndpoint = cbase + "/v1/chat/completions"; let chatUrl; try { chatUrl = new URL(chatEndpoint); } catch { res.writeHead(502); res.end("分组 API 地址无效"); return; } const https2 = chatUrl.protocol === "https:"; const conv = new translate.ChatToResponses(chatReq.model); let ended = false; const endOnce = (tail) => { if (ended) return; ended = true; if (tail) res.write(tail); res.end(); }; res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" }); if (doCapture) writeCapture("last-chat-request.json", `UPSTREAM ${chatUrl.href}\n\n` + JSON.stringify(chatReq, null, 2)); const up2 = (https2 ? https : http).request( { method: "POST", hostname: chatUrl.hostname, port: chatUrl.port || (https2 ? 443 : 80), path: chatUrl.pathname + chatUrl.search, headers: { "content-type": "application/json", authorization: "Bearer " + g.key, accept: "text/event-stream", host: chatUrl.host, "content-length": Buffer.byteLength(chatBody), }, }, (upRes) => { if (upRes.statusCode !== 200) { const eb = []; upRes.on("data", (c) => eb.push(c)); upRes.on("end", () => { const raw = Buffer.concat(eb).toString("utf-8").slice(0, 2000); if (doCapture) writeCapture("last-chat-response.txt", `STATUS ${upRes.statusCode}\n\n` + raw); // 提取上游错误消息,作为一条助手回复显示给 Codex(而不是"断连") let human = raw.slice(0, 800); try { const j = JSON.parse(raw); human = (j.error && (j.error.message || j.error)) || j.message || human; } catch {} let s = conv.onChunk({ choices: [{ delta: { content: `⚠️ 中转上游返回错误 (${upRes.statusCode}):\n${human}` } }] }); s += conv.finish(); endOnce(s); }); return; } const rawBuf = []; let sseBuf = ""; upRes.on("data", (chunk) => { if (doCapture) rawBuf.push(chunk); sseBuf += chunk.toString("utf-8"); let i; while ((i = sseBuf.indexOf("\n")) >= 0) { const line = sseBuf.slice(0, i).trim(); sseBuf = sseBuf.slice(i + 1); if (!line.startsWith("data:")) continue; const data = line.slice(5).trim(); if (data === "[DONE]") { endOnce(conv.finish()); return; } try { res.write(conv.onChunk(JSON.parse(data))); } catch {} } }); upRes.on("end", () => { if (doCapture) writeCapture("last-chat-response.txt", `STATUS 200\n\n` + Buffer.concat(rawBuf).toString("utf-8").slice(0, 8000)); endOnce(conv.finish()); }); } ); up2.on("error", (e) => { if (doCapture) writeCapture("last-chat-response.txt", "PROXY ERROR: " + String(e.message || e)); endOnce(conv.ev("error", { code: "proxy_error", message: String(e.message || e) })); }); up2.end(chatBody); return; } let target; try { target = new URL(g.baseUrl.replace(/\/$/, "") + req.url); } catch { res.writeHead(502); res.end("分组 API 地址无效"); return; } const isHttps = target.protocol === "https:"; const mod = isHttps ? https : http; const headers = upstreamHeaders(req.headers, target.host, body.length); // Anthropic 风格请求(Claude Code 走 /v1/messages):保持客户端原本的认证方式,只把密钥换成分组的真实密钥。 // 关键:不要同时塞 Bearer + x-api-key —— 有的中转会据此判定为"直连 API 模式"而落到空账号池,返回 "No available accounts"。 const isAnthropic = /\/v1\/messages/.test(req.url || "") || "anthropic-version" in headers || "x-api-key" in headers; if (isAnthropic) { if ("x-api-key" in headers) { headers["x-api-key"] = g.key; // 客户端用的是 x-api-key 模式 delete headers["authorization"]; } else { headers["authorization"] = "Bearer " + g.key; // Claude Code 默认:只发 Bearer delete headers["x-api-key"]; } if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01"; } else { headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥 } // 1M 长上下文:若本次请求的模型被该分组标记为「1M」,加上 Anthropic 的 1M beta 头解锁百万窗口。 // 对 GPT/GLM 等 OpenAI 兼容网关该头无害(被忽略);对 Claude 生效。 if (Array.isArray(g.oneMillionModels) && g.oneMillionModels.length && body.length) { let reqModel = ""; try { reqModel = (JSON.parse(body.toString("utf-8")) || {}).model || ""; } catch {} if (reqModel && g.oneMillionModels.includes(reqModel)) { const beta = "context-1m-2025-08-07"; const existing = headers["anthropic-beta"]; headers["anthropic-beta"] = existing ? (existing.includes(beta) ? existing : existing + "," + beta) : beta; } } if (doCapture) { writeCapture("last-request.json", `URL ${req.method} ${req.url}\nUPSTREAM ${target.href}\n\n` + body.toString("utf-8")); } const up = mod.request( { method: req.method, hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), path: target.pathname + target.search, headers, }, (upRes) => { res.writeHead(upRes.statusCode || 502, upRes.headers); if (doCapture) { const buf = []; upRes.on("data", (c) => { buf.push(c); res.write(c); }); upRes.on("end", () => { res.end(); writeCapture("last-response.txt", `STATUS ${upRes.statusCode}\n\n` + Buffer.concat(buf).toString("utf-8")); }); } else { upRes.pipe(res); // 流式透传(SSE 也 OK) } } ); up.on("error", (e) => { if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); res.end("上游错误:" + e.message); }); up.end(body); }); } function start() { load(); if (server) return { running: true, port: config.port }; return new Promise((resolve, reject) => { server = http.createServer(handle); server.on("error", (e) => { server = null; reject(e); }); server.listen(config.port, "127.0.0.1", () => resolve({ running: true, port: config.port })); }); } function stop() { if (server) { server.close(); server = null; } return { running: false }; } // 只解除本工具对 Codex 的接管,不把用户后来手写的 provider 覆盖成官方直连。 // 关键边界:如果根级 model_provider 已经不是 local-router/td-direct,说明用户已经 // 自己写了中转 provider,此时只清理本工具残留的段和路由 Key,保留用户配置。 function releaseCodexTakeover() { load(); const cfgFile = path.join(CODEX_HOME, "config.toml"); const before = readTextForUpdate(cfgFile); backupConfig(before); const managedProvider = isManagedProviderId(modelProviderFromText(before)); let text = before; // 无论根级当前指向谁,都先移除本工具可能残留的两个段,避免污染用户配置。 text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); if (managedProvider) { // 根级还指向本工具,才删根级 model_provider 和根级 model;否则不碰用户配置。 text = text.replace(/^\s*model_provider\s*=.*$\n?/m, ""); text = text.replace(/^\s*model\s*=.*$\n?/m, ""); } text = text.replace(/\n{3,}/g, "\n\n").trimEnd() + (text.trim() ? "\n" : ""); const authFile = path.join(CODEX_HOME, "auth.json"); const authRaw = readTextForUpdate(authFile); const auth = readJsonForUpdate(authFile); const authManaged = authLooksManaged(authRaw); if (authManaged) delete auth.OPENAI_API_KEY; writeCodexConfigPair(text, JSON.stringify(auth, null, 2)); return { removedManagedProvider: managedProvider, keptUserProvider: !managedProvider && !!modelProviderFromText(text), authCleared: authManaged, }; } // 路由页面的“停止”:关闭本地服务;只要 Codex 仍有本软件接管痕迹,就释放接管。 // 保留 stop() 的纯停止语义,避免改端口、退出应用等流程产生配置写入副作用。 function stopAndRestoreDirect() { return disableCodex(); } // “取消本地路由”必须是幂等操作:无论路由进程当前是否运行,只要 Codex 配置仍由本工具接管, // 都立即清除 local-router / td-direct / 路由 Key,同时保留用户自己写的 provider。 function disableCodex() { load(); const shouldRelease = configLooksManaged(readConfigToml()) || authLooksManaged(readTextIfExists(path.join(CODEX_HOME, "auth.json"))); const stopped = stop(); if (!shouldRelease) return { ...stopped, released: false, skippedRelease: true }; try { const detail = releaseCodexTakeover(); return { ...stopped, released: true, ...detail }; } catch (e) { return { ...stopped, released: false, releaseError: String((e && e.message) || e) }; } } function isRunning() { return !!server; } function status() { load(); return { running: isRunning(), port: config.port, routerKey: config.routerKey, address: `http://127.0.0.1:${config.port}`, activeId: config.activeId, activeClaudeId: config.activeClaudeId, claudeApplied: claudeApplied(), codexApplied: codexRouteApplied(), groups: config.groups.map((g) => ({ id: g.id, name: g.name, baseUrl: g.isDefault ? "(Codex 原始配置 · 直连你的账号)" : g.baseUrl, model: g.model || "", mode: g.mode || "responses", contextWindow: g.contextWindow || "", hasKey: g.isDefault ? true : !!g.key, isDefault: !!g.isDefault, codexImage: !!g.codexImage, codexVideo: !!g.codexVideo, stt: !!g.stt, sttModel: g.sttModel || "", multimodal: !!g.multimodal, oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [], })), }; } function defaultConfigInfo() { load(); const def = config.groups.find((g) => g.isDefault) || null; if (def && snapshotOfficialLogin(def)) save(); const currentConfigToml = readConfigToml(); const snapshotConfigToml = (def && def.officialBackup && def.officialBackup.configToml) || ""; const snapshotAuthJson = (def && def.officialBackup && def.officialBackup.authJson) || ""; return { ok: true, codexHome: CODEX_HOME, configPath: path.join(CODEX_HOME, "config.toml"), authPath: path.join(CODEX_HOME, "auth.json"), currentConfigToml, snapshotConfigToml, current: { modelProvider: currentModelProvider() || "Codex 原生登录态", model: currentRootModel() || "", managed: configLooksManaged(currentConfigToml) || authLooksManaged(readTextIfExists(path.join(CODEX_HOME, "auth.json"))), auth: readAuthSummary(), }, snapshot: { savedAt: def && def.officialBackup && def.officialBackup.ts ? def.officialBackup.ts : 0, repairedAt: def && def.officialBackup && def.officialBackup.repairedTs ? def.officialBackup.repairedTs : 0, modelProvider: (def && def.modelProvider) || "Codex 原生登录态", model: (def && def.origModel) || "", hasProviderBlock: !!(def && def.providerBlock), managed: configLooksManaged(snapshotConfigToml) || authLooksManaged(snapshotAuthJson), auth: authSummaryFromText(snapshotAuthJson), }, }; } // —————————————————————————————————————————————————————————————— // gen-image 生图技能:把「用于 Codex 生图」的中转分组凭证写进技能的 .env // —————————————————————————————————————————————————————————————— // 技能目录名可能因显示名不同而变(市场安装按 skill.name 派生中文目录), // 故扫描各 skills/*/SKILL.md 的 name 字段来定位,而非写死路径。 function genImageSkillDir() { const base = path.join(CODEX_HOME, "skills"); let names = []; try { names = fs.readdirSync(base, { withFileTypes: true }) .filter((d) => d.isDirectory() || d.isSymbolicLink()) .map((d) => d.name); } catch { return null; } for (const name of names) { try { const txt = fs.readFileSync(path.join(base, name, "SKILL.md"), "utf-8"); const fm = /^---\s*\n([\s\S]*?)\n---/.exec(txt); const scope = fm ? fm[1] : txt.slice(0, 400); const m = /^\s*name\s*:\s*(.+)$/im.exec(scope); if (m && m[1].trim().replace(/^["']|["']$/g, "") === "gen-image") return path.join(base, name); } catch {} } for (const guess of ["gen-image", "gen-image-生图"]) { if (fs.existsSync(path.join(base, guess, "SKILL.md"))) return path.join(base, guess); } return null; } function writeGenImageEnv(dir, baseUrl, key, model) { const body = "# 由 TD工具箱「中转」自动写入:勾选“在 Codex 中用于生图”的分组凭证。\n" + "# 取消勾选 / 删除该分组会被清除。请勿手动提交到 git。\n" + `IMAGE_API_BASE_URL=${baseUrl || ""}\n` + `IMAGE_API_KEY=${key || ""}\n` + `IMAGE_MODEL=${model || "gpt-image-2"}\n`; const p = path.join(dir, ".env"); fs.writeFileSync(p, body, "utf-8"); try { fs.chmodSync(p, 0o600); } catch {} } function clearGenImageEnv(dir) { const p = path.join(dir, ".env"); try { if (!fs.existsSync(p)) return; // 只清除工具箱自动写入的那份,保留用户手工建的 .env(避免误删) if (fs.readFileSync(p, "utf-8").includes("由 TD工具箱")) fs.unlinkSync(p); } catch {} } // 依据当前分组的 codexImage 标记,同步写入/清除 gen-image 的 .env。 // 生图默认用 gpt-image-2(分组的 model 是对话模型,不用于生图)。 function syncGenImage() { load(); const dir = genImageSkillDir(); const src = config.groups.find((g) => g.codexImage && !g.isDefault && g.baseUrl && g.key); if (!dir) return { ok: false, reason: src ? "skill-not-installed" : "no-skill" }; if (!src) { clearGenImageEnv(dir); return { ok: true, cleared: true, dir }; } writeGenImageEnv(dir, src.baseUrl, src.key, src.imageModel || "gpt-image-2"); return { ok: true, wrote: true, dir, group: src.name }; } // —— 分组增删改 / 切换 —— function upsertGroup(g) { load(); // 附件理解仅在 Responses -> Chat 的转译路径生效,避免错误保存到其它协议分组。 g.multimodal = g.mode === "chat" && !!g.multimodal; let id; let prevMode = null; // 编辑前的接口类型(用于判断是否从 claude 改成别的) if (g.id) { const i = config.groups.findIndex((x) => x.id === g.id); if (i >= 0) { prevMode = config.groups[i].mode || "responses"; config.groups[i] = { ...config.groups[i], ...g }; } else config.groups.push({ ...g }); id = g.id; } else { g.id = "g-" + crypto.randomBytes(6).toString("hex"); config.groups.push(g); id = g.id; } // 生图来源唯一:勾选的分组独占,清掉其它分组的标记 const cur = config.groups.find((x) => x.id === id); if (cur && cur.codexImage) { for (const grp of config.groups) if (grp.id !== id) grp.codexImage = false; } // 视频来源唯一:勾选的分组独占,清掉其它分组的标记 if (cur && cur.codexVideo) { for (const grp of config.groups) if (grp.id !== id) grp.codexVideo = false; } if (isToolOnlyGroup(cur) && config.activeId === id) { const next = config.groups.find((x) => !isClaudeGroup(x) && !x.isDefault && !isToolOnlyGroup(x)) || config.groups.find((x) => !isClaudeGroup(x) && !isToolOnlyGroup(x)); config.activeId = next ? next.id : null; } if (isToolOnlyGroup(cur) && config.activeClaudeId === id) { const next = config.groups.find((x) => isClaudeGroup(x) && !isToolOnlyGroup(x)); config.activeClaudeId = next ? next.id : null; } save(); try { syncGenImage(); } catch {} // 保存只保存配置,不自动接入 Codex/Claude,也不改 activeId。 // 真正启用由用户点击列表单选触发 setActive,避免插件专用分组被误设为默认对话分组。 const st = status(); if (cur && !cur.isDefault) { if (cur.id === config.activeClaudeId && cur.mode === "claude") { try { const r = claudeApplied() ? applyToClaudeCode() : (claudeDirectApplied() ? applyDirectToClaude(cur.id) : null); if (r && r.ok) st._claudeApplied = true; else if (r && !r.ok) { st._claudeSkipped = r.skipped; st._claudeReason = r.error; } } catch (e) { st._claudeError = (e && e.message) || String(e); } } // 仅当【本分组】之前是 claude 模式、现改回 Codex 模式 → 还原它写过的 Claude Code 配置 if (prevMode === "claude" && cur.mode !== "claude" && config.claudePrev && claudeDirectApplied()) { try { restoreClaudeCode(); st._claudeRestored = true; } catch {} } } return st; } function deleteGroup(id) { load(); const g = config.groups.find((x) => x.id === id); if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" }; const wasClaude = isClaudeGroup(g); config.groups = config.groups.filter((x) => x.id !== id); // 删的是哪族分组,就补选同族的另一个(找不到则清空该族激活槽),不影响另一族。 if (wasClaude) { if (config.activeClaudeId === id) { const next = config.groups.find((x) => isClaudeGroup(x) && !isToolOnlyGroup(x)); config.activeClaudeId = next ? next.id : null; } } else { if (config.activeId === id) { const next = config.groups.find((x) => !isClaudeGroup(x) && !x.isDefault && !isToolOnlyGroup(x)) || config.groups.find((x) => !isClaudeGroup(x) && !isToolOnlyGroup(x)); config.activeId = next ? next.id : null; } } save(); try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env return status(); } function setActive(id) { load(); const target = config.groups.find((g) => g.id === id); if (!target) return status(); if (isToolOnlyGroup(target)) { return { ...status(), _activeSkipped: true, _activeReason: "该分组是插件/画布专用分组,不参与 Codex/GPT 默认分组选择", }; } // Claude Code(Anthropic 族):落 activeClaudeId,写 ~/.claude/settings.json,绝不碰 Codex 配置。 // 与 OpenAI 族互不顶替 —— 两族可同时激活。 if (isClaudeGroup(target)) { const previousActiveClaudeId = config.activeClaudeId; const hadClaudePrev = Object.prototype.hasOwnProperty.call(config, "claudePrev"); const previousClaudePrev = hadClaudePrev ? JSON.parse(JSON.stringify(config.claudePrev)) : undefined; config.activeClaudeId = id; try { let claudeMode = "proxy"; let claudeSkipped, claudeReason; if (isRunning()) { // 路由在跑:settings.json 指向本地路由,并同步 ANTHROPIC_MODEL 让新开 CLI 立即显示当前分组模型。 claudeMode = "proxy"; const r = applyToClaudeCode(); if (!r.ok) { claudeSkipped = r.skipped; claudeReason = r.error; } } else { // 默认模式:直连中转,把分组地址写进 settings.json(新开 claude 会话生效) claudeMode = "direct"; const r = applyDirectToClaude(id); if (!r.ok) { claudeSkipped = r.skipped; claudeReason = r.error; } } save(); return { ...status(), _mode: claudeMode, _claudeSkipped: claudeSkipped, _claudeReason: claudeReason }; } catch (e) { config.activeClaudeId = previousActiveClaudeId; if (hadClaudePrev) config.claudePrev = previousClaudePrev; else delete config.claudePrev; save(); throw e; } } const before = currentModelProvider(); const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID; const previousActiveId = config.activeId; let needsRestart = false; let needsCodexRefresh = false; let mode = "proxy"; config.activeId = id; try { if (target.isDefault) { // 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex) restoreDirect(); mode = "direct"; needsRestart = wasTakenOver; // 之前在走路由或直连中转 → 需重启 } else if (isRunning()) { // 路由进程在跑(高级模式):先更新 activeId,再同步本地路由和目标模型。 mode = "proxy"; if (before !== PROVIDER_ID) { applyToCodex(); needsRestart = true; // 从直连/默认切到路由 → 需重启一次 } else if (target.model) { // 路由进程已经接管 Codex:上游请求会即时切到新模型,但 Codex 客户端的 // 顶部模型选择器只在启动时读取 config.toml 的根级 model,不会热刷新。 const beforeModel = currentRootModel(); applyModelCatalog(target); setConfigModel(target.model); needsCodexRefresh = beforeModel !== target.model; } } else { // 默认模式:直连中转,改 config.toml 指向该分组地址(必然需重启 Codex) mode = "direct"; const r = applyDirectToCodex(id); // applyDirectToCodex 内部已保存;chat/缺 key 则不写配置,但保留用户选中的分组。 needsRestart = !!r.ok && !r.restored; save(); return { ...status(), _needsRestart: needsRestart, _mode: mode, _directSkipped: r.skipped, _directReason: r.error }; } save(); return { ...status(), _needsRestart: needsRestart, _needsCodexRefresh: needsCodexRefresh, _mode: mode }; } catch (e) { config.activeId = previousActiveId; save(); throw e; } } function setPort(port) { load(); config.port = parseInt(port, 10) || 8787; save(); return status(); } // —— 自动配置 Codex —— 写 config.toml 的 local-router provider + auth.json 的 routerKey function codexSnippet() { load(); return `model_provider = "${PROVIDER_ID}"\n\n[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true`; } function applyToCodex() { load(); // 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组(排除 claude 模式) const cur = config.groups.find((g) => g.id === config.activeId); if (!cur || cur.isDefault || cur.mode === "claude" || isToolOnlyGroup(cur)) { const proxy = config.groups.find((g) => !g.isDefault && g.mode !== "claude" && !isToolOnlyGroup(g)); if (proxy) config.activeId = proxy.id; } const cfgFile = path.join(CODEX_HOME, "config.toml"); let text = readTextForUpdate(cfgFile); // 备份 try { fs.mkdirSync(path.join(CODEX_HOME, "backups_state", "router"), { recursive: true }); fs.writeFileSync(path.join(CODEX_HOME, "backups_state", "router", "config.toml." + Date.now() + ".bak"), text); } catch {} // 替换/插入 model_provider 根键 if (/^\s*model_provider\s*=/m.test(text)) { text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${PROVIDER_ID}"`); } else { text = `model_provider = "${PROVIDER_ID}"\n` + text; } // 清掉「默认分组」原始 provider 的旧段(已存进 router.json,避免残留误导) const def = config.groups.find((g) => g.isDefault); if (def && def.modelProvider && def.modelProvider !== PROVIDER_ID) { text = removeTomlSection(text, `model_providers.${def.modelProvider}`); } // 替换/插入 [model_providers.local-router] 段:先删旧的(含被 bug 弄重复的),再追加干净的 const block = `[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true\n`; text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); text = text.trimEnd() + "\n\n" + block; // auth.json 写 routerKey(保留其它字段),Codex 会把它当 Bearer 发到本地路由 const authFile = path.join(CODEX_HOME, "auth.json"); const auth = readJsonForUpdate(authFile); auth.OPENAI_API_KEY = config.routerKey; // 当前分组若指定了模型 → 写模型目录窗口 + 设 config 模型 const cur2 = config.groups.find((g) => g.id === config.activeId); if (cur2 && !cur2.isDefault && cur2.model) { applyModelCatalog(cur2); ensureModelInCatalog(cur2.model); text = setRootModelInText(text, cur2.model); } text = ensureCatalogLinked(text); writeCodexConfigPair(text, JSON.stringify(auth, null, 2)); // 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录), // 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。 if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {}); save(); return { ok: true }; } // —— 直连中转写配置(不经本地路由,不起进程)—— // 把指定分组的中转 base_url/key 直接写进 config.toml + auth.json,Codex 直连中转站。 // 仅在用户明确选择/接入分组时调用;重启 Codex 生效。chat 模式分组需协议转换、无法直连 → 拒绝。 function applyDirectToCodex(groupId) { load(); const g = config.groups.find((x) => x.id === (groupId || config.activeId)); if (!g) return { ok: false, error: "分组不存在" }; if (isToolOnlyGroup(g)) { return { ok: false, skipped: true, error: "该分组是插件/画布专用分组,不写入 Codex 默认配置" }; } if (g.isDefault) { restoreDirect(); // 默认分组语义 = 回到官方登录直连 config.activeId = g.id; save(); return { ok: true, restored: true }; } if (!g.baseUrl || !g.key) { return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Codex 配置" }; } if (g.mode === "chat") { return { ok: false, skipped: true, needRouter: true, error: "该分组为 Chat 协议转换模式,直连 Codex 不支持,需启用本地路由" }; } if (g.mode === "claude") { return { ok: false, skipped: true, error: "该分组为 Claude Code 类型,不写入 Codex 配置" }; } // 接管前先确保官方登录已完整备份(幂等) const def = config.groups.find((x) => x.isDefault); if (def) snapshotOfficialLogin(def); const cfgFile = path.join(CODEX_HOME, "config.toml"); let text = readTextForUpdate(cfgFile); backupConfig(text); // 清理其他接管痕迹:本地路由段、上次直连段、默认分组原始 provider 段 text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); if (def && def.modelProvider && def.modelProvider !== DIRECT_PROVIDER_ID) { text = removeTomlSection(text, `model_providers.${def.modelProvider}`); } // 替换/插入根键 → 指向直连 provider if (/^\s*model_provider\s*=/m.test(text)) { text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${DIRECT_PROVIDER_ID}"`); } else { text = `model_provider = "${DIRECT_PROVIDER_ID}"\n` + text; } // 追加 [model_providers.td-direct] 段:base_url = 分组真实中转地址(非 127.0.0.1) const base = g.baseUrl.replace(/\/+$/, ""); const block = `[model_providers.${DIRECT_PROVIDER_ID}]\nname = "${(g.name || "中转直连").replace(/"/g, "")}"\nbase_url = "${base}"\nwire_api = "responses"\nrequires_openai_auth = true\n`; text = text.trimEnd() + "\n\n" + block; text = text.replace(/\n{3,}/g, "\n\n"); // auth.json 写分组真实 key(Codex 直连中转站时用它作 Bearer) const authFile = path.join(CODEX_HOME, "auth.json"); const auth = readJsonForUpdate(authFile); auth.OPENAI_API_KEY = g.key; // 分组指定模型 → 写模型目录窗口 + 设根级 model if (g.model) { applyModelCatalog(g); ensureModelInCatalog(g.model); text = setRootModelInText(text, g.model); } text = ensureCatalogLinked(text); writeCodexConfigPair(text, JSON.stringify(auth, null, 2)); // 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。 // 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App), // 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。 syncCatalogFromProvider(g).catch(() => {}); config.activeId = g.id; save(); return { ok: true, direct: true, group: g.name }; } function claudeSettingsPath() { return path.join(CLAUDE_HOME, "settings.json"); } function applyToClaudeCode() { load(); // Anthropic 请求由路由按 activeClaudeId 派发(见 handle);这里只把 settings.json 指向本地路由。 // 不再动 activeId(OpenAI/Codex 槽)—— 两族独立,接入 Claude Code 不该顶掉 Codex 的选择。 fs.mkdirSync(CLAUDE_HOME, { recursive: true }); const p = claudeSettingsPath(); const existed = fs.existsSync(p); const settings = readJsonForUpdate(p); // 备份原文件 if (existed) { try { const dir = path.join(CLAUDE_HOME, "backups_router"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "settings.json." + Date.now() + ".bak"), fs.readFileSync(p)); } catch {} } settings.env = settings.env || {}; // 首次接入时记住用户原有值,便于「取消接入」还原。 // 老版本只记录了 base/token;这里补记 model,避免覆盖后无法还原。 if (!config.claudePrev) { config.claudePrev = { base: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_BASE_URL") ? settings.env.ANTHROPIC_BASE_URL : null, token: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_AUTH_TOKEN") ? settings.env.ANTHROPIC_AUTH_TOKEN : null, model: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_MODEL") ? settings.env.ANTHROPIC_MODEL : null, }; } else if (!Object.prototype.hasOwnProperty.call(config.claudePrev, "model")) { config.claudePrev.model = Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_MODEL") ? settings.env.ANTHROPIC_MODEL : null; } settings.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${config.port}`; settings.env.ANTHROPIC_AUTH_TOKEN = config.routerKey; const g = activeGroup(true); if (g && g.model && g.model.trim()) settings.env.ANTHROPIC_MODEL = g.model.trim(); else delete settings.env.ANTHROPIC_MODEL; safeWriteFileSync(p, JSON.stringify(settings, null, 2), "utf-8"); try { fs.chmodSync(p, 0o600); } catch {} save(); return { ok: true, path: p }; } // 取消接入:把 settings.json 里的两项还原成用户原值(或删除) function restoreClaudeCode() { load(); const p = claudeSettingsPath(); if (!fs.existsSync(p)) return { ok: true }; // 没有文件就无需还原 const settings = readJsonForUpdate(p); settings.env = settings.env || {}; const prev = config.claudePrev || { base: null, token: null, model: null }; for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token], ["ANTHROPIC_MODEL", prev.model]]) { if (v == null) delete settings.env[k]; else settings.env[k] = v; } if (settings.env && Object.keys(settings.env).length === 0) delete settings.env; safeWriteFileSync(p, JSON.stringify(settings, null, 2), "utf-8"); delete config.claudePrev; save(); return { ok: true }; } // Claude Code 是否已指向本地路由 function claudeApplied() { try { const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8")); return !!(s.env && s.env.ANTHROPIC_BASE_URL === `http://127.0.0.1:${(config || {}).port || 8787}`); } catch { return false; } } // —— Claude Code 直连中转(不经本地路由)—— // 把指定分组的中转地址/key 直接写进 ~/.claude/settings.json 的 env,Claude Code 直连中转站。 // 与 applyToClaudeCode(写本地路由地址)对称,但 base_url = 分组主地址(非 127.0.0.1)。 function applyDirectToClaude(groupId) { load(); const g = config.groups.find((x) => x.id === (groupId || config.activeId)); if (!g) return { ok: false, error: "分组不存在" }; if (g.isDefault) return { ok: false, error: "默认分组不接入 Claude Code" }; if (isToolOnlyGroup(g)) return { ok: false, skipped: true, error: "该分组是插件/画布专用分组,不写入 Claude Code 配置" }; // Claude Code 直连用分组主字段:API 地址 baseUrl、API 密钥 key、默认模型 model if (!g.baseUrl || !g.key) { return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Claude Code 配置" }; } const base = g.baseUrl.replace(/\/+$/, ""); fs.mkdirSync(CLAUDE_HOME, { recursive: true }); const p = claudeSettingsPath(); const existed = fs.existsSync(p); const settings = readJsonForUpdate(p); // 备份原文件 if (existed) { try { const dir = path.join(CLAUDE_HOME, "backups_router"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "settings.json." + Date.now() + ".bak"), fs.readFileSync(p)); } catch {} } settings.env = settings.env || {}; // 首次接入时记住用户原有值,便于「取消接入」还原(与 applyToClaudeCode 共用 claudePrev) if (!config.claudePrev) { config.claudePrev = { base: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_BASE_URL") ? settings.env.ANTHROPIC_BASE_URL : null, token: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_AUTH_TOKEN") ? settings.env.ANTHROPIC_AUTH_TOKEN : null, model: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_MODEL") ? settings.env.ANTHROPIC_MODEL : null, }; } settings.env.ANTHROPIC_BASE_URL = base; settings.env.ANTHROPIC_AUTH_TOKEN = g.key; if (g.model && g.model.trim()) settings.env.ANTHROPIC_MODEL = g.model.trim(); else delete settings.env.ANTHROPIC_MODEL; safeWriteFileSync(p, JSON.stringify(settings, null, 2), "utf-8"); try { fs.chmodSync(p, 0o600); } catch {} save(); return { ok: true, direct: true, path: p, group: g.name }; } // Claude Code 是否已指向某个中转直连地址(非本地路由) function claudeDirectApplied() { try { const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8")); const url = s.env && s.env.ANTHROPIC_BASE_URL; return !!(url && !/127\.0\.0\.1/.test(url)); } catch { return false; } } module.exports = { load, setCodexDataDir, start, stop, stopAndRestoreDirect, disableCodex, isRunning, status, defaultConfigInfo, upsertGroup, deleteGroup, setActive, setPort, applyToCodex, applyDirectToCodex, codexSnippet, applyToClaudeCode, applyDirectToClaude, restoreClaudeCode, claudeApplied, claudeDirectApplied, getGroupKey, syncGenImage, groupById, registerModelRoute, usableGroups, llmGroup, sttGroup, fetchModels, syncCatalogFromProvider, };