@askalf/dario 6.0.33 → 6.0.35

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/analytics.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * each subscriber listens for `'record'` and writes one SSE frame.
18
18
  */
19
19
  import { EventEmitter } from 'node:events';
20
+ import { createHash } from 'node:crypto';
20
21
  /**
21
22
  * Map the raw `representative-claim` header value to a human-friendly
22
23
  * billing bucket. Pure function; no state; safe to call from any context.
@@ -99,14 +100,14 @@ export function cachedPromptPercent(inputTokens, cacheReadTokens, cacheCreateTok
99
100
  const promptTotal = inputTokens + cacheReadTokens + cacheCreateTokens;
100
101
  return promptTotal > 0 ? Math.round((cacheReadTokens / promptTotal) * 10000) / 100 : 0;
101
102
  }
102
- export function formatUsageLogLine(requestCount, u) {
103
+ export function formatUsageLogLine(requestCount, u, consumer) {
103
104
  const inp = u.inputTokens ?? 0;
104
105
  const out = u.outputTokens ?? 0;
105
106
  const cr = u.cacheReadTokens ?? 0;
106
107
  const cc = u.cacheCreateTokens ?? 0;
107
108
  const promptTotal = inp + cr + cc;
108
109
  const pct = promptTotal > 0 ? Math.round((cr / promptTotal) * 100) : 0;
109
- return `[dario] #${requestCount} usage: in=${inp} out=${out} cache_read=${cr} cache_create=${cc} (${pct}% of prompt from cache)`;
110
+ return `[dario] #${requestCount} usage: in=${inp} out=${out} cache_read=${cr} cache_create=${cc} (${pct}% of prompt from cache)${consumer ? ` consumer=${consumer}` : ''}`;
110
111
  }
111
112
  /**
112
113
  * The sentinel `claim` dario assigns when a response carried no rate-limit
@@ -115,6 +116,40 @@ export function formatUsageLogLine(requestCount, u) {
115
116
  * so the overage-guard must never halt on it.
116
117
  */
117
118
  export const NO_BILLING_CLAIM = 'unknown';
119
+ /** Request header naming the consumer a request is for. */
120
+ export const CONSUMER_HEADER = 'x-dario-consumer';
121
+ /**
122
+ * The consumer named by the `x-dario-consumer` header: one printable-ASCII
123
+ * token, no spaces, at most 64 characters — anything else is treated as
124
+ * absent rather than becoming an analytics key.
125
+ */
126
+ export function consumerFromHeader(value) {
127
+ const raw = Array.isArray(value) ? value[0] : value;
128
+ if (typeof raw !== 'string')
129
+ return undefined;
130
+ const token = raw.trim();
131
+ return token.length > 0 && token.length <= 64 && /^[\x21-\x7e]+$/.test(token) ? token : undefined;
132
+ }
133
+ /**
134
+ * A consumer derived from the request body when no header named one: the
135
+ * Anthropic `metadata.user_id` (Claude Code sends
136
+ * `user_<hash>_account_<uuid>_session_<uuid>`; the session part is dropped
137
+ * so one person is one key across sessions) or the OpenAI `user` field.
138
+ * Hashed, so no account id or raw user id becomes an analytics key.
139
+ */
140
+ export function consumerFromBody(body) {
141
+ if (!body)
142
+ return undefined;
143
+ const meta = body.metadata;
144
+ const userId = meta && typeof meta === 'object' ? meta.user_id : undefined;
145
+ const raw = typeof userId === 'string' && userId.length > 0 ? userId
146
+ : typeof body.user === 'string' && body.user.length > 0 ? body.user
147
+ : undefined;
148
+ if (!raw)
149
+ return undefined;
150
+ const person = raw.match(/^(user_[0-9a-f]+_account_[0-9a-f-]+)_session_/i)?.[1] ?? raw;
151
+ return 'u_' + createHash('sha256').update(person).digest('hex').slice(0, 12);
152
+ }
118
153
  /**
119
154
  * True when a claim represents real *non-subscription* billing — the
120
155
  * condition the overage-guard halts on (see `overage-guard.ts`, #288).
@@ -276,6 +311,7 @@ export class Analytics extends EventEmitter {
276
311
  ...this.computeStats(allTime),
277
312
  },
278
313
  perAccount: this.perAccountStats(recent),
314
+ perConsumer: this.perConsumerStats(recent),
279
315
  perModel: this.perModelStats(recent),
280
316
  utilization: this.currentUtilization(recent),
281
317
  predictions: this.predict(recent),
@@ -364,6 +400,33 @@ export class Analytics extends EventEmitter {
364
400
  }
365
401
  return result;
366
402
  }
403
+ /** Per-consumer usage — only records that carry a consumer take part. */
404
+ perConsumerStats(records) {
405
+ const grouped = {};
406
+ for (const r of records) {
407
+ if (!r.consumer)
408
+ continue;
409
+ (grouped[r.consumer] ??= []).push(r);
410
+ }
411
+ const result = {};
412
+ for (const [consumer, recs] of Object.entries(grouped)) {
413
+ const inputTokens = recs.reduce((s, r) => s + r.inputTokens, 0);
414
+ const cacheReadTokens = recs.reduce((s, r) => s + r.cacheReadTokens, 0);
415
+ const cacheCreateTokens = recs.reduce((s, r) => s + r.cacheCreateTokens, 0);
416
+ result[consumer] = {
417
+ requests: recs.length,
418
+ inputTokens,
419
+ outputTokens: recs.reduce((s, r) => s + r.outputTokens, 0),
420
+ cacheReadTokens,
421
+ cacheCreateTokens,
422
+ cachedPromptPercent: cachedPromptPercent(inputTokens, cacheReadTokens, cacheCreateTokens),
423
+ estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
424
+ accounts: [...new Set(recs.map((r) => r.account))].sort(),
425
+ lastModel: recs[recs.length - 1].model,
426
+ };
427
+ }
428
+ return result;
429
+ }
367
430
  perModelStats(records) {
368
431
  const grouped = {};
369
432
  for (const r of records) {
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,78 @@ 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
+ // The one-word next step (dario#1244): a parked seat wants nothing from
946
+ // the operator; an auth-failure streak wants a re-grant.
947
+ const next = s.action === 'regrant' ? 'next: re-grant this seat (dario accounts remove + add)'
948
+ : s.action === 'wait' ? 'next: nothing, it comes back on its own'
949
+ : null;
950
+ const facts = [
951
+ `served ${s.requestCount}`,
952
+ `429s ${s.rejectedCount}`,
953
+ ...(next ? [next] : []),
954
+ s.organizationId ? `org ${s.organizationId.slice(0, 8)}…` : 'org not yet observed',
955
+ ...(s.sharesWindowWith.length > 0 ? [`shares its window with ${s.sharesWindowWith.join(', ')}`] : []),
956
+ ];
957
+ console.log(` ${''.padEnd(20)} ${facts.join(' · ')}`);
958
+ console.log(` ${''.padEnd(20)} ${describeGrantAge(grantAge(s.grantedAt ?? undefined, now))}`);
959
+ }
960
+ console.log('');
961
+ return true;
962
+ }
885
963
  async function accounts() {
886
964
  const sub = args[1];
965
+ if ((!sub || sub === 'list') && args.includes('--live')) {
966
+ if (await accountsListLive())
967
+ return;
968
+ }
887
969
  if (!sub || sub === 'list') {
888
970
  const aliases = await listAccountAliases();
889
971
  console.log('');
@@ -1372,6 +1454,8 @@ async function help() {
1372
1454
  POSTs /admin/resume on the local proxy. (dario#288)
1373
1455
  dario logout Remove saved credentials
1374
1456
  dario accounts list List accounts in the multi-account pool
1457
+ (--live: the running proxy's view — status,
1458
+ window, 429s, organization, shared windows)
1375
1459
  dario accounts add NAME [--manual] [--from-keychain[=<target>]]
1376
1460
  Add a new account to the pool (runs OAuth flow).
1377
1461
  --manual (alias: --headless) prints an authorize
@@ -1629,6 +1713,21 @@ async function help() {
1629
1713
  concurrency slot before dario returns
1630
1714
  429 "queue-full" (default: 128).
1631
1715
  Env: DARIO_MAX_QUEUED. (dario#80)
1716
+ --max-concurrent-per-consumer=N
1717
+ Max in-flight requests per consumer, keyed
1718
+ by the x-dario-consumer request header; a
1719
+ consumer at the cap waits while others keep
1720
+ flowing (default: 0 = off).
1721
+ Env: DARIO_MAX_CONCURRENT_PER_CONSUMER.
1722
+ --pool-shared-state Share rate-limit readings and sticky bindings
1723
+ with the other dario instances through the
1724
+ refresh-lock service (needs
1725
+ DARIO_REFRESH_LOCK_URL / _TOKEN). Fails open.
1726
+ Env: DARIO_POOL_SHARED_STATE=1.
1727
+ --pool-shared-state-interval=MS
1728
+ How often to pull peers' readings
1729
+ (default: 2000). Env:
1730
+ DARIO_POOL_SHARED_STATE_INTERVAL_MS.
1632
1731
  --queue-timeout=MS Max ms a queued request waits before
1633
1732
  dario returns 504 "queue-timeout"
1634
1733
  (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
+ }