Skip to content
Merged
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
10 changes: 7 additions & 3 deletions apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,13 @@ prior token valid for ten minutes. Agents should pipe API keys to
`--api-key-stdin`;
`--api-key <key>` is an unsafe compatibility form that exposes the key in
process arguments, shell history, and agent transcripts. Prefer `--import` for
an existing Claude Code login. JSON account status
reports rejected upstream bucket resets under `bucketExhaustion`; this
diagnostic field does not affect account selection.
an existing Claude Code login. OAuth quota refreshes on add or enable and every
five minutes while an account is idle. Account tables add columns for observed
model-family buckets; JSON status exposes their utilization, reset, status,
observation time, and source under `familyWeekly`. Selection skips an account
whose requested family is spent while retaining it for other families. A
present `metadata.user_id` account UUID is aligned with the selected OAuth
account.

- Inspect real status, logs, API results, or diffs instead of assumptions.
- Keep file paths on the machine that owns the selected workspace.
Expand Down
17 changes: 12 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,11 +658,18 @@ for ten minutes so in-flight requests can drain. Bypass or restore routing for
one thread with `bb pool bypass <thread-id>` or
`bb pool bypass <thread-id> --off`. Account listing, enable, disable, and
removal are available through `bb pool account list|enable|disable|remove`.
JSON account status includes rejected upstream bucket resets under
`bucketExhaustion`. The field is diagnostic and does not affect selection.

Two settings control routing. `switchThreshold` is the 5-hour or 7-day quota
fraction at which an account stops receiving traffic and defaults to `0.98`.
OAuth accounts refresh quota from Anthropic's usage endpoint when added or
enabled and every five minutes while idle. `account list` adds columns for the
family buckets Anthropic reports; JSON status exposes their utilization,
reset, status, observation time, and `header` or `usage` source under
`familyWeekly`. Requests route around an account spent for their model family
without disabling that account for other families. Imported and newly signed-in
accounts retain their Anthropic account UUID, and the hub aligns a present
`metadata.user_id` account component with the selected account.

Two settings control routing. `switchThreshold` is the shared or requested
model-family quota fraction at which an account stops receiving matching
traffic and defaults to `0.98`.
`upstreamBaseUrl` defaults to `https://api.anthropic.com` and exists only for
tests and QA with a controlled fake upstream:

Expand Down
10 changes: 7 additions & 3 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ safely. Rotation keeps the prior token valid for ten minutes. Agents should use
`--api-key-stdin`, which reads exactly one non-empty key from piped standard
input. The compatibility form `--api-key <key>` exposes the key in process
arguments, shell history, and agent transcripts. Prefer `--import` when Claude
Code is already signed in. JSON account status reports rejected upstream
bucket resets under `bucketExhaustion`; this is diagnostic status and does not
affect selection.
Code is already signed in. OAuth quota refreshes on add or enable and every
five minutes while the account is idle. Account tables add columns for the
family buckets Anthropic reports, and JSON status exposes the same observations
under `familyWeekly`. Selection skips an account only for a spent requested
family while retaining it for other families. When Claude Code supplies an
account UUID in `metadata.user_id`, the hub aligns it with the selected OAuth
account.
The `upstreamBaseUrl` setting exists for tests and QA and defaults to
`https://api.anthropic.com`; `switchThreshold` defaults to `0.98`.

Expand Down
9 changes: 8 additions & 1 deletion plugins/account-pool/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function account(): AccountSummary {
kind: "oauth",
label: "Personal Claude",
email: "person@example.com",
accountUuid: null,
subscriptionType: "max",
rateLimitTier: "default_claude_max_5x",
enabled: true,
Expand All @@ -27,7 +28,13 @@ function account(): AccountSummary {
sevenDayResetAt: null,
sevenDayStatus: null,
representativeClaim: null,
bucketExhaustion: {},
familyWeekly: {
fable: null,
sonnet: null,
opus: null,
haiku: null,
other: null,
},
observedAt: 1,
heldUntil: null,
error: null,
Expand Down
44 changes: 43 additions & 1 deletion plugins/account-pool/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
loginCompleteInputSchema,
tokenRotateInputSchema,
type AccountSummary,
type FamilyQuota,
type ModelFamily,
type PoolStatus,
} from "./contracts.js";
import type { PoolOperations } from "./operations.js";
Expand Down Expand Up @@ -72,10 +74,47 @@ function formatUtilization(value: number | null): string {
return value === null ? "-" : `${Math.round(value * 100)}%`;
}

const MODEL_FAMILIES: ModelFamily[] = [
"fable",
"sonnet",
"opus",
"haiku",
"other",
];

function familyLabel(family: ModelFamily): string {
return family[0]?.toUpperCase() + family.slice(1);
}

function formatFamilyQuota(quota: FamilyQuota | null): string {
if (quota === null) return "-";
return [
formatUtilization(quota.utilization),
quota.status ?? "-",
formatReset(quota.resetAt),
quota.source,
].join(" ");
}

function formatAccounts(accounts: readonly AccountSummary[]): string {
if (accounts.length === 0) return "No accounts configured.";
const families = MODEL_FAMILIES.filter((family) =>
accounts.some((account) => account.familyWeekly[family] !== null),
);
return [
"ID\tLabel\tKind\tEnabled\tPriority\t5h\t5h reset\t7d\t7d reset\tStatus",
[
"ID",
"Label",
"Kind",
"Enabled",
"Priority",
"5h",
"5h reset",
"7d",
"7d reset",
...families.map(familyLabel),
"Status",
].join("\t"),
...accounts.map((account) =>
[
account.id,
Expand All @@ -87,6 +126,9 @@ function formatAccounts(accounts: readonly AccountSummary[]): string {
formatReset(account.fiveHourResetAt),
formatUtilization(account.sevenDayUtilization),
formatReset(account.sevenDayResetAt),
...families.map((family) =>
formatFamilyQuota(account.familyWeekly[family]),
),
account.status,
].join("\t"),
),
Expand Down
38 changes: 36 additions & 2 deletions plugins/account-pool/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@ import { z } from "zod";

export const providerSchema = z.literal("claude");
export const accountKindSchema = z.enum(["oauth", "api-key"]);
export const modelFamilySchema = z.enum([
"fable",
"sonnet",
"opus",
"haiku",
"other",
]);

export type ModelFamily = z.infer<typeof modelFamilySchema>;

export const familyQuotaSchema = z
.object({
utilization: z.number().nullable(),
resetAt: z.number().int().nullable(),
status: z.string().nullable(),
observedAt: z.number().int(),
source: z.enum(["header", "usage"]),
})
.strict();

export type FamilyQuota = z.infer<typeof familyQuotaSchema>;

export const familyWeeklySchema = z
.object({
fable: familyQuotaSchema.nullable(),
sonnet: familyQuotaSchema.nullable(),
opus: familyQuotaSchema.nullable(),
haiku: familyQuotaSchema.nullable(),
other: familyQuotaSchema.nullable(),
})
.strict();

export type FamilyWeekly = z.infer<typeof familyWeeklySchema>;

export const accountSchema = z
.object({
Expand All @@ -10,6 +43,7 @@ export const accountSchema = z
kind: accountKindSchema,
label: z.string().min(1),
email: z.string().email().nullable(),
accountUuid: z.string().uuid().nullable().default(null),
subscriptionType: z.string().nullable(),
rateLimitTier: z.string().nullable(),
enabled: z.boolean(),
Expand Down Expand Up @@ -53,7 +87,7 @@ export const quotaSchema = z
sevenDayResetAt: z.number().int().nullable(),
sevenDayStatus: z.string().nullable(),
representativeClaim: z.string().nullable(),
bucketExhaustion: z.record(z.string(), z.number().int()),
familyWeekly: familyWeeklySchema,
observedAt: z.number().int().nullable(),
heldUntil: z.number().int().nullable(),
error: z.string().nullable(),
Expand All @@ -70,7 +104,7 @@ export const accountSummarySchema = accountSchema.extend({
sevenDayResetAt: z.number().int().nullable(),
sevenDayStatus: z.string().nullable(),
representativeClaim: z.string().nullable(),
bucketExhaustion: z.record(z.string(), z.number().int()),
familyWeekly: familyWeeklySchema,
observedAt: z.number().int().nullable(),
heldUntil: z.number().int().nullable(),
error: z.string().nullable(),
Expand Down
24 changes: 17 additions & 7 deletions plugins/account-pool/src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const credentialsFileSchema = z

const accountFileSchema = z.object({
oauthAccount: z
.object({ emailAddress: z.string().email().nullish() })
.object({
emailAddress: z.string().email().nullish(),
accountUuid: z.string().uuid().nullish(),
})
.nullish(),
});

Expand All @@ -35,11 +38,12 @@ export interface ImportedClaudeCredentials {
subscriptionType: string | null;
rateLimitTier: string | null;
email: string | null;
accountUuid: string | null;
}

function parseCredentials(
raw: string,
): Omit<ImportedClaudeCredentials, "email"> | null {
): Omit<ImportedClaudeCredentials, "email" | "accountUuid"> | null {
const trimmed = raw.trim();
const candidates = [trimmed];
if (/^(?:[0-9a-f]{2})+$/iu.test(trimmed)) {
Expand Down Expand Up @@ -77,17 +81,23 @@ async function readKeychainCredentials(): Promise<string | null> {
return null;
}

async function readAccountEmail(): Promise<string | null> {
async function readAccountIdentity(): Promise<{
email: string | null;
accountUuid: string | null;
}> {
try {
const value = JSON.parse(
await fs.readFile(path.join(os.homedir(), ".claude.json"), "utf8"),
);
const parsed = accountFileSchema.safeParse(value);
return parsed.success
? (parsed.data.oauthAccount?.emailAddress ?? null)
: null;
? {
email: parsed.data.oauthAccount?.emailAddress ?? null,
accountUuid: parsed.data.oauthAccount?.accountUuid ?? null,
}
: { email: null, accountUuid: null };
} catch {
return null;
return { email: null, accountUuid: null };
}
}

Expand All @@ -109,5 +119,5 @@ export async function importClaudeCredentials(): Promise<ImportedClaudeCredentia
"Claude Code OAuth credentials were not found. Run `claude /login` on the bb server host, then retry.",
);
}
return { ...credentials, email: await readAccountEmail() };
return { ...credentials, ...(await readAccountIdentity()) };
}
Loading
Loading