@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/README.md +39 -59
- package/dist/accounts.d.ts +18 -0
- package/dist/accounts.js +14 -0
- package/dist/admin-api.d.ts +8 -0
- package/dist/admin-api.js +11 -1
- package/dist/analytics.d.ts +41 -1
- package/dist/analytics.js +65 -2
- package/dist/cli.js +94 -1
- package/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +34 -0
- package/dist/pool-sync.d.ts +114 -0
- package/dist/pool-sync.js +200 -0
- package/dist/pool.d.ts +51 -0
- package/dist/pool.js +86 -0
- package/dist/proxy.d.ts +14 -0
- package/dist/proxy.js +117 -12
- package/dist/request-queue.d.ts +34 -4
- package/dist/request-queue.js +57 -11
- package/dist/tui/tabs/hits.js +2 -0
- package/docs/admin-api.md +5 -1
- package/docs/configuration.md +10 -0
- package/docs/integrations/openclaw-walkthrough.md +2 -2
- package/docs/multi-account-pool.md +26 -0
- package/docs/multi-instance.md +27 -6
- package/package.json +18 -12
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).
|
package/dist/doctor-core.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/doctor-core.js
CHANGED
|
@@ -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
|