2312 lines
95 KiB
JavaScript
2312 lines
95 KiB
JavaScript
// 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");
|
||
assertManagedConfigComplete(configText);
|
||
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\\[|$)`);
|
||
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 providerBlockIsComplete(text, id) {
|
||
const block = extractProviderBlock(text, id);
|
||
return !!(block && /^\s*base_url\s*=/m.test(block) && /^\s*wire_api\s*=\s*["']responses["']/m.test(block));
|
||
}
|
||
function managedCompatibilityProviderIds() {
|
||
const def = config && Array.isArray(config.groups) ? config.groups.find((g) => g && g.isDefault) : null;
|
||
const id = String((def && def.modelProvider) || "").trim();
|
||
// openai 是 Codex 内置 provider;托管的两个 ID 已由固定兼容段覆盖。
|
||
// 这里只接管安全的 TOML bare key,避免历史异常值生成非法 section。
|
||
if (!id || id === "openai" || isManagedProviderId(id) || !/^[A-Za-z0-9_-]+$/.test(id)) return [];
|
||
return [id];
|
||
}
|
||
function managedProviderBlocksAreComplete(text) {
|
||
return [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()]
|
||
.every((id) => providerBlockIsComplete(text, id));
|
||
}
|
||
function removeManagedProviderSections(text, includeCompatibility = true) {
|
||
let out = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
|
||
out = removeTomlSection(out, `model_providers.${DIRECT_PROVIDER_ID}`);
|
||
if (includeCompatibility) {
|
||
for (const id of managedCompatibilityProviderIds()) {
|
||
out = removeTomlSection(out, `model_providers.${id}`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function providerBlockValue(text, id, key) {
|
||
const block = extractProviderBlock(text, id);
|
||
if (!block) return "";
|
||
const esc = String(key).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const m = block.match(new RegExp(`^\\s*${esc}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "m"));
|
||
return m ? (m[1] !== undefined ? m[1] : m[2]) : "";
|
||
}
|
||
function assertManagedConfigComplete(text) {
|
||
const provider = modelProviderFromText(text);
|
||
if (!isManagedProviderId(provider)) return;
|
||
if (managedProviderBlocksAreComplete(text)) return;
|
||
const required = [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()].join("、");
|
||
throw new Error(`拒绝写入不完整的 Codex config.toml:托管模式缺少 provider 兼容段(${required})`);
|
||
}
|
||
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}]`);
|
||
}
|
||
|
||
function codexConfigRepairPlan() {
|
||
load();
|
||
const text = readConfigToml();
|
||
const provider = modelProviderFromText(text);
|
||
if (provider === PROVIDER_ID && !managedProviderBlocksAreComplete(text)) {
|
||
return { needed: true, mode: "proxy", reason: "托管 provider 兼容段缺失或不完整" };
|
||
}
|
||
if (provider === DIRECT_PROVIDER_ID && !managedProviderBlocksAreComplete(text)) {
|
||
const direct = config.groups.find((g) => g.id === config.activeId && !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key)
|
||
|| config.groups.find((g) => !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key === readAuthKey());
|
||
return {
|
||
needed: true,
|
||
mode: "direct",
|
||
groupId: direct && direct.id,
|
||
reason: "托管 provider 兼容段缺失或不完整",
|
||
};
|
||
}
|
||
if (String(text || "").trim()) return { needed: false };
|
||
|
||
const authText = readTextIfExists(path.join(CODEX_HOME, "auth.json"));
|
||
let auth = {};
|
||
try { auth = JSON.parse(authText || "{}"); } catch {}
|
||
const key = auth.OPENAI_API_KEY || "";
|
||
if (!key) return { needed: false };
|
||
if (key === config.routerKey) {
|
||
return { needed: true, mode: "proxy", reason: "config.toml 为空但 auth.json 仍是本地路由 Key" };
|
||
}
|
||
const direct = config.groups.find((g) => !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key === key)
|
||
|| config.groups.find((g) => g.id === config.activeId && !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key);
|
||
if (direct && authLooksManaged(authText)) {
|
||
return { needed: true, mode: "direct", groupId: direct.id, reason: "config.toml 为空但 auth.json 仍是中转 Key" };
|
||
}
|
||
return { needed: false };
|
||
}
|
||
|
||
function repairCodexConfigIfNeeded() {
|
||
const plan = codexConfigRepairPlan();
|
||
if (!plan.needed) return { repaired: false };
|
||
const currentText = readConfigToml();
|
||
const currentProvider = modelProviderFromText(currentText);
|
||
if (isManagedProviderId(currentProvider) && String(currentText || "").trim()) {
|
||
const fallbackGroup = config.groups.find((g) => g.id === plan.groupId)
|
||
|| config.groups.find((g) => g.id === config.activeId);
|
||
const sourceId = providerBlockIsComplete(currentText, currentProvider)
|
||
? currentProvider
|
||
: (providerBlockIsComplete(currentText, PROVIDER_ID) ? PROVIDER_ID : DIRECT_PROVIDER_ID);
|
||
const fallbackBase = currentProvider === PROVIDER_ID
|
||
? `http://127.0.0.1:${config.port}`
|
||
: (fallbackGroup && fallbackGroup.baseUrl ? responsesProviderBase(fallbackGroup.baseUrl) : "");
|
||
const baseUrl = providerBlockValue(currentText, sourceId, "base_url") || fallbackBase;
|
||
if (!baseUrl) return { repaired: false, error: "没有可用于修复兼容 provider 的 API 地址" };
|
||
const name = providerBlockValue(currentText, sourceId, "name")
|
||
|| (fallbackGroup && fallbackGroup.name)
|
||
|| (currentProvider === PROVIDER_ID ? "本地路由" : "中转直连");
|
||
let repairedText = removeManagedProviderSections(currentText);
|
||
repairedText = repairedText.trimEnd() + "\n\n" + managedProviderBlocks(name, baseUrl);
|
||
backupConfig(currentText);
|
||
writeCodexConfigPair(repairedText, readTextForUpdate(path.join(CODEX_HOME, "auth.json")));
|
||
return {
|
||
repaired: true,
|
||
mode: currentProvider === PROVIDER_ID ? "proxy" : "direct",
|
||
reason: plan.reason,
|
||
groupId: plan.groupId,
|
||
preservedModel: currentRootModel(),
|
||
};
|
||
}
|
||
if (plan.mode === "proxy") {
|
||
applyToCodex();
|
||
return { repaired: true, mode: "proxy", reason: plan.reason };
|
||
}
|
||
if (plan.mode === "direct" && plan.groupId) {
|
||
const result = applyDirectToCodex(plan.groupId);
|
||
if (result && result.ok) return { repaired: true, mode: "direct", reason: plan.reason, groupId: plan.groupId };
|
||
return { repaired: false, error: result && result.error ? result.error : "直连分组不可用" };
|
||
}
|
||
return { repaired: false, error: "没有可用于修复的中转分组" };
|
||
}
|
||
|
||
// 当前 Codex 是否仍保留“本地路由”接管痕迹。这个状态只用于页面显示和取消按钮,
|
||
// 不把直连中转(td-direct)误显示成已启用本地路由。
|
||
function codexRouteApplied() {
|
||
try { repairCodexConfigIfNeeded(); } catch {}
|
||
const text = readConfigToml();
|
||
const provider = modelProviderFromText(text);
|
||
if (provider === PROVIDER_ID) return true;
|
||
if (provider === DIRECT_PROVIDER_ID) return false;
|
||
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);
|
||
}
|
||
|
||
function catalogSyncStatePath(catalogFile) {
|
||
return `${catalogFile}.td-router-state.json`;
|
||
}
|
||
|
||
function readCatalogManagedSlugs(catalogFile) {
|
||
try {
|
||
const state = JSON.parse(fs.readFileSync(catalogSyncStatePath(catalogFile), "utf-8"));
|
||
return new Set(Array.isArray(state.managedSlugs) ? state.managedSlugs.filter((slug) => typeof slug === "string") : []);
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
function writeCatalogManagedSlugs(catalogFile, slugs) {
|
||
safeWriteFileSync(
|
||
catalogSyncStatePath(catalogFile),
|
||
JSON.stringify({ managedSlugs: [...slugs].sort() }, null, 2),
|
||
"utf-8"
|
||
);
|
||
}
|
||
|
||
function isToolOwnedCatalog(catalogFile) {
|
||
return path.resolve(catalogFile) === path.resolve(path.join(CODEX_HOME, "cc-switch-model-catalog.json"));
|
||
}
|
||
|
||
// 国产模型的实际可用窗口通常小于 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");
|
||
}
|
||
// Codex 模型目录只公开当前分组可用的同族文本模型。中转站的 /models 往往返回
|
||
// GPT、Claude、国产模型和多媒体模型的混合库存,不能把整站库存灌进选择器。
|
||
const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|speech|tts|stt|embed|rerank|review|moderation|vision-ocr|video|seedance|veo|sora|banana|kling|hailuo)/i;
|
||
function configuredCatalogModels(group) {
|
||
const values = [
|
||
group && group.model,
|
||
...(Array.isArray(group && group.models) ? group.models : []),
|
||
...(Array.isArray(group && group.routeModels) ? group.routeModels : []),
|
||
];
|
||
return Array.from(new Set(values.filter((slug) => typeof slug === "string" && slug.trim()).map((slug) => slug.trim())));
|
||
}
|
||
function allowedCatalogModels(group, providerIds) {
|
||
const available = Array.from(new Set(
|
||
(providerIds || []).filter((slug) => typeof slug === "string" && slug.trim() && !NON_TEXT_MODEL_RE.test(slug))
|
||
));
|
||
const configured = configuredCatalogModels(group).filter((slug) => !NON_TEXT_MODEL_RE.test(slug));
|
||
const rootModel = currentRootModel();
|
||
const anchor = configured[0] || rootModel || "";
|
||
if (/^gpt-/i.test(anchor)) {
|
||
const allowed = available.filter((slug) => /^gpt-/i.test(slug));
|
||
for (const slug of [...configured, rootModel]) {
|
||
if (/^gpt-/i.test(slug) && !NON_TEXT_MODEL_RE.test(slug) && !allowed.includes(slug)) allowed.push(slug);
|
||
}
|
||
return allowed;
|
||
}
|
||
return configured;
|
||
}
|
||
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 allowedIds = allowedCatalogModels(group, ids);
|
||
if (!allowedIds.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;
|
||
let hidden = 0;
|
||
const allowed = new Set(allowedIds);
|
||
const managedSlugs = readCatalogManagedSlugs(p);
|
||
const hideAllStale = isToolOwnedCatalog(p);
|
||
for (const model of cat.models) {
|
||
if (!model || allowed.has(model.slug) || model.visibility !== "list") continue;
|
||
if (hideAllStale || managedSlugs.has(model.slug)) {
|
||
model.visibility = "hide";
|
||
hidden++;
|
||
}
|
||
}
|
||
for (const slug of allowedIds) {
|
||
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++;
|
||
}
|
||
for (const slug of allowedIds) managedSlugs.add(slug);
|
||
if (added || revealed || refreshed || hidden) {
|
||
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
|
||
safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
|
||
}
|
||
writeCatalogManagedSlugs(p, managedSlugs);
|
||
return { ok: true, added, revealed, refreshed, hidden, total: allowedIds.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}`;
|
||
}
|
||
|
||
function setRootTomlValueInText(text, key, value) {
|
||
const rendered = typeof value === "boolean" ? String(value) : JSON.stringify(String(value));
|
||
const lines = text.split("\n");
|
||
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
|
||
const rootEnd = firstSection === -1 ? lines.length : firstSection;
|
||
const existingIndex = lines.slice(0, rootEnd).findIndex(
|
||
(line) => line.trim().startsWith(`${key}=`) || line.trim().startsWith(`${key} =`)
|
||
);
|
||
if (existingIndex >= 0) {
|
||
lines[existingIndex] = `${key} = ${rendered}`;
|
||
} else {
|
||
lines.splice(rootEnd, 0, `${key} = ${rendered}`);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
// 恢复到默认(把 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 [];
|
||
}
|
||
|
||
// Codex Responses provider 的 base_url 语义是“后续自动追加 /responses”。
|
||
// 模型列表探测允许用户填站点根(自动试 /v1/models),因此直连与路由转发也必须
|
||
// 把站点根规范到 /v1;已填 /v1、/v2 或完整 /responses 地址时不重复改写。
|
||
function responsesProviderBase(baseUrl) {
|
||
const base = String(baseUrl || "").replace(/\/+$/, "");
|
||
try {
|
||
const u = new URL(base);
|
||
const pathname = u.pathname.replace(/\/+$/, "");
|
||
if (/\/responses$/i.test(pathname)) {
|
||
u.pathname = pathname.replace(/\/responses$/i, "");
|
||
return u.href.replace(/\/+$/, "");
|
||
}
|
||
if (/\/v\d+$/i.test(pathname)) return base;
|
||
u.pathname = pathname === "/" ? "/v1" : `${pathname}/v1`;
|
||
return u.href.replace(/\/+$/, "");
|
||
} catch {
|
||
return base;
|
||
}
|
||
}
|
||
|
||
// 拉取某个中转 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 {
|
||
const openAIUpstream = g.mode !== "chat" && !isClaudeGroup(g);
|
||
// Codex 的 provider base_url 可能带 /v1;本地路由自身统一挂在根路径。
|
||
// 先去掉入口里的版本段,再拼上游 base,避免 /v1/v1/responses。
|
||
const upstreamPath = openAIUpstream && /^\/v\d+\/responses\b/i.test(req.url || "")
|
||
? req.url.replace(/^\/v\d+/i, "")
|
||
: req.url;
|
||
const upstreamBase = !openAIUpstream
|
||
? g.baseUrl.replace(/\/$/, "")
|
||
: responsesProviderBase(g.baseUrl);
|
||
target = new URL(upstreamBase + upstreamPath);
|
||
} 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();
|
||
try { repairCodexConfigIfNeeded(); } catch {}
|
||
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) {
|
||
// 托管期间为旧会话补的 provider 别名也要一起清理;用户已手改 provider 时则完全保留。
|
||
for (const id of managedCompatibilityProviderIds()) {
|
||
text = removeTomlSection(text, `model_providers.${id}`);
|
||
}
|
||
// 根级还指向本工具,才删根级 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();
|
||
let repair = { repaired: false };
|
||
try { repair = repairCodexConfigIfNeeded(); } catch (e) { repair = { repaired: false, error: String((e && e.message) || e) }; }
|
||
return {
|
||
running: isRunning(),
|
||
port: config.port,
|
||
routerKey: config.routerKey,
|
||
address: `http://127.0.0.1:${config.port}`,
|
||
activeId: config.activeId,
|
||
activeClaudeId: config.activeClaudeId,
|
||
repair,
|
||
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 : [],
|
||
source: g.source || "",
|
||
sourceGroupId: g.sourceGroupId || "",
|
||
})),
|
||
};
|
||
}
|
||
|
||
function defaultConfigInfo() {
|
||
load();
|
||
try { repairCodexConfigIfNeeded(); } catch {}
|
||
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 importSub2apiProvider(input) {
|
||
load();
|
||
const existing = config.groups.find(
|
||
(g) => !g.isDefault && g.source === "sub2api" && String(g.sourceGroupId) === String(input.sourceGroupId)
|
||
);
|
||
const result = upsertGroup({
|
||
...(existing ? { id: existing.id } : {}),
|
||
name: input.name,
|
||
baseUrl: input.endpoint,
|
||
key: input.apiKey,
|
||
model: input.model,
|
||
mode: "responses",
|
||
source: "sub2api",
|
||
sourceGroupId: String(input.sourceGroupId),
|
||
});
|
||
return { ...result, _imported: true, _updated: !!existing };
|
||
}
|
||
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();
|
||
const base = `http://127.0.0.1:${config.port}`;
|
||
return `model_provider = "${PROVIDER_ID}"\n\n${managedProviderBlocks("本地路由", base).trimEnd()}`;
|
||
}
|
||
|
||
function managedProviderBlocks(name, baseUrl) {
|
||
const providerName = JSON.stringify(String(name || "TD 工具箱"));
|
||
const compatibilityName = JSON.stringify(`${String(name || "TD 工具箱")}(历史会话兼容)`);
|
||
const base = JSON.stringify(String(baseUrl || "").replace(/\/+$/, ""));
|
||
const blocks = [];
|
||
for (const id of [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()]) {
|
||
blocks.push(
|
||
`[model_providers.${id}]`,
|
||
`name = ${id === PROVIDER_ID || id === DIRECT_PROVIDER_ID ? providerName : compatibilityName}`,
|
||
`base_url = ${base}`,
|
||
'wire_api = "responses"',
|
||
"requires_openai_auth = true",
|
||
""
|
||
);
|
||
}
|
||
return blocks.join("\n");
|
||
}
|
||
|
||
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}`);
|
||
}
|
||
// 托管 provider 与历史默认 provider 都保留完整兼容段。历史线程可能记录任意一个 ID,
|
||
// 恢复时会按线程自己的 provider 查配置,缺一个就会一直停在 loading。
|
||
const block = managedProviderBlocks("本地路由", `http://127.0.0.1:${config.port}`);
|
||
text = removeManagedProviderSections(text);
|
||
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 = removeManagedProviderSections(text);
|
||
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;
|
||
}
|
||
// 追加托管与历史 provider 兼容段:base_url 都是分组真实中转地址(非 127.0.0.1)
|
||
const base = responsesProviderBase(g.baseUrl);
|
||
const block = managedProviderBlocks(g.name || "中转直连", base);
|
||
text = setRootTomlValueInText(text, "model_reasoning_effort", "high");
|
||
text = setRootTomlValueInText(text, "disable_response_storage", true);
|
||
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));
|
||
// 异步收敛当前分组模型目录;网络失败时保持现状。
|
||
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,
|
||
importSub2apiProvider,
|
||
deleteGroup,
|
||
setActive,
|
||
setPort,
|
||
applyToCodex,
|
||
applyDirectToCodex,
|
||
codexSnippet,
|
||
applyToClaudeCode,
|
||
applyDirectToClaude,
|
||
restoreClaudeCode,
|
||
claudeApplied,
|
||
claudeDirectApplied,
|
||
getGroupKey,
|
||
syncGenImage,
|
||
groupById,
|
||
registerModelRoute,
|
||
usableGroups,
|
||
llmGroup,
|
||
sttGroup,
|
||
fetchModels,
|
||
syncCatalogFromProvider,
|
||
};
|