codex-proxy / src /auth /account-persistence.ts
icebear
fix: allow multiple team accounts sharing same chatgpt_account_id (#126) (#127)
7e5ecc4 unverified
raw
history blame
6.72 kB
/**
* AccountPersistence — file-system persistence for AccountPool.
* Handles load/save/migrate operations as an injectable dependency.
*/
import {
readFileSync,
writeFileSync,
renameSync,
existsSync,
mkdirSync,
} from "fs";
import { resolve, dirname } from "path";
import { randomBytes } from "crypto";
import { getDataDir } from "../paths.js";
import {
extractChatGptAccountId,
extractUserProfile,
isTokenExpired,
} from "./jwt-utils.js";
import type { AccountEntry, AccountsFile } from "./types.js";
export interface AccountPersistence {
load(): { entries: AccountEntry[]; needsPersist: boolean };
save(accounts: AccountEntry[]): void;
}
function getAccountsFile(): string {
return resolve(getDataDir(), "accounts.json");
}
function getLegacyAuthFile(): string {
return resolve(getDataDir(), "auth.json");
}
export function createFsPersistence(): AccountPersistence {
const persistence: AccountPersistence = {
load(): { entries: AccountEntry[]; needsPersist: boolean } {
// Migrate from legacy auth.json if needed
const migrated = migrateFromLegacy();
// Load from accounts.json
const { entries: loaded, needsPersist } = loadPersisted();
const entries = migrated.length > 0 && loaded.length === 0 ? migrated : loaded;
// Auto-persist when backfill was applied (preserves original behavior)
if (needsPersist && loaded.length > 0) {
persistence.save(loaded);
}
return { entries, needsPersist };
},
save(accounts: AccountEntry[]): void {
try {
const accountsFile = getAccountsFile();
const dir = dirname(accountsFile);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const data: AccountsFile = { accounts };
const tmpFile = accountsFile + ".tmp";
writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
renameSync(tmpFile, accountsFile);
} catch (err) {
console.error("[AccountPool] Failed to persist accounts:", err instanceof Error ? err.message : err);
}
},
};
return persistence;
}
function migrateFromLegacy(): AccountEntry[] {
try {
const accountsFile = getAccountsFile();
const legacyAuthFile = getLegacyAuthFile();
if (existsSync(accountsFile)) return []; // already migrated
if (!existsSync(legacyAuthFile)) return [];
const raw = readFileSync(legacyAuthFile, "utf-8");
const data = JSON.parse(raw) as {
token: string;
proxyApiKey?: string | null;
userInfo?: { email?: string; accountId?: string; planType?: string } | null;
};
if (!data.token) return [];
const id = randomBytes(8).toString("hex");
const accountId = extractChatGptAccountId(data.token);
const entry: AccountEntry = {
id,
token: data.token,
refreshToken: null,
email: data.userInfo?.email ?? null,
accountId: accountId,
userId: extractUserProfile(data.token)?.chatgpt_user_id ?? null,
planType: data.userInfo?.planType ?? null,
proxyApiKey: data.proxyApiKey ?? "codex-proxy-" + randomBytes(24).toString("hex"),
status: isTokenExpired(data.token) ? "expired" : "active",
usage: {
request_count: 0,
input_tokens: 0,
output_tokens: 0,
empty_response_count: 0,
last_used: null,
rate_limit_until: null,
window_request_count: 0,
window_input_tokens: 0,
window_output_tokens: 0,
window_counters_reset_at: null,
limit_window_seconds: null,
},
addedAt: new Date().toISOString(),
cachedQuota: null,
quotaFetchedAt: null,
};
// Write new format
const dir = dirname(accountsFile);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const accountsData: AccountsFile = { accounts: [entry] };
writeFileSync(accountsFile, JSON.stringify(accountsData, null, 2), "utf-8");
// Rename old file
renameSync(legacyAuthFile, legacyAuthFile + ".bak");
console.log("[AccountPool] Migrated from auth.json → accounts.json");
return [entry];
} catch (err) {
console.warn("[AccountPool] Migration failed:", err);
return [];
}
}
function loadPersisted(): { entries: AccountEntry[]; needsPersist: boolean } {
try {
const accountsFile = getAccountsFile();
if (!existsSync(accountsFile)) return { entries: [], needsPersist: false };
const raw = readFileSync(accountsFile, "utf-8");
const data = JSON.parse(raw) as AccountsFile;
if (!Array.isArray(data.accounts)) return { entries: [], needsPersist: false };
const entries: AccountEntry[] = [];
let needsPersist = false;
for (const entry of data.accounts) {
if (!entry.id || !entry.token) continue;
// Backfill missing fields from JWT
if (!entry.planType || !entry.email || !entry.accountId || !entry.userId) {
const profile = extractUserProfile(entry.token);
const accountId = extractChatGptAccountId(entry.token);
if (!entry.planType && profile?.chatgpt_plan_type) {
entry.planType = profile.chatgpt_plan_type;
needsPersist = true;
}
if (!entry.email && profile?.email) {
entry.email = profile.email;
needsPersist = true;
}
if (!entry.accountId && accountId) {
entry.accountId = accountId;
needsPersist = true;
}
if (!entry.userId && profile?.chatgpt_user_id) {
entry.userId = profile.chatgpt_user_id;
needsPersist = true;
}
}
// Backfill userId for entries missing it (pre-v1.0.68)
if (entry.userId === undefined) {
entry.userId = null;
needsPersist = true;
}
// Backfill empty_response_count
if (entry.usage.empty_response_count == null) {
entry.usage.empty_response_count = 0;
needsPersist = true;
}
// Backfill window counter fields
if (entry.usage.window_request_count == null) {
entry.usage.window_request_count = 0;
entry.usage.window_input_tokens = 0;
entry.usage.window_output_tokens = 0;
entry.usage.window_counters_reset_at = null;
entry.usage.limit_window_seconds = null;
needsPersist = true;
}
// Backfill cachedQuota fields
if (entry.cachedQuota === undefined) {
entry.cachedQuota = null;
entry.quotaFetchedAt = null;
needsPersist = true;
}
entries.push(entry);
}
return { entries, needsPersist };
} catch (err) {
console.warn("[AccountPool] Failed to load accounts:", err instanceof Error ? err.message : err);
return { entries: [], needsPersist: false };
}
}