import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".."); const POOL_DEFINITIONS = [ { id: "codex-accounts", label: "Codex 账号池", category: "accounts", provider: "codex", filePath: path.join(REPO_ROOT, "acc_pool", "pool.json"), }, { id: "codex-api", label: "Codex API 池", category: "api", provider: "codex", filePath: path.join(REPO_ROOT, "api_pool", "codex", "pool.json"), }, { id: "claude-code-api", label: "Claude Code API 池", category: "api", provider: "claude-code", filePath: path.join(REPO_ROOT, "api_pool", "claude-code", "pool.json"), }, ]; function nowStamp() { const date = new Date(); const pad = (value) => String(value).padStart(2, "0"); return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()), "-", pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds()), ].join(""); } function normalizeBoolean(value) { return value === true || value === "true"; } function normalizeText(value) { return value == null ? "" : String(value); } function isValidDate(value) { if (!value) return true; const stamp = new Date(value).getTime(); return !Number.isNaN(stamp); } function validationError(pathName, message) { return { path: pathName, message }; } function normalizeCodexAccountItem(item) { const tokenSource = item?.tokens && typeof item.tokens === "object" ? item.tokens : item || {}; return { OPENAI_API_KEY: normalizeText(item?.OPENAI_API_KEY), auth_mode: normalizeText(item?.auth_mode) || "chatgpt", type: normalizeText(item?.type) || "codex", disabled: normalizeBoolean(item?.disabled), email: normalizeText(item?.email), name: normalizeText(item?.name), last_refresh: normalizeText(item?.last_refresh), expired: normalizeText(item?.expired), tokens: { access_token: normalizeText(tokenSource.access_token), account_id: normalizeText(tokenSource.account_id), id_token: normalizeText(tokenSource.id_token), refresh_token: normalizeText(tokenSource.refresh_token), }, }; } function validateCodexAccountItem(item, index) { const errors = []; if ((normalizeText(item.type) || "codex") !== "codex") { errors.push(validationError(`${index}.type`, "账号池条目的 type 必须是 codex")); } if (item.last_refresh && !isValidDate(item.last_refresh)) { errors.push(validationError(`${index}.last_refresh`, "last_refresh 不是合法时间")); } if (item.expired && !isValidDate(item.expired)) { errors.push(validationError(`${index}.expired`, "expired 不是合法时间")); } if (!item.tokens.access_token) { errors.push(validationError(`${index}.tokens.access_token`, "缺少 access_token")); } if (!item.tokens.account_id) { errors.push(validationError(`${index}.tokens.account_id`, "缺少 account_id")); } if (!item.tokens.id_token) { errors.push(validationError(`${index}.tokens.id_token`, "缺少 id_token")); } if (!item.tokens.refresh_token) { errors.push(validationError(`${index}.tokens.refresh_token`, "缺少 refresh_token")); } return errors; } function normalizeApiItem(item, provider) { return { name: normalizeText(item?.name), type: normalizeText(item?.type) || provider, baseUrl: normalizeText(item?.baseUrl), apiKey: normalizeText(item?.apiKey), model: normalizeText(item?.model), probePath: normalizeText(item?.probePath), disabled: normalizeBoolean(item?.disabled), }; } function validateApiItem(item, provider, index) { const errors = []; if (item.type !== provider) { errors.push(validationError(`${index}.type`, `API 池条目的 type 必须是 ${provider}`)); } if (!item.name) { errors.push(validationError(`${index}.name`, "缺少 name")); } if (!item.baseUrl) { errors.push(validationError(`${index}.baseUrl`, "缺少 baseUrl")); } else { try { new URL(item.baseUrl); } catch { errors.push(validationError(`${index}.baseUrl`, "baseUrl 不是合法 URL")); } } if (!item.apiKey) { errors.push(validationError(`${index}.apiKey`, "缺少 apiKey")); } return errors; } async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } async function readJsonFile(filePath) { return JSON.parse(await fs.readFile(filePath, "utf8")); } export class PoolStore { constructor(definitions = POOL_DEFINITIONS) { this.definitions = definitions; } listPools() { return this.definitions.map((item) => ({ id: item.id, label: item.label, category: item.category, provider: item.provider, filePath: item.filePath, })); } getDefinition(poolId) { const definition = this.definitions.find((item) => item.id === poolId) || null; if (!definition) { const error = new Error(`Unknown pool: ${poolId}`); error.statusCode = 404; throw error; } return definition; } async loadPool(poolId) { const definition = this.getDefinition(poolId); let items = []; let savedAt = null; if (await fileExists(definition.filePath)) { const raw = JSON.parse(await fs.readFile(definition.filePath, "utf8")); items = Array.isArray(raw) ? raw : []; const stat = await fs.stat(definition.filePath); savedAt = stat.mtime.toISOString(); } return { pool: { id: definition.id, label: definition.label, category: definition.category, provider: definition.provider, filePath: definition.filePath, }, items, savedAt, }; } async updateCodexAccountFromLocalAuth(index, options = {}) { const definition = this.getDefinition("codex-accounts"); const authPath = path.resolve( options.authPath || path.join(os.homedir(), ".codex", "auth.json"), ); if (!(await fileExists(definition.filePath))) { const error = new Error("账号池文件不存在"); error.statusCode = 404; throw error; } if (!(await fileExists(authPath))) { const error = new Error(`本地 auth.json 不存在: ${authPath}`); error.statusCode = 404; throw error; } const loadedItems = await readJsonFile(definition.filePath); const currentItems = Array.isArray(loadedItems) ? loadedItems : []; const targetIndex = Number(index); if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= currentItems.length) { const error = new Error(`账号池条目不存在: ${index}`); error.statusCode = 404; throw error; } const targetItem = normalizeCodexAccountItem(currentItems[targetIndex]); const localItem = normalizeCodexAccountItem(await readJsonFile(authPath)); if (!localItem.tokens.account_id) { const error = new Error("本地 auth.json 缺少 account_id,无法更新"); error.statusCode = 400; throw error; } if (localItem.tokens.account_id !== targetItem.tokens.account_id) { const error = new Error( `本地 auth.json 的 account_id(${localItem.tokens.account_id}) 与当前条目(${targetItem.tokens.account_id}) 不一致`, ); error.statusCode = 409; throw error; } const mergedItem = { ...targetItem, OPENAI_API_KEY: localItem.OPENAI_API_KEY || targetItem.OPENAI_API_KEY, auth_mode: localItem.auth_mode || targetItem.auth_mode, email: targetItem.email || localItem.email, last_refresh: localItem.last_refresh || new Date().toISOString(), expired: localItem.expired || targetItem.expired, tokens: { ...targetItem.tokens, access_token: localItem.tokens.access_token || targetItem.tokens.access_token, account_id: localItem.tokens.account_id, id_token: localItem.tokens.id_token || targetItem.tokens.id_token, refresh_token: localItem.tokens.refresh_token || targetItem.tokens.refresh_token, }, }; const nextItems = [...currentItems]; nextItems[targetIndex] = mergedItem; return this.savePool("codex-accounts", nextItems); } validatePoolItems(poolId, items) { const definition = this.getDefinition(poolId); if (!Array.isArray(items)) { return { ok: false, errors: [validationError("items", "items 必须是数组")], normalizedItems: [], }; } const normalizedItems = []; const errors = []; for (const [index, rawItem] of items.entries()) { if (!rawItem || typeof rawItem !== "object") { errors.push(validationError(String(index), "条目必须是对象")); continue; } if (definition.category === "accounts") { const normalized = normalizeCodexAccountItem(rawItem); normalizedItems.push(normalized); errors.push(...validateCodexAccountItem(normalized, index)); continue; } const normalized = normalizeApiItem(rawItem, definition.provider); normalizedItems.push(normalized); errors.push(...validateApiItem(normalized, definition.provider, index)); } return { ok: errors.length === 0, errors, normalizedItems, }; } async savePool(poolId, items) { const definition = this.getDefinition(poolId); const validation = this.validatePoolItems(poolId, items); if (!validation.ok) { const error = new Error("Pool validation failed"); error.statusCode = 400; error.details = validation.errors; throw error; } await fs.mkdir(path.dirname(definition.filePath), { recursive: true }); if (await fileExists(definition.filePath)) { const backupDir = path.join(path.dirname(definition.filePath), "_backup"); await fs.mkdir(backupDir, { recursive: true }); const backupPath = path.join(backupDir, `pool-${nowStamp()}.json`); await fs.copyFile(definition.filePath, backupPath); } await fs.writeFile( definition.filePath, `${JSON.stringify(validation.normalizedItems, null, 2)}\n`, "utf8", ); const stat = await fs.stat(definition.filePath); return { pool: { id: definition.id, label: definition.label, category: definition.category, provider: definition.provider, filePath: definition.filePath, }, items: validation.normalizedItems, savedAt: stat.mtime.toISOString(), count: validation.normalizedItems.length, }; } }