Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ Authentication failures and temporary upstream failures can fail over to
another eligible account before a response reaches the client. The hub never
replays a response after it starts streaming to the client.

Conversation affinity keeps requests on the same eligible account, scoped by
provider and host. Claude uses the session ID in JSON or legacy
`metadata.user_id`. Codex uses its native `session-id` header, then the existing
`session_id` form, then the body `prompt_cache_key`. Session IDs and cache keys
have separate namespaces. Requests without a usable identifier follow the
priority, in-flight count, and weekly-reset order.

The hub rebinds a conversation when its account becomes ineligible. It keeps
up to 4,096 bindings in memory, expires them after 30 minutes of inactivity,
and resets them on restart. Codex's native `session-id` and `thread-id` headers,
cache fields, and Responses-based compaction payloads pass through, including
encrypted items. Affinity avoids unnecessary account switches but does not
guarantee an upstream prompt-cache hit.

The builtin Keep Awake plugin prevents macOS idle sleep while bb is running.
Its settings page lets you target all hosts or selected hosts. The CLI
equivalents are:
Expand Down
4 changes: 3 additions & 1 deletion plugins/account-pool/PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Keep a Claude Code or Codex thread running when one account hits its limit. The
## What you get

- A pool of Claude and Codex accounts, added by importing the login already on the machine, signing in through the browser, or pasting an Anthropic API key.
- Per-request selection that follows your priority order, then the account with the fewest requests in flight, then the account whose weekly window resets first.
- New conversations follow your priority order, then the account with the fewest requests in flight, then the account whose weekly window resets first. A conversation stays on its account while that account remains eligible.
- Live limit windows per account and model family in the plugin's settings page, and the same numbers from `bb pool status`.
- A routing switch per provider and a bypass per thread, so one thread can go straight to its own credentials.

Expand All @@ -15,6 +15,8 @@ Concurrent requests share one OAuth refresh per account. During a temporary refr

If an OAuth request receives HTTP 401, the hub refreshes its credential once and retries. It reuses a token already refreshed by another request. Authentication failures and temporary upstream failures can move the request to another eligible account before a response reaches the client. Once a response starts, the hub does not replay it on another account.

Conversation affinity uses Claude's session metadata or Codex's session header, with its prompt cache key as a fallback. Bindings are separate for each provider and host. The hub chooses another account when the bound account becomes ineligible. Bindings expire after 30 minutes without a request and reset when the hub restarts. Requests without a usable identifier follow ordinary account selection.

## Requirements

Accounts you own and are permitted to use this way.
Expand Down
11 changes: 8 additions & 3 deletions plugins/account-pool/src/claude-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,14 @@ export function createClaudeAdapter(options: {
},
};
},
modelFamily: (body) => parseRequestBody(body).family,
prepareBody: (body, account) =>
parseRequestBody(body).forAccount(account.accountUuid),
parseRequest(body) {
const parsed = parseRequestBody(body);
return {
family: parsed.family,
affinityId: parsed.affinityId,
forAccount: (account) => parsed.forAccount(account.accountUuid),
};
},
upstreamUrl: (request, settings) =>
mountedUpstreamUrl(request, settings.anthropicUpstreamBaseUrl),
requestHeaders(inbound, _account, secret) {
Expand Down
13 changes: 11 additions & 2 deletions plugins/account-pool/src/codex-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { parseCodexRequestBody } from "./request-body.js";
import type {
AccountQuota,
AccountSecret,
Expand Down Expand Up @@ -30,7 +31,9 @@ const ALLOWED_REQUEST_HEADERS = new Set([
"content-type",
"openai-beta",
"originator",
"session-id",
"session_id",
"thread-id",
"user-agent",
]);
const ALLOWED_REQUEST_HEADER_PREFIXES = ["x-codex-", "x-stainless-"];
Expand Down Expand Up @@ -257,8 +260,14 @@ export function createCodexAdapter(options: {
},
};
},
modelFamily: () => "other",
prepareBody: (body) => body,
parseRequest(body, headers) {
const parsed = parseCodexRequestBody(body, headers);
return {
family: parsed.family,
affinityId: parsed.affinityId,
forAccount: () => body,
};
},
upstreamUrl: (request, settings) =>
mountedUpstreamUrl(request, settings.codexUpstreamBaseUrl, "v1/"),
requestHeaders(inbound, account, secret) {
Expand Down
69 changes: 58 additions & 11 deletions plugins/account-pool/src/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const MAX_REFRESH_BACKOFF_MS = 60_000;
const MAX_REFRESH_BACKOFFS = 1_024;
const MAX_FAILURE_DETAIL_BYTES = 1_024;
const FAILURE_DISPOSAL_TIMEOUT_MS = 250;
const AFFINITY_IDLE_TTL_MS = 30 * 60 * 1_000;
const MAX_AFFINITY_BINDINGS = 4_096;
const DROPPED_RESPONSE_HEADERS = new Set([
"content-encoding",
"content-length",
Expand Down Expand Up @@ -108,13 +110,18 @@ export class AccountPoolHub {
private readonly activeControllers = new Set<AbortController>();
private readonly refreshes = new Map<string, SecretFlight>();
private readonly refreshBackoffs = new Map<string, RefreshBackoff>();
private readonly affinityBindings = new Map<
string,
{ accountId: string; lastUsedAt: number }
>();
private readonly usageRefreshes = new Map<string, Promise<void>>();
private readonly lastUsageRefreshAt = new Map<string, number>();
private readonly drainWaiters = new Set<() => void>();

constructor(private readonly options: HubOptions) {}

async start(signal: AbortSignal): Promise<void> {
this.affinityBindings.clear();
this.stopped = new AbortController();
this.accepting = true;
while (!signal.aborted) {
Expand Down Expand Up @@ -280,12 +287,18 @@ export class AccountPoolHub {
): Promise<Response> {
const signal = AbortSignal.any([request.signal, this.stopped.signal]);
const attempted = new Set<string>();
let previousAccountId: string | null = null;
let failure: FailureSummary | null = null;
const accounts = (await this.options.accounts.list()).filter(
(account) => account.provider === adapter.provider,
);
const candidateIds = new Set(accounts.map((account) => account.id));
const family = adapter.modelFamily(body);
const parsed = adapter.parseRequest(body, request.headers);
const family = parsed.family;
const affinityKey =
hostId === null || parsed.affinityId === null
? null
: JSON.stringify([adapter.provider, hostId, parsed.affinityId]);
try {
while (attempted.size < candidateIds.size) {
signal.throwIfAborted();
Expand All @@ -294,8 +307,12 @@ export class AccountPoolHub {
candidateIds,
attempted,
family,
affinityKey,
previousAccountId,
signal,
);
if (selected === null) break;
previousAccountId = selected.account.id;
attempted.add(selected.account.id);
if (hostId !== null) {
const changed = await this.options.accounts.recordUsed(
Expand Down Expand Up @@ -329,7 +346,7 @@ export class AccountPoolHub {
try {
upstream = await this.fetchUpstream(
request,
adapter.prepareBody(body, selected.account),
parsed.forAccount(selected.account),
selected.account,
secret,
adapter,
Expand Down Expand Up @@ -567,24 +584,27 @@ export class AccountPoolHub {
candidateIds: ReadonlySet<string>,
attempted: ReadonlySet<string>,
family: ModelFamily,
affinityKey: string | null,
previousAccountId: string | null,
signal: AbortSignal,
): Promise<SelectedAccount | null> {
const accounts = await this.options.accounts.list();
signal.throwIfAborted();
const now = this.options.now();
const threshold = this.options.getSettings().switchThreshold;
const candidates = (await this.options.accounts.list())
.filter(
(account) =>
account.provider === provider &&
candidateIds.has(account.id) &&
account.enabled &&
!attempted.has(account.id),
)
const eligible = accounts
.filter((account) => account.provider === provider && account.enabled)
.map((account) => ({
account,
quota: this.options.quotas.get(account.id),
}))
.filter(({ quota }) => quota.error === null)
.filter(({ quota }) => quota.heldUntil === null || quota.heldUntil <= now)
.filter(({ quota }) => !isQuotaExhausted(quota, family, threshold, now));
const candidates = eligible.filter(
({ account }) =>
candidateIds.has(account.id) && !attempted.has(account.id),
);
candidates.sort((left, right) => {
const priority = left.account.priority - right.account.priority;
if (priority !== 0) return priority;
Expand All @@ -598,7 +618,34 @@ export class AccountPoolHub {
(governingWeeklyResetAt(right.quota, family) ?? Number.MAX_SAFE_INTEGER)
);
});
return candidates[0] ?? null;
const binding =
affinityKey === null ? undefined : this.affinityBindings.get(affinityKey);
const bound =
binding !== undefined && now - binding.lastUsedAt < AFFINITY_IDLE_TTL_MS
? eligible.find(({ account }) => account.id === binding.accountId)
: undefined;
const selected =
bound !== undefined && candidates.includes(bound)
? bound
: (candidates[0] ?? null);
if (
affinityKey !== null &&
selected !== null &&
(bound === undefined ||
bound.account.id === selected.account.id ||
bound.account.id === previousAccountId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an older in-flight request fails on the currently-bound account and a newer concurrent request then re-confirms that same account (so the binding still points to it), the older request's retry sees bound.account.id === previousAccountId and rebinds the shared session to a different account. This can disrupt the newer request's stable routing even though that account just succeeded for it. The rebind condition bound.account.id === previousAccountId does not distinguish 'account I failed on and nobody rebinding since' from 'account a newer request just re-confirmed', and the existing tests only cover the case where the newer binding points to a different account. Consider tracking whether the binding was re-confirmed by a newer request before allowing an older retry to rebind away from it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/account-pool/src/hub.ts, line 636:

<comment>When an older in-flight request fails on the currently-bound account and a newer concurrent request then re-confirms that same account (so the binding still points to it), the older request's retry sees `bound.account.id === previousAccountId` and rebinds the shared session to a different account. This can disrupt the newer request's stable routing even though that account just succeeded for it. The rebind condition `bound.account.id === previousAccountId` does not distinguish 'account I failed on and nobody rebinding since' from 'account a newer request just re-confirmed', and the existing tests only cover the case where the newer binding points to a different account. Consider tracking whether the binding was re-confirmed by a newer request before allowing an older retry to rebind away from it.</comment>

<file context>
@@ -598,7 +618,34 @@ export class AccountPoolHub {
+      selected !== null &&
+      (bound === undefined ||
+        bound.account.id === selected.account.id ||
+        bound.account.id === previousAccountId)
+    ) {
+      this.affinityBindings.delete(affinityKey);
</file context>

) {
this.affinityBindings.delete(affinityKey);
this.affinityBindings.set(affinityKey, {
accountId: selected.account.id,
lastUsedAt: now,
});
while (this.affinityBindings.size > MAX_AFFINITY_BINDINGS) {
const oldest = this.affinityBindings.keys().next();
if (!oldest.done) this.affinityBindings.delete(oldest.value);
}
}
return selected;
}

private async freshSecret(
Expand Down
10 changes: 8 additions & 2 deletions plugins/account-pool/src/provider-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,14 @@ export interface ProviderAdapter {
provider: PoolProvider;
upstreamName: string;
importAccount(): Promise<ImportedProviderAccount>;
modelFamily(body: Uint8Array): ModelFamily;
prepareBody(body: Uint8Array, account: Account): Uint8Array;
parseRequest(
body: Uint8Array,
headers: Headers,
): {
family: ModelFamily;
affinityId: string | null;
forAccount: (account: Account) => Uint8Array;
};
upstreamUrl(request: Request, settings: HubSettings): URL;
requestHeaders(
inbound: Headers,
Expand Down
Loading
Loading