@askalf/dario 6.0.38 → 6.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@
11
11
  * system surfaces as `fail` instead of crashing the CLI.
12
12
  */
13
13
  import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
14
+ import { maskEmail } from './pool.js';
14
15
  import { readFileSync } from 'node:fs';
15
16
  import { join, dirname } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
@@ -221,7 +222,89 @@ export function checkOrganizations(input) {
221
222
  return [{
222
223
  status: 'info',
223
224
  label: 'Organizations',
224
- detail: `${head} — ${pairs}. Seats on one organization may be one subscription counted twice: \`sharesWindowWith\` on GET /accounts says so when they report the same window`,
225
+ detail: `${head} — ${pairs}. Seats on one organization are not one subscription: the Accounts row says which seats are the same account, from each token's OAuth profile.`,
226
+ }];
227
+ }
228
+ /**
229
+ * The Accounts doctor row (dario#1244, #1263): how many distinct Anthropic
230
+ * accounts the pool's seats are, from the OAuth account uuid each record
231
+ * carries — the fact, not an inference from reset seconds. Two seats with one
232
+ * uuid ARE one subscription under two aliases; a seat without one is not yet
233
+ * identified (its next token refresh fills it in) and is never guessed at.
234
+ */
235
+ export function checkAccountIdentity(input) {
236
+ if (input.accounts.length < 2)
237
+ return [];
238
+ const byId = new Map();
239
+ let unidentified = 0;
240
+ for (const a of input.accounts) {
241
+ if (!a.accountId) {
242
+ unidentified++;
243
+ continue;
244
+ }
245
+ const g = byId.get(a.accountId);
246
+ if (g) {
247
+ g.aliases.push(a.alias);
248
+ if (!g.email && a.accountEmail)
249
+ g.email = a.accountEmail;
250
+ }
251
+ else
252
+ byId.set(a.accountId, { aliases: [a.alias], email: a.accountEmail });
253
+ }
254
+ const distinct = byId.size + unidentified;
255
+ const head = `${input.accounts.length} seats, ${distinct} distinct account${distinct === 1 ? '' : 's'}`
256
+ + (unidentified > 0 ? ` (${unidentified} not yet identified — filled in by the next token refresh)` : '');
257
+ const dups = [...byId.values()].filter((g) => g.aliases.length > 1);
258
+ if (dups.length === 0) {
259
+ return [{ status: 'ok', label: 'Accounts', detail: `${head} — no alias is a duplicate of another` }];
260
+ }
261
+ const named = dups.map((g) => `${g.aliases.join(' + ')} are the same account${g.email ? ` (${maskEmail(g.email)})` : ''}`).join('; ');
262
+ return [{
263
+ status: 'info',
264
+ label: 'Accounts',
265
+ detail: `${head} — ${named}. Duplicates share one window and one set of limits; the pool counts them once.`,
266
+ }];
267
+ }
268
+ /**
269
+ * The Client identity doctor row (dario#1244, 2026-09-08). Every `accounts add`
270
+ * used to copy the machine's Claude Code identity into the new alias, so a
271
+ * pool of colleagues' tokens on one machine presented ONE `device_id` /
272
+ * `account_uuid` in `metadata.user_id` across every OAuth account it held.
273
+ * Anthropic ties what it sees to that identity (see checkIdentityDrift).
274
+ * Seats that are genuinely the same account may share it; seats that are
275
+ * different accounts, or not yet identified, must not.
276
+ */
277
+ export function checkSharedClientIdentity(input) {
278
+ if (input.accounts.length < 2)
279
+ return [];
280
+ const byIdentity = new Map();
281
+ for (const a of input.accounts) {
282
+ if (!a.deviceId && !a.accountUuid)
283
+ continue;
284
+ const k = `${a.deviceId}|${a.accountUuid}`;
285
+ const list = byIdentity.get(k);
286
+ if (list)
287
+ list.push(a);
288
+ else
289
+ byIdentity.set(k, [a]);
290
+ }
291
+ const offending = [...byIdentity.entries()].filter(([, seats]) => {
292
+ if (seats.length < 2)
293
+ return false;
294
+ const ids = new Set(seats.map((s) => s.accountId));
295
+ return ids.size > 1 || ids.has(undefined);
296
+ });
297
+ if (offending.length === 0) {
298
+ return [{ status: 'ok', label: 'Client identity', detail: 'every seat presents its own client identity (or shares one only with the same account)' }];
299
+ }
300
+ const parts = offending.map(([k, seats]) => {
301
+ const accounts = new Set(seats.map((s) => s.accountId ?? `?${s.alias}`)).size;
302
+ return `${seats.length} seats present ONE client identity (device ${(k.split('|')[0] || '(empty)').slice(0, 8)}…) across ${accounts} account${accounts === 1 ? '' : 's'}: ${seats.map((s) => s.alias).join(', ')}`;
303
+ });
304
+ return [{
305
+ status: 'warn',
306
+ label: 'Client identity',
307
+ detail: `${parts.join('; ')}. Every alias added on a machine with Claude Code installed copied its identity, and Anthropic ties usage and limits to the identity it sees. Give each seat its own: \`dario accounts identity --fresh <alias>\` (or \`--all\`); the running proxy presents the new one on the seat's next request.`,
225
308
  }];
226
309
  }
227
310
  const REGRANT_FIX = " — re-grant with `dario accounts add <alias>` (or `dario login --force-reauth` for the login seat); the new grant restarts the clock";
@@ -811,8 +894,8 @@ export async function runChecks(opts = {}) {
811
894
  : '';
812
895
  checks.push({
813
896
  status: util >= 0.90 ? 'warn' : 'ok',
814
- label: `Usage 7d (${family} only)`,
815
- detail: `${pct(util)} used${marker}`,
897
+ label: family === 'oi' ? 'Included overage credit (7d, oi)' : `Usage 7d (${family} only)`,
898
+ detail: `${pct(util)} used${marker}${family === 'oi' ? " — the plan's included-overage credit; binds Fable's weekly allowance (#1262)" : ''}`,
816
899
  });
817
900
  }
818
901
  if (firstOk.overageUtil > 0) {
@@ -969,6 +1052,7 @@ export async function runChecks(opts = {}) {
969
1052
  });
970
1053
  checks.push(...checkRefreshGrant({ accounts: loaded.map((a) => ({ alias: a.alias, grantedAt: a.grantedAt })), now }));
971
1054
  checks.push(...checkOrganizations({ accounts: loaded.map((a) => ({ alias: a.alias, organizationId: a.organizationId })) }));
1055
+ checks.push(...checkAccountIdentity({ accounts: loaded.map((a) => ({ alias: a.alias, accountId: a.accountId, accountEmail: a.accountEmail })) }));
972
1056
  // Next-account-in-rotation surfacing. The proxy's per-request
973
1057
  // selector picks by max headroom (with 7d_<family> per-model
974
1058
  // bucket considered when a request's model family is known);
@@ -1047,6 +1131,10 @@ export async function runChecks(opts = {}) {
1047
1131
  });
1048
1132
  for (const c of driftChecks)
1049
1133
  checks.push(c);
1134
+ for (const c of checkSharedClientIdentity({
1135
+ accounts: loaded.map((a) => ({ alias: a.alias, deviceId: a.deviceId, accountUuid: a.accountUuid, accountId: a.accountId, identityFrom: a.identityFrom })),
1136
+ }))
1137
+ checks.push(c);
1050
1138
  }
1051
1139
  catch (err) {
1052
1140
  checks.push({ status: 'warn', label: 'Identity', detail: `check failed: ${err.message}` });
@@ -496,7 +496,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
496
496
  */
497
497
  export declare const SUPPORTED_CC_RANGE: {
498
498
  readonly min: "1.0.0";
499
- readonly maxTested: "2.1.263";
499
+ readonly maxTested: "2.1.265";
500
500
  };
501
501
  /**
502
502
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -1194,7 +1194,7 @@ export function detectDrift(t, installedOverride) {
1194
1194
  */
1195
1195
  export const SUPPORTED_CC_RANGE = {
1196
1196
  min: '1.0.0',
1197
- maxTested: '2.1.263',
1197
+ maxTested: '2.1.265',
1198
1198
  };
1199
1199
  /**
1200
1200
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/dist/pool.d.ts CHANGED
@@ -38,6 +38,34 @@ export interface RateLimitSnapshot {
38
38
  reset: number;
39
39
  fallbackPct: number;
40
40
  updatedAt: number;
41
+ /**
42
+ * `retry-after` on the response, in ms, or null/undefined when absent. Read
43
+ * for a 429 that names no exhausted window (see `exhausted`): it is the only
44
+ * duration the upstream actually stated for that case.
45
+ */
46
+ retryAfterMs?: number | null;
47
+ /**
48
+ * Set by `markRejected`. True when the 429 itself showed a window was over —
49
+ * a utilization at or past the 1.0 threshold on some window or bucket — so the
50
+ * seat is parked until `reset`. False when the 429 said no such thing
51
+ * (dario#1244 follow-up, 2026-09-08): a seat on the fleet box was parked for
52
+ * 546 HOURS on a 429 reading `5h 0%, 7d 0%, claim unknown`, because the
53
+ * status code alone used to decide and the stated reset was honoured
54
+ * blindly. Such a rejection cools for `retryAfterMs` (or a minute) and stays
55
+ * probeable. Undefined on snapshots that predate the field, which behave as
56
+ * before (exhausted).
57
+ */
58
+ exhausted?: boolean;
59
+ /** Epoch ms a non-exhausted rejection stops being ineligible. */
60
+ cooldownUntil?: number;
61
+ /**
62
+ * Which per-model buckets the upstream has shown to bind which model
63
+ * family on THIS account, learned from responses: `{ fable: ['oi'] }`. Merged
64
+ * across readings by the pool, so a binding learned on one response holds
65
+ * for the seat's later headroom decisions. See `WIRE_BUCKET_BINDINGS` for the
66
+ * static seed and the evidence.
67
+ */
68
+ boundBuckets?: Record<string, string[]>;
41
69
  }
42
70
  export declare const EMPTY_SNAPSHOT: RateLimitSnapshot;
43
71
  /** Freshness of an account's utilisation reading — see `utilFreshness`. */
@@ -94,25 +122,35 @@ export declare function rateLimitWindow(rl: RateLimitSnapshot, now: number): Rat
94
122
  */
95
123
  export declare function describeRateLimitSnapshot(rl: RateLimitSnapshot, now?: number): string;
96
124
  /**
97
- * The identity of the rate-limit window a reading was measured against:
98
- * its representative claim plus its reset second, or null when the reading
99
- * states no live window (no reset, a reset that has passed, or no claim).
125
+ * The log line for a 429 as `markRejected` classified it: an exhausted window
126
+ * parks the seat until its reset; anything else cools briefly and says which
127
+ * stated reset was NOT honoured, so the operator can see the reading dario
128
+ * declined to act on.
129
+ */
130
+ /** `th***@example.com` — enough to recognise an account by eye, never the whole address on a listing or a log. */
131
+ export declare function maskEmail(email: string | null | undefined): string | null;
132
+ export declare function describeRejection(rl: RateLimitSnapshot, now?: number): string;
133
+ /**
134
+ * For every seat, the other aliases that are the SAME Anthropic account —
135
+ * one subscription under several aliases (dario#1244).
100
136
  *
101
- * Two seats that report the same key are one subscription under two aliases
102
- * (dario#1244, "a few have the same issue"): two independent windows all but
103
- * never share a reset second, and two readings of one window always do. The
104
- * organization id is deliberately NOT part of the key — several seats can
105
- * share an organization and still have their own windows the window itself
106
- * is the fact that matters for headroom.
137
+ * Identity, not inference. The first version of this keyed seats on
138
+ * `claim@reset` "two independent windows all but never share a reset
139
+ * second". They do: Anthropic aligns the five-hour reset to a 20-minute grid
140
+ * (dario#1263 two demonstrably different accounts, both resetting at
141
+ * exactly :40:00), so a five-hour window has 15 possible reset seconds and a
142
+ * pool of 18 seats is GUARANTEED collisions. That heuristic told an operator
143
+ * seven independent colleagues were one subscription. `accountId` is the
144
+ * account uuid the OAuth grant belongs to (accounts.ts, `fetchOAuthProfile`);
145
+ * two seats with the same one are the same account, full stop, and two
146
+ * seats without one are simply not yet identified — never guessed.
107
147
  */
108
- export declare function windowKey(rl: RateLimitSnapshot, now: number): string | null;
109
- /** For every seat, the other aliases whose last reading names the same live window. */
110
- export declare function windowPeers(accounts: readonly PoolAccount[], now: number): Map<string, string[]>;
148
+ export declare function accountPeers(accounts: readonly PoolAccount[]): Map<string, string[]>;
111
149
  /**
112
- * How many windows the pool really has: each measured live window once, and
113
- * each seat without a live reading as its own (nothing says otherwise yet).
150
+ * How many accounts the pool really has: each identified account once, and
151
+ * each seat not yet identified as its own (nothing says otherwise).
114
152
  */
115
- export declare function distinctWindows(accounts: readonly PoolAccount[], now: number): number;
153
+ export declare function distinctAccounts(accounts: readonly PoolAccount[]): number;
116
154
  export interface PoolAccount {
117
155
  alias: string;
118
156
  accessToken: string;
@@ -131,6 +169,19 @@ export interface PoolAccount {
131
169
  rejectedCount: number;
132
170
  /** Epoch ms of the most recent 429 on this account; undefined if never. */
133
171
  lastRejectedAt?: number;
172
+ /**
173
+ * The Anthropic account uuid behind this seat's token, from the OAuth
174
+ * profile at grant time (accounts.ts). The one fact that says whether two
175
+ * aliases are one subscription (dario#1244, #1263). Undefined until the
176
+ * seat's record carries it — a record from before this field is filled in
177
+ * by its next token refresh.
178
+ */
179
+ accountId?: string;
180
+ /** Email on that account, for the operator's eye; masked on every listing. */
181
+ accountEmail?: string;
182
+ /** `organization.rate_limit_tier` / `seat_tier` from the same profile, when stated. */
183
+ rateLimitTier?: string;
184
+ seatTier?: string;
134
185
  /**
135
186
  * The Anthropic organization behind this seat's token, from the
136
187
  * `anthropic-organization-id` response header: learned on the first
@@ -238,6 +289,32 @@ export declare function isAccountEligible(account: PoolAccount, now?: number): b
238
289
  * back, so it stays probeable (dario#1244).
239
290
  */
240
291
  export declare function isParkedInLiveWindow(account: PoolAccount, now?: number): boolean;
292
+ /**
293
+ * A seat rejected by a 429 that named no exhausted window, still inside the
294
+ * cool-down that rejection earned (see `RateLimitSnapshot.exhausted`). It is
295
+ * deliberately NOT "parked in a live window" — the reset it stated was never
296
+ * this seat's window — but it is equally not askable yet: the upstream said
297
+ * retry after N, and asking sooner is the retry storm the cool-down exists to
298
+ * prevent (dario#1264 review).
299
+ */
300
+ export declare function isCoolingAfterRejection(account: PoolAccount, now?: number): boolean;
301
+ /**
302
+ * Whether the router may send this seat a request RIGHT NOW, for reasons that
303
+ * expire on their own: an auth cool-down, a live rate-limit window, or the
304
+ * cool-down a non-window 429 earned.
305
+ *
306
+ * ONE predicate, filtered on by both fallback paths (the all-exhausted branch
307
+ * in `select()` and the mid-flight `selectExcluding()`). They carried the
308
+ * condition inline and independently, so `isCoolingAfterRejection` was added to
309
+ * neither — a seat that had just answered `retry-after: 17` could be retried
310
+ * inside the same client request. Caught in review on #1264; the shape of the
311
+ * bug is why this is a named predicate rather than two copies of a filter.
312
+ *
313
+ * Note this is NOT `isAccountEligible`: eligibility also refuses an expired
314
+ * token, which no amount of waiting fixes and which these paths handle
315
+ * separately.
316
+ */
317
+ export declare function isProbeable(account: PoolAccount, now?: number): boolean;
241
318
  /**
242
319
  * The operator's next step for one seat, next to `status` on both listings
243
320
  * (dario#1244 — "do I have to re-login?" should not need the docs table).
@@ -280,8 +357,50 @@ export type PoolStrategy = 'headroom' | 'fill-first';
280
357
  * in this codebase (see resolveSessionRotationConfig).
281
358
  */
282
359
  export declare function resolvePoolStrategy(explicit?: string | null, env?: NodeJS.ProcessEnv): PoolStrategy;
360
+ /**
361
+ * Which wire buckets bind which model family, when the wire does not say it
362
+ * by name. `7d_sonnet` names its family; `7d_oi` does not — it is the plan's
363
+ * INCLUDED-OVERAGE credit, and it is what Fable's weekly allowance is metered
364
+ * on (dario#1262):
365
+ *
366
+ * - 2026-07-05, live Max account: Fable drew `representative-claim:
367
+ * seven_day_overage_included` at 7d 82% with `7d_oi` at 99%, served at $0;
368
+ * at `7d_oi` ≥ 1.0 Fable answered a hard 429 (`7d_oi-status: rejected`,
369
+ * `7d_oi-surpassed-threshold: 1.0`) while Opus kept serving.
370
+ * - 2026-09-08 (#1262): a Fable response at `7d 0.63, 7d_oi 0.98` — headroom
371
+ * read 0.37 against a seat two points from refusal.
372
+ *
373
+ * So for Fable, `oi` IS the binding weekly bucket, under a name that is not
374
+ * "fable". This seed makes that true from the first response. Bindings for
375
+ * other families are LEARNED per account from the wire (see
376
+ * `parseRateLimits`): a response whose claim is `*_overage_included`, or a 429
377
+ * whose `7d_<bucket>-status` is `rejected`, proves that bucket binds the family
378
+ * that request was for. Nothing here maps a family to `oi` by assumption:
379
+ * a seat drawing on included overage for Opus is bound by `oi` for Opus
380
+ * exactly when its responses say so.
381
+ */
382
+ export declare const WIRE_BUCKET_BINDINGS: Readonly<Record<string, readonly string[]>>;
383
+ /** Parse `retry-after`: delta-seconds or an HTTP date; null when absent or unreadable. */
384
+ export declare function parseRetryAfterMs(value: string | null, now?: number): number | null;
385
+ /** Union of two readings' learned bindings, per family, order-preserving. */
386
+ export declare function mergeBoundBuckets(prev: Record<string, string[]> | undefined, next: Record<string, string[]> | undefined): Record<string, string[]> | undefined;
387
+ /** `next` with everything the account had already learned carried forward. */
388
+ export declare function withBoundBuckets(prev: RateLimitSnapshot, next: RateLimitSnapshot): RateLimitSnapshot;
283
389
  /** Parse an Anthropic response's rate-limit headers into a snapshot. */
284
- export declare function parseRateLimits(headers: Headers): RateLimitSnapshot;
390
+ export declare function parseRateLimits(headers: Headers, family?: string | null): RateLimitSnapshot;
391
+ /**
392
+ * Does this 429 say a rate-limit WINDOW is over? True only when the reading
393
+ * itself shows one: a utilization at or past the 1.0 threshold on any window
394
+ * or bucket (the unified headers are a ratio against
395
+ * `surpassed-threshold: 1.0`, so `1.02` is 102%; every live rejection observed
396
+ * has read 1.00–1.06). A 429 with a claim but 30% used, or with no claim and
397
+ * 0% used, is a refusal of some other kind — concurrency, an account-level
398
+ * lock, a monthly credit — and its stated `reset` is not the moment this
399
+ * seat's window rolls. 0.99 rather than 1 absorbs float formatting.
400
+ */
401
+ export declare function isWindowRejection(rl: RateLimitSnapshot): boolean;
402
+ /** Cool-down for a 429 that named no exhausted window, when it stated no `retry-after`. */
403
+ export declare const NON_WINDOW_REJECTION_COOLDOWN_MS = 60000;
285
404
  /**
286
405
  * Extract the model family (`opus` / `sonnet` / `haiku` / `fable`) from a
287
406
  * request's model id. Used to look up the per-model 7d bucket in
@@ -307,8 +426,14 @@ export declare function modelFamily(modelId: string | null | undefined): string
307
426
  * isn't represented in the snapshot (e.g. account hasn't seen a Sonnet
308
427
  * request yet so `7d_sonnet` is unknown), headroom is computed from the
309
428
  * unified buckets only — best-effort, populated on the next response.
429
+ *
430
+ * A bucket counts for a family when it names the family (`7d_sonnet`), when
431
+ * `WIRE_BUCKET_BINDINGS` says it binds the family (`7d_oi` → fable, dario#1262),
432
+ * or when this account's responses have shown it does (`boundBuckets`).
310
433
  */
311
434
  export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: string | null): number;
435
+ /** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
436
+ export declare function bucketsBindingFamily(snapshot: RateLimitSnapshot, family: string): string[];
312
437
  export declare class AccountPool {
313
438
  private readonly strategy;
314
439
  private accounts;
@@ -327,6 +452,10 @@ export declare class AccountPool {
327
452
  accountUuid: string;
328
453
  grantedAt?: number;
329
454
  organizationId?: string;
455
+ accountId?: string;
456
+ accountEmail?: string;
457
+ rateLimitTier?: string;
458
+ seatTier?: string;
330
459
  }): void;
331
460
  remove(alias: string): boolean;
332
461
  get size(): number;
@@ -367,7 +496,7 @@ export declare class AccountPool {
367
496
  * review on dario#1254 that caught the mixed case).
368
497
  */
369
498
  parkedUntil(now?: number): number | null;
370
- /** Seats currently parked inside a live window (dario#1244). */
499
+ /** Seats a rate limit is currently keeping out of rotation (dario#1244, #1264). */
371
500
  parkedCount(now?: number): number;
372
501
  /**
373
502
  * Select with session stickiness. If `stickyKey` is already bound to a