AI_PROJECT / src /ui-server /pool-store.mjs
chenchenaoyang's picture
Deploy from Codex
f395b70 verified
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,
};
}
}