Files
codex-market-app/electron/lib/router.js
T

2039 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};