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

1374 lines
56 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 直连本地,指令经此转发到"当前分组"的中转 API。
// 切分组只改内存里的 activeId,Codex 那头地址不变 → 秒切、无需重启 Codex。
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 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 cfgPath() {
return path.join(app.getPath("userData"), "router.json");
}
// —— 加解密(复用 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"; }
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);
const fallback = config.groups.find((g) => !isClaudeGroup(g));
config.activeId = (proxy || fallback || {}).id || null;
}
// 补齐 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();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(out, null, 2));
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;
}
// 判断一个进来的请求是否是 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] : "";
}
// 读根级 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 "";
}
}
// 是否已用自己账号登录 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 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;
if (def.officialBackup && def.officialBackup.done) return; // 幂等守卫
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 {}
// 磁盘副本(灾备):仅当不存在时写,避免二次接管覆盖原始官方备份
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(),
};
}
// 首次使用时,把当前(原始)Codex 配置快照为不可删除的「默认」分组。
// 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态,
// 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。
function ensureDefault() {
if (!config) return;
const loggedIn = isLoggedIn();
const existing = config.groups.find((g) => g.isDefault);
if (existing) {
snapshotOfficialLogin(existing); // 幂等:确保已装用户也补上官方登录完整备份
// 自愈:已用自己账号登录,但历史版本把默认分组错误地快照成了中转 provider →
// 纠正为「纯登录态」,保证「切回默认」永远能干净复原自己账号。
if (loggedIn && (existing.modelProvider || existing.providerBlock || existing.authKey)) {
existing.modelProvider = "";
existing.providerBlock = "";
existing.authKey = "";
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 || prov === PROVIDER_ID) 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 按真实窗口自动压缩
// 铁律:只要分组指定了 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 = parseInt(group.contextWindow, 10); // 未填 → NaN(假值),此时保留/沿用既有窗口
const existing = cat.models.find((m) => m.slug === group.model);
if (existing) {
if (win) {
existing.context_window = win;
existing.max_context_window = 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;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
}
try {
fs.writeFileSync(p + ".bak-router", fs.readFileSync(p));
} catch {}
fs.writeFileSync(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;
let added = 0;
let revealed = 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++; }
continue;
}
const t = JSON.parse(JSON.stringify(tpl0)); // 克隆模板(含 base_instructions 等必需字段)
t.slug = slug;
t.display_name = slug;
t.description = slug;
t.context_window = w0;
t.max_context_window = w0;
t.priority = 900;
t.visibility = "list";
cat.models.push(t);
added++;
}
if (added || revealed) {
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
return { ok: true, added, revealed, 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 {}
fs.writeFileSync(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 {}
fs.writeFileSync(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 = readConfigToml();
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
else text = text.replace(/^(\s*model_provider\s*=.*)$/m, `$1\nmodel = "${model}"`);
fs.writeFileSync(cfgFile, text, "utf-8");
}
// 恢复到默认(把 config.toml/auth.json 还原成客户原始配置,Codex 直连自己账号)
function restoreDirect() {
const def = config.groups.find((g) => g.isDefault);
if (!def) return;
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
backupConfig(text);
// 先移除本地路由段 + 直连中转段(无论哪种复原都要清掉,避免残留污染)
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");
fs.writeFileSync(cfgFile, text, "utf-8");
const af = path.join(CODEX_HOME, "auth.json");
// 优先用官方登录完整备份整体还原(含 OAuth tokens)→ 100% 复原官方登录态
if (def.officialBackup && def.officialBackup.done && def.officialBackup.hadTokens && def.officialBackup.authJson) {
try {
fs.writeFileSync(af, def.officialBackup.authJson, "utf-8");
fs.chmodSync(af, 0o600);
return;
} catch {}
}
try {
const a = JSON.parse(fs.readFileSync(af, "utf-8"));
// 登录态:清空 OPENAI_API_KEY、保留 tokens → Codex 用 OAuth 直连;纯 key 用户则还原原始 key
a.OPENAI_API_KEY = def.authKey || null;
fs.writeFileSync(af, JSON.stringify(a, null, 2), "utf-8");
fs.chmodSync(af, 0o600);
} catch {}
}
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;
}
// 可供画布等内部功能"按需选用"的中转分组列表:排除默认直连分组,需配齐 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 && 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;
}
// 拉取某个中转 API 的模型列表(试 /v1/models 再 /models)
async function fetchModels(baseUrl, key) {
if (!baseUrl) throw new Error("请先填 API 地址");
const base = baseUrl.replace(/\/$/, "");
let lastErr = null;
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;
}
}
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 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) {
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 (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 handle(req, res) {
// 按请求协议派发:Claude Code(Anthropic) → activeClaudeId;Codex/GPT(OpenAI) → activeId。
// 这样两族各转发到各自激活的分组,可同时使用、互不顶替。
const wantClaude = reqIsAnthropic(req);
const g = activeGroup(wantClaude);
if (!g) {
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
res.end(wantClaude ? "本地路由:未选择 Claude Code 分组" : "本地路由:未选择中转分组");
return;
}
// 校验路由 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", () => {
let body = Buffer.concat(chunks);
const doCapture = /responses/.test(req.url || "");
if (doCapture) body = rewriteResponsesBody(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 {}
const chatReq = translate.responsesToChat(responsesReq, g.model || responsesReq.model);
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 = { ...req.headers };
headers["host"] = target.host;
// 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;
}
}
delete headers["content-length"];
if (body.length) headers["content-length"] = Buffer.byteLength(body);
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 };
}
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(),
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,
stt: !!g.stt,
sttModel: g.sttModel || "",
oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [],
})),
};
}
// ——————————————————————————————————————————————————————————————
// 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();
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;
}
// 首个分组自动激活到所属协议族的槽:claude→activeClaudeId,其余→activeId
const curG = config.groups.find((x) => x.id === id);
if (isClaudeGroup(curG)) { if (!config.activeClaudeId) config.activeClaudeId = id; }
else { if (!config.activeId) config.activeId = 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;
}
save();
try { syncGenImage(); } catch {}
// 保存分组后按接口类型分流:
// - claude 模式:把分组直连配置写进 ~/.claude/settings.json(接入 Claude Code CLI),不碰 Codex
// - responses/chat 模式:把直连中转配置写进 Codex config.toml(不经路由、不起进程)
const st = status();
if (cur && !cur.isDefault) {
if (cur.mode === "claude") {
// 接入 Claude Code
if (!cur.baseUrl || !cur.key) {
st._claudeSkipped = true;
st._claudeReason = "该分组缺少 API 地址或密钥,未写入 Claude Code 配置";
} else {
try {
const rc = applyDirectToClaude(cur.id);
if (rc.ok) st._claudeApplied = true;
else { st._claudeSkipped = true; st._claudeReason = rc.error || "写入失败"; }
} catch (e) {
st._claudeError = String((e && e.message) || e);
}
}
} else {
// 接入 Codex(responses 直连 / chat 协议转换)
// 仅当【本分组】之前是 claude 模式、现改回 Codex 模式 → 还原它写过的 Claude Code 配置
if (prevMode === "claude" && config.claudePrev && claudeDirectApplied()) {
try { restoreClaudeCode(); st._claudeRestored = true; } catch {}
}
if (isRunning()) {
st._directSkipped = true;
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
} else if (!cur.baseUrl || !cur.key) {
st._directSkipped = true;
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
} else if (cur.mode === "chat") {
st._directSkipped = true;
st._directNeedRouter = true;
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
} else {
try {
const r = applyDirectToCodex(cur.id);
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
} catch (e) {
st._directError = String((e && e.message) || e);
}
}
}
}
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));
config.activeClaudeId = next ? next.id : null;
}
} else {
if (config.activeId === id) {
const next = config.groups.find((x) => !isClaudeGroup(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();
// Claude Code(Anthropic 族):落 activeClaudeId,写 ~/.claude/settings.json,绝不碰 Codex 配置。
// 与 OpenAI 族互不顶替 —— 两族可同时激活。
if (isClaudeGroup(target)) {
config.activeClaudeId = id;
let claudeMode = "proxy";
let claudeSkipped, claudeReason;
if (isRunning()) {
// 路由在跑:settings.json 指向本地路由即可,路由按 activeClaudeId 动态转发,无需改文件、无需重启
claudeMode = "proxy";
if (!claudeApplied()) applyToClaudeCode();
} 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 };
}
const before = currentModelProvider();
const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID;
let needsRestart = false;
let mode = "proxy";
if (target.isDefault) {
// 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex)
restoreDirect();
mode = "direct";
needsRestart = wasTakenOver; // 之前在走路由或直连中转 → 需重启
} else if (isRunning()) {
// 路由进程在跑(高级模式):确保 config.toml 指向本地路由,秒切无需重启
mode = "proxy";
if (before !== PROVIDER_ID) {
applyToCodex();
needsRestart = true; // 从直连/默认切到路由 → 需重启一次
}
if (target.model) {
applyModelCatalog(target);
setConfigModel(target.model);
}
} else {
// 默认模式:直连中转,改 config.toml 指向该分组地址(必然需重启 Codex)
mode = "direct";
const r = applyDirectToCodex(id);
// applyDirectToCodex 内部已 setActive + save;chat/缺 key 则不写配置
needsRestart = !!r.ok && !r.restored;
config.activeId = id;
save();
return { ...status(), _needsRestart: needsRestart, _mode: mode, _directSkipped: r.skipped, _directReason: r.error };
}
config.activeId = id;
save();
return { ...status(), _needsRestart: needsRestart, _mode: mode };
}
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") {
const proxy = config.groups.find((g) => !g.isDefault && g.mode !== "claude");
if (proxy) config.activeId = proxy.id;
}
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = "";
try {
text = fs.readFileSync(cfgFile, "utf-8");
} catch {}
// 备份
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;
fs.writeFileSync(cfgFile, text, "utf-8");
// auth.json 写 routerKey(保留其它字段),Codex 会把它当 Bearer 发到本地路由
const authFile = path.join(CODEX_HOME, "auth.json");
let auth = {};
try {
auth = JSON.parse(fs.readFileSync(authFile, "utf-8"));
} catch {}
auth.OPENAI_API_KEY = config.routerKey;
try {
fs.writeFileSync(authFile, JSON.stringify(auth, null, 2), "utf-8");
fs.chmodSync(authFile, 0o600);
} catch {}
// 当前分组若指定了模型 → 写模型目录窗口 + 设 config 模型
const cur2 = config.groups.find((g) => g.id === config.activeId);
if (cur2 && !cur2.isDefault && cur2.model) {
applyModelCatalog(cur2);
setConfigModel(cur2.model);
}
// 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录),
// 保证用户之后在 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 (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 = "";
try { text = fs.readFileSync(cfgFile, "utf-8"); } catch {}
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");
fs.writeFileSync(cfgFile, text, "utf-8");
// auth.json 写分组真实 key(Codex 直连中转站时用它作 Bearer)
const authFile = path.join(CODEX_HOME, "auth.json");
let auth = {};
try { auth = JSON.parse(fs.readFileSync(authFile, "utf-8")); } catch {}
auth.OPENAI_API_KEY = g.key;
try {
fs.writeFileSync(authFile, JSON.stringify(auth, null, 2), "utf-8");
fs.chmodSync(authFile, 0o600);
} catch {}
// 分组指定模型 → 写模型目录窗口 + 设根级 model
if (g.model) {
applyModelCatalog(g);
setConfigModel(g.model);
}
// 先回填:把该中转站能用的全部文本模型登记进 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();
let settings = {};
let existed = false;
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
existed = true;
} catch {}
// 备份原文件
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 || {};
// 首次接入时记住用户原有的两项,便于「取消接入」还原
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,
};
}
settings.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${config.port}`;
settings.env.ANTHROPIC_AUTH_TOKEN = config.routerKey;
fs.writeFileSync(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();
let settings = {};
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return { ok: true }; // 没有文件就无需还原
}
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;
fs.writeFileSync(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" };
// 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();
let settings = {};
let existed = false;
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
existed = true;
} catch {}
// 备份原文件
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;
fs.writeFileSync(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,
start,
stop,
isRunning,
status,
upsertGroup,
deleteGroup,
setActive,
setPort,
applyToCodex,
applyDirectToCodex,
codexSnippet,
applyToClaudeCode,
applyDirectToClaude,
restoreClaudeCode,
claudeApplied,
claudeDirectApplied,
getGroupKey,
syncGenImage,
groupById,
usableGroups,
llmGroup,
sttGroup,
fetchModels,
syncCatalogFromProvider,
};