fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)

This commit is contained in:
mac
2026-07-20 08:48:56 +08:00
parent 42e23663ee
commit aadd2b04ae
28 changed files with 5881 additions and 329 deletions
+321 -51
View File
@@ -36,12 +36,18 @@ function dec(s) {
return "";
}
// —— 配置持久化 —— config: { port, routerKey, activeId, groups:[{id,name,baseUrl,key,model}] }
// —— 配置持久化 —— 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, groups: [] };
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, activeClaudeId: null, groups: [] };
}
// 分组所属协议族:claude 模式 = Anthropic 族,其余(responses/chat/默认)= OpenAI 族
function isClaudeGroup(g) { return !!g && g.mode === "claude"; }
function load() {
if (config) return config;
try {
@@ -60,8 +66,28 @@ function load() {
config = defaultConfig();
}
ensureDefault();
migrateActiveSlots();
return config;
}
// 老配置只有单一 activeId:若它指向的是 claude 分组,迁移到 activeClaudeId,
// 并为空出来的 OpenAI 槽自动选一个非 claude 分组,保证两族各有默认激活。
function migrateActiveSlots() {
if (!("activeClaudeId" in config)) config.activeClaudeId = null;
const act = config.groups.find((g) => g.id === config.activeId);
if (act && isClaudeGroup(act)) {
if (!config.activeClaudeId) config.activeClaudeId = act.id;
// 优先选真正的中转分组(非默认、非 claude);没有再退到默认分组
const proxy = config.groups.find((g) => !isClaudeGroup(g) && !g.isDefault);
const fallback = config.groups.find((g) => !isClaudeGroup(g));
config.activeId = (proxy || fallback || {}).id || null;
}
// 补齐 claude 槽:若从未设过,且存在 claude 分组,默认选第一个
if (!config.activeClaudeId) {
const cg = config.groups.find((g) => isClaudeGroup(g));
if (cg) config.activeClaudeId = cg.id;
}
}
function save() {
const out = {
...config,
@@ -82,9 +108,17 @@ function save() {
} catch {}
}
function activeGroup() {
// 取激活分组。wantClaude=true 取 Anthropic 族(activeClaudeId),否则取 OpenAI 族(activeId)。
function activeGroup(wantClaude) {
load();
return config.groups.find((g) => g.id === config.activeId) || null;
const id = wantClaude ? config.activeClaudeId : config.activeId;
return config.groups.find((g) => g.id === id) || null;
}
// 判断一个进来的请求是否是 Anthropic 协议(Claude Code):走 /v1/messages,或带 anthropic 头。
function reqIsAnthropic(req) {
const h = req.headers || {};
return /\/v1\/messages/.test(req.url || "") || "anthropic-version" in h || "x-api-key" in h;
}
// —— 默认分组:客户原本的 Codex 配置(自己账号,直连)——
@@ -243,8 +277,12 @@ function catalogPath() {
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
}
// 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩
// 铁律:只要分组指定了 model,就必须保证它在目录里存在(visibility=list)。
// 否则 config.toml 的根级 model 指向一个「目录里没有的模型」,Codex 会以未知模型启动、
// 无法解析出该模型 → 侧栏历史线程还原失败 = 用户看到「切模型后会话记录消失」。
// contextWindow 只是「可选的窗口覆盖」,绝不能作为「是否登记进目录」的前置条件。
function applyModelCatalog(group) {
if (!group || group.isDefault || !group.model || !group.contextWindow) return;
if (!group || group.isDefault || !group.model) return; // 不再要求 contextWindow
const p = catalogPath();
let cat;
try {
@@ -253,20 +291,23 @@ function applyModelCatalog(group) {
return; // 无目录文件就不动
}
if (!cat.models || !cat.models.length) return;
const win = parseInt(group.contextWindow, 10);
if (!win) return;
const win = parseInt(group.contextWindow, 10); // 未填 → NaN(假值),此时保留/沿用既有窗口
const existing = cat.models.find((m) => m.slug === group.model);
if (existing) {
existing.context_window = win;
existing.max_context_window = win;
existing.visibility = "list";
if (win) {
existing.context_window = win;
existing.max_context_window = win;
}
existing.visibility = "list"; // 已在目录:无论有无窗口,都确保可见
} else {
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
// 未指定窗口时,用模板自带窗口兜底(再退到 128000),保证新模型有合法窗口
const w = win || parseInt(tpl.context_window, 10) || 128000;
tpl.slug = group.model;
tpl.display_name = group.model;
tpl.description = group.model;
tpl.context_window = win;
tpl.max_context_window = win;
tpl.context_window = w;
tpl.max_context_window = w;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
@@ -276,9 +317,100 @@ function applyModelCatalog(group) {
} catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 把 config.toml 根级 model 设成指定值
// 【根治「切模型丢会话」】把分组中转站 /models 返回的全部文本模型,先登记进 Codex 模型目录。
// 关键:用户是在 Codex 本体的模型选择器里切模型的,那条路径不经过本 App,App 的 applyModelCatalog
// 不会触发;若目标 slug 不在目录里,Codex 会以未知模型启动、侧栏历史还原失败 = 会话"消失"。
// 因此必须【先回填】:把该中转能用的模型全部登记为 visibility=list,用户在 Codex 里选哪个都不丢。
// 非文本模型(画图/审阅/语音等)不是对话模型,排除;任何网络/解析失败都不动既有目录(离线安全)。
const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|tts|stt|embed|rerank|review|moderation|vision-ocr)/i;
async function syncCatalogFromProvider(group) {
try {
if (!group || group.isDefault || !group.baseUrl || !group.key) return { ok: false, skipped: true };
if (group.mode === "claude") return { ok: false, skipped: true }; // Claude 族不写 Codex 目录
let ids = [];
try {
ids = await fetchModels(group.baseUrl, group.key); // 复用既有 /v1/models · /models 探测
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) }; // 网络/接口失败 → 不动既有目录
}
const textIds = ids.filter((s) => !NON_TEXT_MODEL_RE.test(s));
if (!textIds.length) return { ok: false, error: "无可登记的文本模型" };
const p = catalogPath();
let cat;
try {
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return { ok: false, error: "无模型目录文件" };
}
if (!cat.models || !cat.models.length) return { ok: false, error: "模型目录为空" };
const tpl0 = cat.models[0];
const w0 = parseInt(tpl0.context_window, 10) || 128000;
let added = 0;
let revealed = 0;
for (const slug of textIds) {
const ex = cat.models.find((m) => m.slug === slug);
if (ex) {
if (ex.visibility !== "list") { ex.visibility = "list"; revealed++; }
continue;
}
const t = JSON.parse(JSON.stringify(tpl0)); // 克隆模板(含 base_instructions 等必需字段)
t.slug = slug;
t.display_name = slug;
t.description = slug;
t.context_window = w0;
t.max_context_window = w0;
t.priority = 900;
t.visibility = "list";
cat.models.push(t);
added++;
}
if (added || revealed) {
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
return { ok: true, added, revealed, total: textIds.length };
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) };
}
}
// 兜底:确保某个裸 model slug 一定在目录里(visibility=list)。用于 setConfigModel 前的自愈,
// 覆盖「config.toml 指向的模型没有对应分组(手改/历史残骸)」这种也会导致侧栏历史还原失败的情况。
function ensureModelInCatalog(model) {
if (!model) return;
const p = catalogPath();
let cat;
try {
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return; // 无目录文件就不动
}
if (!cat.models || !cat.models.length) return;
const existing = cat.models.find((m) => m.slug === model);
if (existing) {
if (existing.visibility !== "list") {
existing.visibility = "list";
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
return;
}
const tpl = JSON.parse(JSON.stringify(cat.models[0]));
const w = parseInt(tpl.context_window, 10) || 128000;
tpl.slug = model;
tpl.display_name = model;
tpl.description = model;
tpl.context_window = w;
tpl.max_context_window = w;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 把 config.toml 根级 model 设成指定值(写入前先确保该模型已在目录里,避免 Codex 以未知模型启动)
function setConfigModel(model) {
if (!model) return;
ensureModelInCatalog(model);
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
@@ -468,10 +600,13 @@ function rewriteResponsesBody(body, group) {
}
function handle(req, res) {
const g = activeGroup();
// 按请求协议派发:Claude Code(Anthropic) → activeClaudeId;Codex/GPT(OpenAI) → activeId。
// 这样两族各转发到各自激活的分组,可同时使用、互不顶替。
const wantClaude = reqIsAnthropic(req);
const g = activeGroup(wantClaude);
if (!g) {
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
res.end("本地路由:未选择中转分组");
res.end(wantClaude ? "本地路由:未选择 Claude Code 分组" : "本地路由:未选择中转分组");
return;
}
// 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来)
@@ -612,6 +747,17 @@ function handle(req, res) {
} else {
headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥
}
// 1M 长上下文:若本次请求的模型被该分组标记为「1M」,加上 Anthropic 的 1M beta 头解锁百万窗口。
// 对 GPT/GLM 等 OpenAI 兼容网关该头无害(被忽略);对 Claude 生效。
if (Array.isArray(g.oneMillionModels) && g.oneMillionModels.length && body.length) {
let reqModel = "";
try { reqModel = (JSON.parse(body.toString("utf-8")) || {}).model || ""; } catch {}
if (reqModel && g.oneMillionModels.includes(reqModel)) {
const beta = "context-1m-2025-08-07";
const existing = headers["anthropic-beta"];
headers["anthropic-beta"] = existing ? (existing.includes(beta) ? existing : existing + "," + beta) : beta;
}
}
delete headers["content-length"];
if (body.length) headers["content-length"] = Buffer.byteLength(body);
if (doCapture) {
@@ -681,6 +827,7 @@ function status() {
routerKey: config.routerKey,
address: `http://127.0.0.1:${config.port}`,
activeId: config.activeId,
activeClaudeId: config.activeClaudeId,
claudeApplied: claudeApplied(),
groups: config.groups.map((g) => ({
id: g.id,
@@ -694,6 +841,7 @@ function status() {
codexImage: !!g.codexImage,
stt: !!g.stt,
sttModel: g.sttModel || "",
oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [],
})),
};
}
@@ -765,9 +913,10 @@ function syncGenImage() {
function upsertGroup(g) {
load();
let id;
let prevMode = null; // 编辑前的接口类型(用于判断是否从 claude 改成别的)
if (g.id) {
const i = config.groups.findIndex((x) => x.id === g.id);
if (i >= 0) config.groups[i] = { ...config.groups[i], ...g };
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 {
@@ -775,7 +924,10 @@ function upsertGroup(g) {
config.groups.push(g);
id = g.id;
}
if (!config.activeId) config.activeId = g.id;
// 首个分组自动激活到所属协议族的槽:claude→activeClaudeId,其余→activeId
const curG = config.groups.find((x) => x.id === id);
if (isClaudeGroup(curG)) { if (!config.activeClaudeId) config.activeClaudeId = id; }
else { if (!config.activeId) config.activeId = id; }
// 生图来源唯一:勾选的分组独占,清掉其它分组的标记
const cur = config.groups.find((x) => x.id === id);
if (cur && cur.codexImage) {
@@ -783,28 +935,49 @@ function upsertGroup(g) {
}
save();
try { syncGenImage(); } catch {}
// 保存中转分组后:把它的直连中转配置写进 Codex config.toml(不经路由、不起进程),
// 并设为激活分组。用户重启 Codex 即用当前分组。只有显式「启用本地路由」才走路由进程。
// 保存分组后按接口类型分流:
// - claude 模式:把分组直连配置写进 ~/.claude/settings.json(接入 Claude Code CLI),不碰 Codex
// - responses/chat 模式:把直连中转配置写进 Codex config.toml(不经路由、不起进程)
const st = status();
if (cur && !cur.isDefault) {
// 路由进程正在跑(用户处于「启用路由」高级模式)→ 保持走本地路由,不改成直连
if (isRunning()) {
st._directSkipped = true;
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
} else if (!cur.baseUrl || !cur.key) {
st._directSkipped = true;
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
} else if (cur.mode === "chat") {
st._directSkipped = true;
st._directNeedRouter = true;
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
if (cur.mode === "claude") {
// 接入 Claude Code
if (!cur.baseUrl || !cur.key) {
st._claudeSkipped = true;
st._claudeReason = "该分组缺少 API 地址或密钥,未写入 Claude Code 配置";
} else {
try {
const rc = applyDirectToClaude(cur.id);
if (rc.ok) st._claudeApplied = true;
else { st._claudeSkipped = true; st._claudeReason = rc.error || "写入失败"; }
} catch (e) {
st._claudeError = String((e && e.message) || e);
}
}
} else {
try {
const r = applyDirectToCodex(cur.id);
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
} catch (e) {
st._directError = String((e && e.message) || e);
// 接入 Codex(responses 直连 / chat 协议转换)
// 仅当【本分组】之前是 claude 模式、现改回 Codex 模式 → 还原它写过的 Claude Code 配置
if (prevMode === "claude" && config.claudePrev && claudeDirectApplied()) {
try { restoreClaudeCode(); st._claudeRestored = true; } catch {}
}
if (isRunning()) {
st._directSkipped = true;
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
} else if (!cur.baseUrl || !cur.key) {
st._directSkipped = true;
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
} else if (cur.mode === "chat") {
st._directSkipped = true;
st._directNeedRouter = true;
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
} else {
try {
const r = applyDirectToCodex(cur.id);
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
} catch (e) {
st._directError = String((e && e.message) || e);
}
}
}
}
@@ -814,8 +987,20 @@ 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 (config.activeId === id) config.activeId = config.groups[0] ? config.groups[0].id : null;
// 删的是哪族分组,就补选同族的另一个(找不到则清空该族激活槽),不影响另一族。
if (wasClaude) {
if (config.activeClaudeId === id) {
const next = config.groups.find((x) => isClaudeGroup(x));
config.activeClaudeId = next ? next.id : null;
}
} else {
if (config.activeId === id) {
const next = config.groups.find((x) => !isClaudeGroup(x));
config.activeId = next ? next.id : null;
}
}
save();
try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env
return status();
@@ -824,6 +1009,25 @@ function setActive(id) {
load();
const target = config.groups.find((g) => g.id === id);
if (!target) return status();
// Claude Code(Anthropic 族):落 activeClaudeId,写 ~/.claude/settings.json,绝不碰 Codex 配置。
// 与 OpenAI 族互不顶替 —— 两族可同时激活。
if (isClaudeGroup(target)) {
config.activeClaudeId = id;
let claudeMode = "proxy";
let claudeSkipped, claudeReason;
if (isRunning()) {
// 路由在跑:settings.json 指向本地路由即可,路由按 activeClaudeId 动态转发,无需改文件、无需重启
claudeMode = "proxy";
if (!claudeApplied()) applyToClaudeCode();
} else {
// 默认模式:直连中转,把分组地址写进 settings.json(新开 claude 会话生效)
claudeMode = "direct";
const r = applyDirectToClaude(id);
if (!r.ok) { claudeSkipped = r.skipped; claudeReason = r.error; }
}
save();
return { ...status(), _mode: claudeMode, _claudeSkipped: claudeSkipped, _claudeReason: claudeReason };
}
const before = currentModelProvider();
const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID;
let needsRestart = false;
@@ -873,10 +1077,10 @@ function codexSnippet() {
function applyToCodex() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组(排除 claude 模式)
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (!cur || cur.isDefault || cur.mode === "claude") {
const proxy = config.groups.find((g) => !g.isDefault && g.mode !== "claude");
if (proxy) config.activeId = proxy.id;
}
const cfgFile = path.join(CODEX_HOME, "config.toml");
@@ -924,6 +1128,9 @@ function applyToCodex() {
applyModelCatalog(cur2);
setConfigModel(cur2.model);
}
// 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录),
// 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。
if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {});
save();
return { ok: true };
}
@@ -947,6 +1154,9 @@ function applyDirectToCodex(groupId) {
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);
@@ -991,6 +1201,10 @@ function applyDirectToCodex(groupId) {
applyModelCatalog(g);
setConfigModel(g.model);
}
// 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。
// 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App),
// 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。
syncCatalogFromProvider(g).catch(() => {});
config.activeId = g.id;
save();
return { ok: true, direct: true, group: g.name };
@@ -1001,15 +1215,8 @@ function claudeSettingsPath() {
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();
}
}
// Anthropic 请求由路由按 activeClaudeId 派发(见 handle);这里只把 settings.json 指向本地路由。
// 不再动 activeId(OpenAI/Codex 槽)—— 两族独立,接入 Claude Code 不该顶掉 Codex 的选择。
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const p = claudeSettingsPath();
let settings = {};
@@ -1055,8 +1262,8 @@ function restoreClaudeCode() {
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]]) {
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;
}
@@ -1077,6 +1284,66 @@ function claudeApplied() {
}
}
// —— Claude Code 直连中转(不经本地路由)——
// 把指定分组的中转地址/key 直接写进 ~/.claude/settings.json 的 env,Claude Code 直连中转站。
// 与 applyToClaudeCode(写本地路由地址)对称,但 base_url = 分组主地址(非 127.0.0.1)。
function applyDirectToClaude(groupId) {
load();
const g = config.groups.find((x) => x.id === (groupId || config.activeId));
if (!g) return { ok: false, error: "分组不存在" };
if (g.isDefault) return { ok: false, error: "默认分组不接入 Claude Code" };
// Claude Code 直连用分组主字段:API 地址 baseUrl、API 密钥 key、默认模型 model
if (!g.baseUrl || !g.key) {
return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Claude Code 配置" };
}
const base = g.baseUrl.replace(/\/+$/, "");
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const p = claudeSettingsPath();
let settings = {};
let existed = false;
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
existed = true;
} catch {}
// 备份原文件
if (existed) {
try {
const dir = path.join(CLAUDE_HOME, "backups_router");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "settings.json." + Date.now() + ".bak"), fs.readFileSync(p));
} catch {}
}
settings.env = settings.env || {};
// 首次接入时记住用户原有值,便于「取消接入」还原(与 applyToClaudeCode 共用 claudePrev)
if (!config.claudePrev) {
config.claudePrev = {
base: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_BASE_URL") ? settings.env.ANTHROPIC_BASE_URL : null,
token: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_AUTH_TOKEN") ? settings.env.ANTHROPIC_AUTH_TOKEN : null,
model: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_MODEL") ? settings.env.ANTHROPIC_MODEL : null,
};
}
settings.env.ANTHROPIC_BASE_URL = base;
settings.env.ANTHROPIC_AUTH_TOKEN = g.key;
if (g.model && g.model.trim()) settings.env.ANTHROPIC_MODEL = g.model.trim();
else delete settings.env.ANTHROPIC_MODEL;
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
try { fs.chmodSync(p, 0o600); } catch {}
save();
return { ok: true, direct: true, path: p, group: g.name };
}
// Claude Code 是否已指向某个中转直连地址(非本地路由)
function claudeDirectApplied() {
try {
const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8"));
const url = s.env && s.env.ANTHROPIC_BASE_URL;
return !!(url && !/127\.0\.0\.1/.test(url));
} catch {
return false;
}
}
module.exports = {
load,
start,
@@ -1091,8 +1358,10 @@ module.exports = {
applyDirectToCodex,
codexSnippet,
applyToClaudeCode,
applyDirectToClaude,
restoreClaudeCode,
claudeApplied,
claudeDirectApplied,
getGroupKey,
syncGenImage,
groupById,
@@ -1100,4 +1369,5 @@ module.exports = {
llmGroup,
sttGroup,
fetchModels,
syncCatalogFromProvider,
};