@askalf/dario 6.0.33 → 6.0.34

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.
package/dist/cli.js CHANGED
@@ -444,6 +444,11 @@ async function proxy() {
444
444
  ?? parsePositiveIntEnv(process.env['DARIO_MAX_QUEUED']);
445
445
  const queueTimeoutMs = parsePositiveIntFlag('--queue-timeout=')
446
446
  ?? parsePositiveIntEnv(process.env['DARIO_QUEUE_TIMEOUT_MS']);
447
+ // --max-concurrent-per-consumer=N — a per-consumer in-flight ceiling keyed
448
+ // by the x-dario-consumer header, for a proxy shared by a team: one heavy
449
+ // user waits at the cap while everyone else keeps flowing. 0 = off.
450
+ const maxConcurrentPerConsumer = parsePositiveIntFlag('--max-concurrent-per-consumer=')
451
+ ?? parsePositiveIntEnv(process.env['DARIO_MAX_CONCURRENT_PER_CONSUMER']);
447
452
  // --pool-strategy=headroom|fill-first — where UNBOUND (new) conversations
448
453
  // land. `headroom` (default) spreads them to the seat with the most slack;
449
454
  // `fill-first` concentrates them on the alphabetically-first eligible seat
@@ -459,6 +464,13 @@ async function proxy() {
459
464
  const poolStrategy = poolStrategyFromFlag
460
465
  ?? process.env['DARIO_POOL_STRATEGY']
461
466
  ?? fileCfg.pool?.strategy;
467
+ // --pool-shared-state — share rate-limit readings and sticky bindings with
468
+ // the other instances through the refresh-lock service (docs/multi-instance.md).
469
+ const poolSharedState = args.includes('--pool-shared-state')
470
+ || process.env['DARIO_POOL_SHARED_STATE'] === '1'
471
+ || process.env['DARIO_POOL_SHARED_STATE'] === 'true';
472
+ const poolSharedStateIntervalMs = parsePositiveIntFlag('--pool-shared-state-interval=')
473
+ ?? parsePositiveIntEnv(process.env['DARIO_POOL_SHARED_STATE_INTERVAL_MS']);
462
474
  // --effort=low|medium|high|xhigh|ultracode|max|client — pin the outbound
463
475
  // output_config.effort (dario#87). Default (unset) forwards the client's
464
476
  // own effort — it's a user knob, real CC wires whatever the user tuned —
@@ -635,7 +647,7 @@ async function proxy() {
635
647
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
636
648
  process.exit(1);
637
649
  }
638
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, poolStrategy, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
650
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
639
651
  }
640
652
  /**
641
653
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -882,8 +894,72 @@ function parsePositiveIntFlag(prefix) {
882
894
  }
883
895
  return n;
884
896
  }
897
+ /**
898
+ * `dario accounts list --live` — the running proxy's view of the pool
899
+ * (dario#1244): status with its countdown, the reading and its age, 429s
900
+ * answered, the organization, and which seats share a window. The on-disk
901
+ * listing knows none of that. Returns false when no proxy answered, so the
902
+ * caller falls back to the on-disk listing.
903
+ */
904
+ async function accountsListLive() {
905
+ const { loadConfig } = await import('./config-file.js');
906
+ const fileCfg = loadConfig().config;
907
+ const portArg = args.find(a => a.startsWith('--port='));
908
+ const port = (portArg ? parseInt(portArg.split('=')[1], 10) : undefined)
909
+ ?? (process.env['DARIO_PORT'] ? parseInt(process.env['DARIO_PORT'], 10) : undefined)
910
+ ?? fileCfg.port ?? 3456;
911
+ const headers = {};
912
+ if (process.env['DARIO_API_KEY'])
913
+ headers['x-api-key'] = process.env['DARIO_API_KEY'];
914
+ let payload = null;
915
+ try {
916
+ const res = await fetch(`http://127.0.0.1:${port}/accounts`, { headers, signal: AbortSignal.timeout(3000) });
917
+ if (res.ok)
918
+ payload = await res.json();
919
+ else
920
+ console.log(` (proxy on http://127.0.0.1:${port} answered ${res.status} to /accounts — showing the on-disk listing)`);
921
+ }
922
+ catch (err) {
923
+ console.log(` (no proxy on http://127.0.0.1:${port}: ${err instanceof Error ? err.message : String(err)} — showing the on-disk listing)`);
924
+ }
925
+ if (!payload || payload.mode !== 'pool' || !Array.isArray(payload.accounts))
926
+ return false;
927
+ const seats = payload.accounts;
928
+ const now = Date.now();
929
+ const pct = (n) => `${Math.round(n * 100)}%`;
930
+ const mins = (ms) => {
931
+ const m = Math.max(1, Math.round(ms / 60_000));
932
+ return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`;
933
+ };
934
+ const age = (ms) => ms === null ? 'never measured' : ms < 60_000 ? `read ${Math.round(ms / 1000)}s ago` : `read ${mins(ms)} ago`;
935
+ console.log('');
936
+ console.log(` dario — Accounts (live, from http://127.0.0.1:${port})`);
937
+ console.log(' ────────────────');
938
+ console.log('');
939
+ const windows = payload.distinctWindows ?? seats.length;
940
+ console.log(` Pool of ${seats.length} (${seats.length === 1 ? '1 seat' : seats.length + ' seats'} on ${windows} distinct window${windows === 1 ? '' : 's'})`);
941
+ console.log('');
942
+ for (const s of seats) {
943
+ const status = s.status === 'rejected' && typeof s.resetInMs === 'number' ? `rejected, back in ${mins(s.resetInMs)}` : s.status;
944
+ console.log(` ${s.alias.padEnd(20)} ${status.padEnd(26)} 5h ${pct(s.util5h).padEnd(6)} 7d ${pct(s.util7d).padEnd(6)} ${age(s.utilAgeMs)}`);
945
+ const facts = [
946
+ `served ${s.requestCount}`,
947
+ `429s ${s.rejectedCount}`,
948
+ s.organizationId ? `org ${s.organizationId.slice(0, 8)}…` : 'org not yet observed',
949
+ ...(s.sharesWindowWith.length > 0 ? [`shares its window with ${s.sharesWindowWith.join(', ')}`] : []),
950
+ ];
951
+ console.log(` ${''.padEnd(20)} ${facts.join(' · ')}`);
952
+ console.log(` ${''.padEnd(20)} ${describeGrantAge(grantAge(s.grantedAt ?? undefined, now))}`);
953
+ }
954
+ console.log('');
955
+ return true;
956
+ }
885
957
  async function accounts() {
886
958
  const sub = args[1];
959
+ if ((!sub || sub === 'list') && args.includes('--live')) {
960
+ if (await accountsListLive())
961
+ return;
962
+ }
887
963
  if (!sub || sub === 'list') {
888
964
  const aliases = await listAccountAliases();
889
965
  console.log('');
@@ -1372,6 +1448,8 @@ async function help() {
1372
1448
  POSTs /admin/resume on the local proxy. (dario#288)
1373
1449
  dario logout Remove saved credentials
1374
1450
  dario accounts list List accounts in the multi-account pool
1451
+ (--live: the running proxy's view — status,
1452
+ window, 429s, organization, shared windows)
1375
1453
  dario accounts add NAME [--manual] [--from-keychain[=<target>]]
1376
1454
  Add a new account to the pool (runs OAuth flow).
1377
1455
  --manual (alias: --headless) prints an authorize
@@ -1629,6 +1707,21 @@ async function help() {
1629
1707
  concurrency slot before dario returns
1630
1708
  429 "queue-full" (default: 128).
1631
1709
  Env: DARIO_MAX_QUEUED. (dario#80)
1710
+ --max-concurrent-per-consumer=N
1711
+ Max in-flight requests per consumer, keyed
1712
+ by the x-dario-consumer request header; a
1713
+ consumer at the cap waits while others keep
1714
+ flowing (default: 0 = off).
1715
+ Env: DARIO_MAX_CONCURRENT_PER_CONSUMER.
1716
+ --pool-shared-state Share rate-limit readings and sticky bindings
1717
+ with the other dario instances through the
1718
+ refresh-lock service (needs
1719
+ DARIO_REFRESH_LOCK_URL / _TOKEN). Fails open.
1720
+ Env: DARIO_POOL_SHARED_STATE=1.
1721
+ --pool-shared-state-interval=MS
1722
+ How often to pull peers' readings
1723
+ (default: 2000). Env:
1724
+ DARIO_POOL_SHARED_STATE_INTERVAL_MS.
1632
1725
  --queue-timeout=MS Max ms a queued request waits before
1633
1726
  dario returns 504 "queue-timeout"
1634
1727
  (default: 60000).
@@ -141,6 +141,21 @@ export declare function checkRefreshGrant(input: {
141
141
  now: number;
142
142
  thresholds?: GrantThresholds;
143
143
  }): Check[];
144
+ export interface OrganizationsInput {
145
+ accounts: Array<{
146
+ alias: string;
147
+ organizationId?: string;
148
+ }>;
149
+ }
150
+ /**
151
+ * The Organizations doctor row (dario#1244): which seats sit on which
152
+ * Anthropic organization, from the id each seat's responses carried. Two
153
+ * seats on one organization MAY be one subscription counted twice — the
154
+ * proxy's `/accounts` says for sure via `sharesWindowWith`, because the
155
+ * window, not the organization, is what two seats can share. Nothing to say
156
+ * until at least two seats have been observed.
157
+ */
158
+ export declare function checkOrganizations(input: OrganizationsInput): Check[];
144
159
  export declare function checkIdentityDrift(input: IdentityDriftInput): Check[];
145
160
  export declare function probeNpmLatestCC(): string | null;
146
161
  /**
@@ -191,6 +191,39 @@ export function checkRefreshGrant(input) {
191
191
  const fix = worst === 'ok' ? '' : REGRANT_FIX;
192
192
  return [{ status, label: 'Refresh grant', detail: `${perSeat}${fix}` }];
193
193
  }
194
+ /**
195
+ * The Organizations doctor row (dario#1244): which seats sit on which
196
+ * Anthropic organization, from the id each seat's responses carried. Two
197
+ * seats on one organization MAY be one subscription counted twice — the
198
+ * proxy's `/accounts` says for sure via `sharesWindowWith`, because the
199
+ * window, not the organization, is what two seats can share. Nothing to say
200
+ * until at least two seats have been observed.
201
+ */
202
+ export function checkOrganizations(input) {
203
+ const seen = input.accounts.filter((a) => typeof a.organizationId === 'string' && a.organizationId.length > 0);
204
+ if (seen.length < 2)
205
+ return [];
206
+ const byOrg = new Map();
207
+ for (const a of seen) {
208
+ const list = byOrg.get(a.organizationId);
209
+ if (list)
210
+ list.push(a.alias);
211
+ else
212
+ byOrg.set(a.organizationId, [a.alias]);
213
+ }
214
+ const shared = [...byOrg.entries()].filter(([, aliases]) => aliases.length > 1);
215
+ const unseen = input.accounts.length - seen.length;
216
+ const head = `${seen.length} seat${seen.length === 1 ? '' : 's'} on ${byOrg.size} organization${byOrg.size === 1 ? '' : 's'}` + (unseen > 0 ? ` (${unseen} not yet observed)` : '');
217
+ if (shared.length === 0) {
218
+ return [{ status: 'ok', label: 'Organizations', detail: `${head} — every observed seat is on its own organization` }];
219
+ }
220
+ const pairs = shared.map(([org, aliases]) => `${aliases.join(' + ')} share ${org.slice(0, 8)}…`).join('; ');
221
+ return [{
222
+ status: 'info',
223
+ 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
+ }];
226
+ }
194
227
  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";
195
228
  export function checkIdentityDrift(input) {
196
229
  const { live, poolAccounts } = input;
@@ -935,6 +968,7 @@ export async function runChecks(opts = {}) {
935
968
  (aliases.length === 1 ? ' (a pool of one — `dario accounts add <alias>` to load-balance)' : ''),
936
969
  });
937
970
  checks.push(...checkRefreshGrant({ accounts: loaded.map((a) => ({ alias: a.alias, grantedAt: a.grantedAt })), now }));
971
+ checks.push(...checkOrganizations({ accounts: loaded.map((a) => ({ alias: a.alias, organizationId: a.organizationId })) }));
938
972
  // Next-account-in-rotation surfacing. The proxy's per-request
939
973
  // selector picks by max headroom (with 7d_<family> per-model
940
974
  // bucket considered when a request's model family is known);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared pool state across dario instances — the two things
3
+ * docs/multi-instance.md called "not solved": rate-limit accounting and
4
+ * session stickiness both lived in one process's memory, so two replicas
5
+ * behind one Service each believed a seat had full headroom, each ate its own
6
+ * 429 to learn otherwise, and a conversation re-cached its prefix on a
7
+ * different seat per replica.
8
+ *
9
+ * This rides the refresh-lock service (dario#993) — the coordination point
10
+ * two instances already share, reached through `DARIO_REFRESH_LOCK_URL` /
11
+ * `DARIO_REFRESH_LOCK_TOKEN` — under three more endpoints, implemented by
12
+ * both reference backends (redis-lock/, cloudflare/refresh-lock/):
13
+ *
14
+ * POST /pool/seat/<alias> { instance, at, snapshot, rejected } → { ok }
15
+ * POST /pool/seats { instance } → { seats: { alias: SharedSeat } }
16
+ * POST /pool/sticky/<key>/bind { alias, ttlMs } → { ok }
17
+ * POST /pool/sticky/<key>/get {} → { alias | null }
18
+ *
19
+ * An instance reports every reading it takes itself, pulls the others'
20
+ * readings on an interval and adopts any that is newer than its own, and
21
+ * consults the shared sticky bindings before binding a conversation locally.
22
+ * Everything fails open: with the service unreachable an instance behaves
23
+ * exactly as it does with the feature off, and says so once per outage.
24
+ *
25
+ * What crosses the wire is rate-limit snapshots and sticky-key → alias
26
+ * bindings. No token, no message content: the sticky key is a hash of the
27
+ * first user message, the same one the proxy already keeps in memory.
28
+ */
29
+ import type { AccountPool, RateLimitSnapshot } from './pool.js';
30
+ /** One instance's last reading of one seat, as the service stores it. */
31
+ export interface SharedSeat {
32
+ instance: string;
33
+ /** Epoch ms the reading was taken (the snapshot's own `updatedAt`). */
34
+ at: number;
35
+ snapshot: RateLimitSnapshot;
36
+ /** Whether that instance holds the seat parked on this reading. */
37
+ rejected: boolean;
38
+ }
39
+ export interface PoolSyncStatus {
40
+ enabled: true;
41
+ instance: string;
42
+ intervalMs: number;
43
+ lastPullAt: number | null;
44
+ lastOkAt: number | null;
45
+ /** Readings taken from peers, cumulative. */
46
+ adopted: number;
47
+ /** Own readings pushed, cumulative. */
48
+ reported: number;
49
+ stickyPushed: number;
50
+ stickyAdopted: number;
51
+ errors: number;
52
+ lastError: string | null;
53
+ }
54
+ export interface PoolSyncOptions {
55
+ baseUrl: string;
56
+ token: string;
57
+ /** Identity of this instance in the shared state; random when omitted. */
58
+ instance?: string;
59
+ /** Pull interval, ms. Default 2000. */
60
+ intervalMs?: number;
61
+ /** Sticky binding lifetime on the service, ms. Default 6h (the local TTL). */
62
+ stickyTtlMs?: number;
63
+ log?: (line: string) => void;
64
+ }
65
+ export declare const DEFAULT_POOL_SYNC_INTERVAL_MS = 2000;
66
+ /** A peer reading older than this describes windows that have long rolled. */
67
+ export declare const SHARED_SEAT_MAX_AGE_MS: number;
68
+ /**
69
+ * Whether a peer's reading should replace ours: a different instance, a
70
+ * complete record, not stale, and strictly newer than what we hold. A
71
+ * reading we adopted carries the peer's `at` as its `updatedAt`, so the
72
+ * same record is never adopted twice, and our own later reading (newer
73
+ * `updatedAt`) wins until a peer reports something newer still.
74
+ */
75
+ export declare function shouldAdopt(local: RateLimitSnapshot, remote: SharedSeat, me: string, now: number): boolean;
76
+ export declare class PoolSync {
77
+ private readonly pool;
78
+ readonly instance: string;
79
+ readonly intervalMs: number;
80
+ private readonly stickyTtlMs;
81
+ private readonly baseUrl;
82
+ private readonly token;
83
+ private readonly log;
84
+ private timer;
85
+ private reporting;
86
+ private dirty;
87
+ private down;
88
+ private stats;
89
+ constructor(pool: AccountPool, opts: PoolSyncOptions);
90
+ /** Start pulling peers' readings on the interval. The timer never keeps the process alive. */
91
+ start(): void;
92
+ stop(): void;
93
+ status(): PoolSyncStatus;
94
+ /**
95
+ * Push our reading of `alias`. Fire-and-forget and coalesced: while one
96
+ * push for the alias is in flight, further calls mark it dirty and one
97
+ * more push follows with whatever the seat reads then — the latest reading
98
+ * is the only one worth having.
99
+ */
100
+ reportSeat(alias: string): void;
101
+ /** Pull every peer's readings and adopt the newer ones. Returns how many were adopted. */
102
+ pullOnce(): Promise<number>;
103
+ /** The alias a peer bound this conversation to, or null. */
104
+ lookupSticky(key: string): Promise<string | null>;
105
+ /** Publish a binding so a peer that sees the conversation next lands on the same seat. A null key (no conversation) is a no-op. */
106
+ bindSticky(key: string | null, alias: string): void;
107
+ /**
108
+ * One POST to the service. Null on any failure — the caller carries on
109
+ * with local state, which is the whole contract: an outage of the
110
+ * coordination point must never stop the proxy serving. Logged once per
111
+ * transition into and out of the failed state, not per call.
112
+ */
113
+ private call;
114
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Shared pool state across dario instances — the two things
3
+ * docs/multi-instance.md called "not solved": rate-limit accounting and
4
+ * session stickiness both lived in one process's memory, so two replicas
5
+ * behind one Service each believed a seat had full headroom, each ate its own
6
+ * 429 to learn otherwise, and a conversation re-cached its prefix on a
7
+ * different seat per replica.
8
+ *
9
+ * This rides the refresh-lock service (dario#993) — the coordination point
10
+ * two instances already share, reached through `DARIO_REFRESH_LOCK_URL` /
11
+ * `DARIO_REFRESH_LOCK_TOKEN` — under three more endpoints, implemented by
12
+ * both reference backends (redis-lock/, cloudflare/refresh-lock/):
13
+ *
14
+ * POST /pool/seat/<alias> { instance, at, snapshot, rejected } → { ok }
15
+ * POST /pool/seats { instance } → { seats: { alias: SharedSeat } }
16
+ * POST /pool/sticky/<key>/bind { alias, ttlMs } → { ok }
17
+ * POST /pool/sticky/<key>/get {} → { alias | null }
18
+ *
19
+ * An instance reports every reading it takes itself, pulls the others'
20
+ * readings on an interval and adopts any that is newer than its own, and
21
+ * consults the shared sticky bindings before binding a conversation locally.
22
+ * Everything fails open: with the service unreachable an instance behaves
23
+ * exactly as it does with the feature off, and says so once per outage.
24
+ *
25
+ * What crosses the wire is rate-limit snapshots and sticky-key → alias
26
+ * bindings. No token, no message content: the sticky key is a hash of the
27
+ * first user message, the same one the proxy already keeps in memory.
28
+ */
29
+ import { randomUUID } from 'node:crypto';
30
+ import { describeRateLimitSnapshot, rateLimitWindowPassed } from './pool.js';
31
+ export const DEFAULT_POOL_SYNC_INTERVAL_MS = 2_000;
32
+ /** A peer reading older than this describes windows that have long rolled. */
33
+ export const SHARED_SEAT_MAX_AGE_MS = 6 * 3_600_000;
34
+ const CALL_TIMEOUT_MS = 3_000;
35
+ /**
36
+ * Whether a peer's reading should replace ours: a different instance, a
37
+ * complete record, not stale, and strictly newer than what we hold. A
38
+ * reading we adopted carries the peer's `at` as its `updatedAt`, so the
39
+ * same record is never adopted twice, and our own later reading (newer
40
+ * `updatedAt`) wins until a peer reports something newer still.
41
+ */
42
+ export function shouldAdopt(local, remote, me, now) {
43
+ if (!remote || remote.instance === me)
44
+ return false;
45
+ if (!remote.snapshot || typeof remote.at !== 'number' || !(remote.at > 0))
46
+ return false;
47
+ if (now - remote.at > SHARED_SEAT_MAX_AGE_MS)
48
+ return false;
49
+ return remote.at > (local.updatedAt || 0);
50
+ }
51
+ export class PoolSync {
52
+ pool;
53
+ instance;
54
+ intervalMs;
55
+ stickyTtlMs;
56
+ baseUrl;
57
+ token;
58
+ log;
59
+ timer = null;
60
+ reporting = new Map();
61
+ dirty = new Set();
62
+ down = false;
63
+ stats = { lastPullAt: null, lastOkAt: null, adopted: 0, reported: 0, stickyPushed: 0, stickyAdopted: 0, errors: 0, lastError: null };
64
+ constructor(pool, opts) {
65
+ this.pool = pool;
66
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
67
+ this.token = opts.token;
68
+ this.instance = opts.instance ?? randomUUID();
69
+ this.intervalMs = Math.max(200, opts.intervalMs ?? DEFAULT_POOL_SYNC_INTERVAL_MS);
70
+ this.stickyTtlMs = opts.stickyTtlMs ?? 6 * 3_600_000;
71
+ this.log = opts.log ?? ((line) => console.error(line));
72
+ }
73
+ /** Start pulling peers' readings on the interval. The timer never keeps the process alive. */
74
+ start() {
75
+ if (this.timer)
76
+ return;
77
+ this.timer = setInterval(() => { void this.pullOnce(); }, this.intervalMs);
78
+ this.timer.unref?.();
79
+ }
80
+ stop() {
81
+ if (this.timer)
82
+ clearInterval(this.timer);
83
+ this.timer = null;
84
+ }
85
+ status() {
86
+ return { enabled: true, instance: this.instance, intervalMs: this.intervalMs, ...this.stats };
87
+ }
88
+ /**
89
+ * Push our reading of `alias`. Fire-and-forget and coalesced: while one
90
+ * push for the alias is in flight, further calls mark it dirty and one
91
+ * more push follows with whatever the seat reads then — the latest reading
92
+ * is the only one worth having.
93
+ */
94
+ reportSeat(alias) {
95
+ if (this.reporting.has(alias)) {
96
+ this.dirty.add(alias);
97
+ return;
98
+ }
99
+ const run = async () => {
100
+ const seat = this.pool.get(alias);
101
+ if (!seat)
102
+ return;
103
+ const body = {
104
+ instance: this.instance,
105
+ at: seat.rateLimit.updatedAt || Date.now(),
106
+ snapshot: seat.rateLimit,
107
+ rejected: seat.rateLimit.status === 'rejected',
108
+ };
109
+ const res = await this.call(`/pool/seat/${encodeURIComponent(alias)}`, body);
110
+ if (res)
111
+ this.stats.reported++;
112
+ };
113
+ const p = run().finally(() => {
114
+ this.reporting.delete(alias);
115
+ if (this.dirty.delete(alias))
116
+ this.reportSeat(alias);
117
+ });
118
+ this.reporting.set(alias, p);
119
+ }
120
+ /** Pull every peer's readings and adopt the newer ones. Returns how many were adopted. */
121
+ async pullOnce() {
122
+ const now = Date.now();
123
+ this.stats.lastPullAt = now;
124
+ const res = await this.call('/pool/seats', { instance: this.instance });
125
+ if (!res || !res.seats || typeof res.seats !== 'object')
126
+ return 0;
127
+ let adopted = 0;
128
+ for (const [alias, remote] of Object.entries(res.seats)) {
129
+ const seat = this.pool.get(alias);
130
+ if (!seat)
131
+ continue;
132
+ if (!shouldAdopt(seat.rateLimit, remote, this.instance, now))
133
+ continue;
134
+ const wasParked = seat.rateLimit.status === 'rejected' && !rateLimitWindowPassed(seat.rateLimit, now);
135
+ if (!this.pool.adoptSnapshot(alias, remote.snapshot, remote.rejected, remote.instance))
136
+ continue;
137
+ adopted++;
138
+ // Same event the proxy logs for its own 429s (dario#1244), so a seat
139
+ // leaving rotation is visible on every instance, not only the one that
140
+ // took the 429.
141
+ if (remote.rejected && !wasParked) {
142
+ this.log(`[dario] seat "${alias}" parked by peer ${remote.instance}'s reading: ${describeRateLimitSnapshot(remote.snapshot, now)} — parked until the window rolls`);
143
+ }
144
+ }
145
+ this.stats.adopted += adopted;
146
+ return adopted;
147
+ }
148
+ /** The alias a peer bound this conversation to, or null. */
149
+ async lookupSticky(key) {
150
+ const res = await this.call(`/pool/sticky/${encodeURIComponent(key)}/get`, {});
151
+ const alias = res?.alias;
152
+ if (typeof alias === 'string' && alias.length > 0) {
153
+ this.stats.stickyAdopted++;
154
+ return alias;
155
+ }
156
+ return null;
157
+ }
158
+ /** Publish a binding so a peer that sees the conversation next lands on the same seat. A null key (no conversation) is a no-op. */
159
+ bindSticky(key, alias) {
160
+ if (!key)
161
+ return;
162
+ void this.call(`/pool/sticky/${encodeURIComponent(key)}/bind`, { alias, ttlMs: this.stickyTtlMs })
163
+ .then((res) => { if (res)
164
+ this.stats.stickyPushed++; });
165
+ }
166
+ /**
167
+ * One POST to the service. Null on any failure — the caller carries on
168
+ * with local state, which is the whole contract: an outage of the
169
+ * coordination point must never stop the proxy serving. Logged once per
170
+ * transition into and out of the failed state, not per call.
171
+ */
172
+ async call(path, body) {
173
+ try {
174
+ const res = await fetch(`${this.baseUrl}${path}`, {
175
+ method: 'POST',
176
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.token}` },
177
+ body: JSON.stringify(body),
178
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
179
+ });
180
+ if (!res.ok)
181
+ throw new Error(`HTTP ${res.status}`);
182
+ const out = (await res.json());
183
+ this.stats.lastOkAt = Date.now();
184
+ if (this.down) {
185
+ this.down = false;
186
+ this.log(`[dario] pool shared state: service reachable again (${this.baseUrl})`);
187
+ }
188
+ return out;
189
+ }
190
+ catch (err) {
191
+ this.stats.errors++;
192
+ this.stats.lastError = err instanceof Error ? err.message : String(err);
193
+ if (!this.down) {
194
+ this.down = true;
195
+ this.log(`[dario] pool shared state: ${this.baseUrl} unreachable (${this.stats.lastError}) — carrying on with this instance's own state until it answers again`);
196
+ }
197
+ return null;
198
+ }
199
+ }
200
+ }
package/dist/pool.d.ts CHANGED
@@ -93,6 +93,26 @@ export declare function rateLimitWindow(rl: RateLimitSnapshot, now: number): Rat
93
93
  * `5h 104%, 7d 25%, claim five_hour, resets in 37m`.
94
94
  */
95
95
  export declare function describeRateLimitSnapshot(rl: RateLimitSnapshot, now?: number): string;
96
+ /**
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).
100
+ *
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.
107
+ */
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[]>;
111
+ /**
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).
114
+ */
115
+ export declare function distinctWindows(accounts: readonly PoolAccount[], now: number): number;
96
116
  export interface PoolAccount {
97
117
  alias: string;
98
118
  accessToken: string;
@@ -111,6 +131,22 @@ export interface PoolAccount {
111
131
  rejectedCount: number;
112
132
  /** Epoch ms of the most recent 429 on this account; undefined if never. */
113
133
  lastRejectedAt?: number;
134
+ /**
135
+ * The Anthropic organization behind this seat's token, from the
136
+ * `anthropic-organization-id` response header: learned on the first
137
+ * response the seat serves, written to its record with the next token
138
+ * refresh (dario#1244 — a reading that surprises you is usually a token on
139
+ * an organization other than the one whose usage page you are looking at).
140
+ * Undefined until seen.
141
+ */
142
+ organizationId?: string;
143
+ /**
144
+ * Set when the current reading came from a peer instance (pool-sync.ts):
145
+ * that instance's id. Cleared by the next reading this instance takes
146
+ * itself. A seat parked on a peer's 429 shows `rejected` with
147
+ * `rejectedCount` unchanged — the 429 was the peer's — and this says so.
148
+ */
149
+ adoptedFrom?: string;
114
150
  /** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
115
151
  grantedAt?: number;
116
152
  /**
@@ -274,6 +310,7 @@ export declare class AccountPool {
274
310
  deviceId: string;
275
311
  accountUuid: string;
276
312
  grantedAt?: number;
313
+ organizationId?: string;
277
314
  }): void;
278
315
  remove(alias: string): boolean;
279
316
  get size(): number;
@@ -350,6 +387,19 @@ export declare class AccountPool {
350
387
  * the same seat many times, and only the transition is worth a log line.
351
388
  */
352
389
  markRejected(alias: string, snapshot: RateLimitSnapshot): boolean;
390
+ /**
391
+ * Record the organization a response said this seat belongs to. Returns
392
+ * true when it is news — the first observation, or a change (an alias
393
+ * re-granted on another organization) — so the caller persists it once.
394
+ */
395
+ noteOrganization(alias: string, organizationId: string): boolean;
396
+ /**
397
+ * Take a peer instance's reading of `alias` (pool-sync.ts): its snapshot
398
+ * replaces ours, `rejected` parks the seat on it. Counters are left alone
399
+ * — a request the peer served or a 429 it took are the peer's facts — and
400
+ * `adoptedFrom` records whose reading this is. False for an unknown alias.
401
+ */
402
+ adoptSnapshot(alias: string, snapshot: RateLimitSnapshot, rejected: boolean, from: string): boolean;
353
403
  updateTokens(alias: string, accessToken: string, refreshToken: string, expiresAt: number): void;
354
404
  get(alias: string): PoolAccount | undefined;
355
405
  all(): PoolAccount[];
@@ -372,6 +422,7 @@ export interface ReconcilableAccount {
372
422
  deviceId: string;
373
423
  accountUuid: string;
374
424
  grantedAt?: number;
425
+ organizationId?: string;
375
426
  }
376
427
  /**
377
428
  * Reconcile a live pool against the current on-disk account set: add or refresh