import fs from "node:fs/promises"; import path from "node:path"; import { classifyFailure } from "./codex-account-pool.mjs"; const COOLDOWN_SECONDS = { auth: 1800, quota: 900, rate_limit: 120, server: 45, network: 30, invalid: 300, }; function nowMs(nowFn = Date.now) { return Number(nowFn()); } function isoFromMs(ms) { return new Date(ms).toISOString(); } function normalizeProvider(value) { const input = String(value || "").trim().toLowerCase(); if (input === "codex") return "codex"; if (input === "claude-code" || input === "claude_code" || input === "claude") { return "claude-code"; } return ""; } function prefersOpenAICompatibility(endpoint) { const model = String(endpoint?.model || "").trim().toLowerCase(); return ( model.startsWith("gpt-") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4") || model.includes("codex") ); } function makeEndpoint(raw, filePath) { return { id: path.basename(filePath), filePath, raw, name: raw.name || path.basename(filePath, path.extname(filePath)), type: normalizeProvider(raw.type), baseUrl: String(raw.baseUrl || "").trim(), apiKey: String(raw.apiKey || "").trim(), model: String(raw.model || "").trim(), probePath: String(raw.probePath || "").trim(), disabled: Boolean(raw.disabled), lastValidation: null, lastFailureReason: "", consecutiveFailures: 0, cooldownUntilMs: 0, healthy: false, }; } function makeEndpointFromEntry(raw, filePath, index = 0) { const endpoint = makeEndpoint(raw, filePath); if (index > 0) { endpoint.id = `${path.basename(filePath)}#${index + 1}`; } if (!endpoint.name) { endpoint.name = `${path.basename(filePath, path.extname(filePath))}-${index + 1}`; } return endpoint; } function normalizeRawEntries(raw) { if (Array.isArray(raw)) { return raw.filter((entry) => entry && typeof entry === "object"); } if (raw && typeof raw === "object") { return [raw]; } return []; } export function normalizeEndpointType(value) { return normalizeProvider(value); } export function isEndpointStructurallyEligible(endpoint, provider = "") { if (!endpoint) return false; if (endpoint.disabled) return false; if (!endpoint.baseUrl || !endpoint.apiKey) return false; if (!["codex", "claude-code"].includes(endpoint.type)) return false; if (provider && endpoint.type !== normalizeProvider(provider)) return false; return true; } export class ApiEndpointPool { constructor({ poolDir, provider, fetchFn = fetch, nowFn = Date.now, logger = () => {}, loadSnapshot = null, sourcePath = "", }) { this.poolDir = poolDir; this.provider = normalizeProvider(provider); this.fetchFn = fetchFn; this.nowFn = nowFn; this.logger = logger; this.loadSnapshot = loadSnapshot; this.sourcePath = sourcePath || (poolDir ? path.join(poolDir, "pool.json") : "pool.json"); this.endpoints = []; this.activeEndpointId = null; this.activeEndpointVersion = 0; } listEndpoints() { return [...this.endpoints]; } getActiveEndpoint() { if (!this.activeEndpointId) return null; return this.endpoints.find((endpoint) => endpoint.id === this.activeEndpointId) || null; } getActiveEndpointVersion() { return this.activeEndpointVersion; } async readSnapshots() { if (typeof this.loadSnapshot === "function") { const loaded = await this.loadSnapshot(); if (!loaded) return []; if (Array.isArray(loaded)) { return [{ entries: loaded, sourcePath: this.sourcePath }]; } return [ { entries: Array.isArray(loaded.entries) ? loaded.entries : [], sourcePath: loaded.sourcePath || this.sourcePath, }, ]; } const dirEntries = await fs.readdir(this.poolDir, { withFileTypes: true }); const allFiles = dirEntries .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => path.join(this.poolDir, entry.name)) .sort((left, right) => left.localeCompare(right)); const prioritizedPoolFile = allFiles.find((filePath) => path.basename(filePath) === "pool.json"); const files = prioritizedPoolFile ? [prioritizedPoolFile] : allFiles; const snapshots = []; for (const filePath of files) { try { const raw = JSON.parse(await fs.readFile(filePath, "utf8")); snapshots.push({ entries: normalizeRawEntries(raw), sourcePath: filePath, }); } catch { snapshots.push({ entries: [], sourcePath: filePath, }); } } return snapshots; } async load() { const snapshots = await this.readSnapshots(); const loaded = []; for (const snapshot of snapshots) { const entries = normalizeRawEntries(snapshot.entries); if (entries.length === 0) { this.logger("load:skip", { file: path.basename(snapshot.sourcePath), reason: "empty-or-invalid-json", }); continue; } for (const [index, entry] of entries.entries()) { const endpoint = makeEndpointFromEntry(entry, snapshot.sourcePath, index); if (!isEndpointStructurallyEligible(endpoint, this.provider)) { this.logger("load:skip", { file: path.basename(snapshot.sourcePath), index, reason: "structurally-ineligible", }); continue; } loaded.push(endpoint); this.logger("load:endpoint", { file: path.basename(snapshot.sourcePath), index, provider: endpoint.type, baseUrl: endpoint.baseUrl, }); } } this.endpoints = loaded; if (!this.activeEndpointId && this.endpoints.length > 0) { this.activeEndpointId = this.endpoints[0].id; } if ( this.activeEndpointId && !this.endpoints.find((endpoint) => endpoint.id === this.activeEndpointId) ) { this.activeEndpointId = this.endpoints[0]?.id || null; } } isCoolingDown(endpoint) { return endpoint.cooldownUntilMs > nowMs(this.nowFn); } pickNextHealthyEndpoint(excluded = new Set()) { const ordered = this.endpoints; const activeId = this.activeEndpointId; const startIdx = activeId ? Math.max(ordered.findIndex((endpoint) => endpoint.id === activeId), 0) : 0; const rotated = ordered .slice(startIdx + 1) .concat(ordered.slice(0, startIdx + 1)); return ( rotated.find((endpoint) => !excluded.has(endpoint.id) && !this.isCoolingDown(endpoint)) || null ); } pickNextRotationCandidate(excluded = new Set()) { if (this.endpoints.length <= 1) return null; const ordered = this.endpoints; const activeId = this.activeEndpointId; const startIdx = activeId ? Math.max(ordered.findIndex((endpoint) => endpoint.id === activeId), 0) : 0; const rotated = ordered .slice(startIdx + 1) .concat(ordered.slice(0, startIdx + 1)); return ( rotated.find( (endpoint) => endpoint.id !== activeId && !excluded.has(endpoint.id) && !endpoint.disabled && !this.isCoolingDown(endpoint), ) || null ); } recordHealthy(endpoint) { endpoint.healthy = true; endpoint.consecutiveFailures = 0; endpoint.lastFailureReason = ""; endpoint.cooldownUntilMs = 0; endpoint.lastValidation = isoFromMs(nowMs(this.nowFn)); } setActiveEndpoint(endpoint, mode = "failover", { expectedVersion = null, expectedId } = {}) { if ( expectedVersion !== null && (this.activeEndpointVersion !== expectedVersion || this.activeEndpointId !== expectedId) ) { return false; } const previousId = this.activeEndpointId; const previous = previousId ? this.endpoints.find((item) => item.id === previousId) : null; const previousFailure = previous?.lastFailureReason || ""; this.activeEndpointId = endpoint.id; if (previousId !== endpoint.id) { this.activeEndpointVersion += 1; } if (previousId && previousId !== endpoint.id) { if (mode === "scheduled") { this.logger("pool:active-endpoint:scheduled", { message: `活跃节点定时切换:${previousId} → ${endpoint.id} (${endpoint.name || ""})`, id: endpoint.id, name: endpoint.name, baseUrl: endpoint.baseUrl, model: endpoint.model, previousId, }); } else { this.logger("pool:active-endpoint", { message: `活跃节点切换:${previousId} → ${endpoint.id} (${endpoint.name || ""}),上一个节点失败原因:${previousFailure || "未知"}`, id: endpoint.id, name: endpoint.name, baseUrl: endpoint.baseUrl, model: endpoint.model, previousId, previousFailure, }); } } return true; } markSuccess(endpoint, options = {}) { this.recordHealthy(endpoint); return this.setActiveEndpoint(endpoint, "failover", options); } markScheduledSwitch(endpoint) { this.recordHealthy(endpoint); return this.setActiveEndpoint(endpoint, "scheduled"); } markFailure(endpoint, category, reason) { endpoint.healthy = false; endpoint.consecutiveFailures += 1; endpoint.lastFailureReason = `${category}:${reason}`; const cooldownSeconds = COOLDOWN_SECONDS[category] || COOLDOWN_SECONDS.invalid; endpoint.cooldownUntilMs = nowMs(this.nowFn) + cooldownSeconds * 1000; } classifyProbeFailure({ status = 0, detail = "" }) { return classifyFailure({ status, detail }); } resolveProbeRequest(endpoint) { if (endpoint.probePath) { return { method: "GET", path: endpoint.probePath, headers: {}, body: null, }; } if (endpoint.type === "claude-code") { if (endpoint.model) { return { method: "POST", path: "/v1/messages", headers: { "anthropic-version": "2023-06-01", "content-type": "application/json", }, body: JSON.stringify({ model: endpoint.model, max_tokens: 1, messages: [{ role: "user", content: "ping" }], }), }; } return { method: "GET", path: "/v1/models", headers: {}, body: null, }; } if (endpoint.model) { return { method: "POST", path: "/v1/responses", headers: { "content-type": "application/json", }, body: JSON.stringify({ model: endpoint.model, input: "Reply with OK.", max_output_tokens: 8, }), }; } return { method: "GET", path: "/v1/models", headers: {}, body: null, }; } async probeEndpoint(endpoint, options = {}) { const activateOnSuccess = options.activateOnSuccess !== false; const activationMode = options.activationMode || "failover"; const probe = this.resolveProbeRequest(endpoint); const url = new URL(probe.path, endpoint.baseUrl.endsWith("/") ? endpoint.baseUrl : `${endpoint.baseUrl}/`); this.logger("probe:start", { id: endpoint.id, provider: endpoint.type, url: url.toString(), }); let response; try { response = await this.fetchFn(url, { method: probe.method, headers: { authorization: `Bearer ${endpoint.apiKey}`, "x-api-key": endpoint.apiKey, ...probe.headers, }, body: probe.body, }); } catch (error) { const detail = error?.message || String(error); this.markFailure(endpoint, "network", detail); return { ok: false, status: 0, category: "network", reason: "network", detail, }; } if (response.ok) { this.recordHealthy(endpoint); if (activateOnSuccess) { this.setActiveEndpoint(endpoint, activationMode); } return { ok: true, status: response.status, category: "ok", reason: "probe-ok" }; } const detail = await response.text(); const classified = this.classifyProbeFailure({ status: response.status, detail }); this.markFailure(endpoint, classified.category, detail || classified.reason); return { ok: false, status: response.status, category: classified.category, reason: classified.reason, detail, }; } async getInitialEndpoint() { if (this.endpoints.length === 0) return null; const excluded = new Set(); for (let i = 0; i < this.endpoints.length; i += 1) { const currentActive = this.getActiveEndpoint(); const candidate = i === 0 && currentActive && !excluded.has(currentActive.id) ? currentActive : this.pickNextHealthyEndpoint(excluded); if (!candidate) break; excluded.add(candidate.id); if (this.isCoolingDown(candidate)) continue; const probe = await this.probeEndpoint(candidate, { activateOnSuccess: true, activationMode: "initial", }); if (probe.ok) { this.activeEndpointId = candidate.id; return candidate; } } return null; } }