diff --git a/apps/server/src/t4/claudeAccounts.test.ts b/apps/server/src/t4/claudeAccounts.test.ts index 38227bfa372af..2824d4a1aa2c0 100644 --- a/apps/server/src/t4/claudeAccounts.test.ts +++ b/apps/server/src/t4/claudeAccounts.test.ts @@ -135,6 +135,21 @@ describe("t4 claudeAccounts", () => { expect(pickClaudeAccount(1_000, noLogin)).toEqual({ key: "main" }); }); + it("with every account used up and no main login, gives the one that frees first (AI-99)", () => { + const { dir, env } = setup(); + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t4-home-")); + const noLogin = { ...env, HOME: home } as NodeJS.ProcessEnv; + markClaudeAccountExhausted("1", 90_000, 1_000, noLogin); + markClaudeAccountExhausted("2", 50_000, 1_000, noLogin); + expect(pickClaudeAccount(1_000, noLogin)).toEqual({ key: "2", token: "token-two" }); + // A token file gone is skipped there too. + NodeFS.rmSync(NodePath.join(dir, "2.token")); + expect(pickClaudeAccount(1_000, noLogin)).toEqual({ key: "1", token: "token-one" }); + // No usable account at all: the main login, as before. + NodeFS.rmSync(NodePath.join(dir, "1.token")); + expect(pickClaudeAccount(1_000, noLogin)).toEqual({ key: "main" }); + }); + it("signs the status check and text generation in with an account, used up or not", () => { const { dir, env } = setup(); const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t4-home-")); diff --git a/apps/server/src/t4/claudeAccounts.ts b/apps/server/src/t4/claudeAccounts.ts index 27ec78a79ae97..9c39ed75021b5 100644 --- a/apps/server/src/t4/claudeAccounts.ts +++ b/apps/server/src/t4/claudeAccounts.ts @@ -193,7 +193,11 @@ export const isClaudeAccountExhausted = ( return blockedUntil(loadState(env, files), key, model) > nowMs; }; -/** The first account in order not used up for `model` (the main login included), else the main login. */ +/** + * The first account not used up for `model` (the main login included); with none, the main login + * if it exists, else the account freeing first - so Claude answers with its real limit, never the + * "Not logged in" of a token-less main, which nothing resumes (AI-99). + */ export const pickClaudeAccount = ( nowMs: number, env: NodeJS.ProcessEnv = process.env, @@ -202,16 +206,20 @@ export const pickClaudeAccount = ( const files = accountFiles(env); if (files.length === 0) return { key: MAIN_CLAUDE_ACCOUNT }; const state = loadState(env, files); + let soonest: { account: ClaudeAccount; until: number } | null = null; for (const { key, file } of accountList(env)) { - if (blockedUntil(state, key, model) > nowMs) continue; + const until = blockedUntil(state, key, model); if (file === undefined) { - if (mainLoginAvailable(env)) return { key: MAIN_CLAUDE_ACCOUNT }; + if (until <= nowMs && mainLoginAvailable(env)) return { key: MAIN_CLAUDE_ACCOUNT }; continue; } const token = readToken(file); - if (token !== undefined) return { key, token }; + if (token === undefined) continue; + if (until <= nowMs) return { key, token }; + if (soonest === null || until < soonest.until) soonest = { account: { key, token }, until }; } - return { key: MAIN_CLAUDE_ACCOUNT }; + if (mainLoginAvailable(env) || soonest === null) return { key: MAIN_CLAUDE_ACCOUNT }; + return soonest.account; }; export const claudeAccountEnvironment = (