feat: add desktop app hot update channel
This commit is contained in:
@@ -0,0 +1,884 @@
|
||||
// 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 名
|
||||
|
||||
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, groups:[{id,name,baseUrl,key,model}] }
|
||||
let config = null;
|
||||
|
||||
function defaultConfig() {
|
||||
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, groups: [] };
|
||||
}
|
||||
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) => ({ ...g, key: dec(g.key) }));
|
||||
config = { ...defaultConfig(), ...raw };
|
||||
} catch {
|
||||
config = defaultConfig();
|
||||
}
|
||||
ensureDefault();
|
||||
return config;
|
||||
}
|
||||
function save() {
|
||||
const out = {
|
||||
...config,
|
||||
routerKey: enc(config.routerKey),
|
||||
groups: config.groups.map((g) => ({ ...g, key: enc(g.key) })),
|
||||
};
|
||||
const p = cfgPath();
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(out, null, 2));
|
||||
try {
|
||||
fs.chmodSync(p, 0o600);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function activeGroup() {
|
||||
load();
|
||||
return config.groups.find((g) => g.id === config.activeId) || null;
|
||||
}
|
||||
|
||||
// —— 默认分组:客户原本的 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 配置快照为不可删除的「默认」分组。
|
||||
// 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态,
|
||||
// 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。
|
||||
function ensureDefault() {
|
||||
if (!config) return;
|
||||
const loggedIn = isLoggedIn();
|
||||
const existing = config.groups.find((g) => g.isDefault);
|
||||
if (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(新版桌面登录态通常无 → ""),复原时精确还原
|
||||
});
|
||||
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(),
|
||||
});
|
||||
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 按真实窗口自动压缩
|
||||
function applyModelCatalog(group) {
|
||||
if (!group || group.isDefault || !group.model || !group.contextWindow) 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 win = parseInt(group.contextWindow, 10);
|
||||
if (!win) return;
|
||||
const existing = cat.models.find((m) => m.slug === group.model);
|
||||
if (existing) {
|
||||
existing.context_window = win;
|
||||
existing.max_context_window = win;
|
||||
existing.visibility = "list";
|
||||
} else {
|
||||
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
|
||||
tpl.slug = group.model;
|
||||
tpl.display_name = group.model;
|
||||
tpl.description = group.model;
|
||||
tpl.context_window = win;
|
||||
tpl.max_context_window = win;
|
||||
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 设成指定值
|
||||
function setConfigModel(model) {
|
||||
if (!model) return;
|
||||
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}`);
|
||||
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");
|
||||
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;
|
||||
}
|
||||
|
||||
// 拉取某个中转 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 handle(req, res) {
|
||||
const g = activeGroup();
|
||||
if (!g) {
|
||||
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
||||
res.end("本地路由:未选择中转分组");
|
||||
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 || "");
|
||||
// 分组指定了模型 → 覆盖请求体里的 model
|
||||
if (g.model && body.length) {
|
||||
try {
|
||||
const j = JSON.parse(body.toString("utf-8"));
|
||||
if (j && typeof j === "object" && "model" in j) {
|
||||
j.model = g.model;
|
||||
body = Buffer.from(JSON.stringify(j), "utf-8");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// —— 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 等):换成分组密钥
|
||||
}
|
||||
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,
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ——————————————————————————————————————————————————————————————
|
||||
// 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;
|
||||
if (g.id) {
|
||||
const i = config.groups.findIndex((x) => x.id === g.id);
|
||||
if (i >= 0) 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;
|
||||
}
|
||||
if (!config.activeId) config.activeId = 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;
|
||||
}
|
||||
save();
|
||||
try { syncGenImage(); } catch {}
|
||||
return status();
|
||||
}
|
||||
function deleteGroup(id) {
|
||||
load();
|
||||
const g = config.groups.find((x) => x.id === id);
|
||||
if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" };
|
||||
config.groups = config.groups.filter((x) => x.id !== id);
|
||||
if (config.activeId === id) config.activeId = config.groups[0] ? config.groups[0].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();
|
||||
const before = currentModelProvider();
|
||||
let needsRestart = false;
|
||||
let mode = "proxy";
|
||||
if (target.isDefault) {
|
||||
// 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex)
|
||||
restoreDirect();
|
||||
mode = "direct";
|
||||
needsRestart = before === PROVIDER_ID; // 之前在走路由 → 需重启
|
||||
} else {
|
||||
// 切到中转分组:确保 config.toml 指向本地路由
|
||||
if (before !== PROVIDER_ID) {
|
||||
applyToCodex();
|
||||
needsRestart = true; // 从直连切到路由 → 需重启一次
|
||||
}
|
||||
// 若分组指定了模型:写模型目录窗口 + 设 config 模型,让 Codex 按真实窗口压缩
|
||||
if (target.model) {
|
||||
applyModelCatalog(target);
|
||||
setConfigModel(target.model);
|
||||
}
|
||||
}
|
||||
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();
|
||||
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
|
||||
const cur = config.groups.find((g) => g.id === config.activeId);
|
||||
if (!cur || cur.isDefault) {
|
||||
const proxy = config.groups.find((g) => !g.isDefault);
|
||||
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);
|
||||
}
|
||||
save();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// —— 自动接入 Claude Code —— 写 ~/.claude/settings.json 的 env:ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
|
||||
// Claude Code 会把 settings.json 里 env 的键当环境变量注入每个会话,从而走本地路由。
|
||||
function claudeSettingsPath() {
|
||||
return path.join(CLAUDE_HOME, "settings.json");
|
||||
}
|
||||
|
||||
function applyToClaudeCode() {
|
||||
load();
|
||||
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
|
||||
const cur = config.groups.find((g) => g.id === config.activeId);
|
||||
if (!cur || cur.isDefault) {
|
||||
const proxy = config.groups.find((g) => !g.isDefault);
|
||||
if (proxy) {
|
||||
config.activeId = proxy.id;
|
||||
save();
|
||||
}
|
||||
}
|
||||
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 };
|
||||
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token]]) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
load,
|
||||
start,
|
||||
stop,
|
||||
isRunning,
|
||||
status,
|
||||
upsertGroup,
|
||||
deleteGroup,
|
||||
setActive,
|
||||
setPort,
|
||||
applyToCodex,
|
||||
codexSnippet,
|
||||
applyToClaudeCode,
|
||||
restoreClaudeCode,
|
||||
claudeApplied,
|
||||
getGroupKey,
|
||||
syncGenImage,
|
||||
groupById,
|
||||
usableGroups,
|
||||
llmGroup,
|
||||
fetchModels,
|
||||
};
|
||||
Reference in New Issue
Block a user