@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.
@@ -30,6 +30,20 @@ export function decideAdmit(state) {
30
30
  return { action: 'enqueue' };
31
31
  return { action: 'reject', reason: 'queue-full' };
32
32
  }
33
+ /**
34
+ * Pure per-consumer gate (dario#1244 follow-up — a team gateway where one
35
+ * heavy user could hold every slot). A consumer already holding `cap` slots
36
+ * waits even when the queue has room: `enqueue` if it does, `reject` if
37
+ * not. Returns null when the gate does not apply (cap off, or the consumer
38
+ * is under it), so `decideAdmit` decides as before.
39
+ */
40
+ export function decideConsumerAdmit(activeForConsumer, cap, state) {
41
+ if (cap <= 0 || activeForConsumer < cap)
42
+ return null;
43
+ if (state.queued < state.maxQueued)
44
+ return { action: 'enqueue' };
45
+ return { action: 'reject', reason: 'queue-full' };
46
+ }
33
47
  /** Pure timeout check — separated so tests can pass an explicit clock. */
34
48
  export function isQueueEntryExpired(enqueuedAt, now, timeoutMs) {
35
49
  return (now - enqueuedAt) > timeoutMs;
@@ -47,8 +61,10 @@ export class RequestQueue {
47
61
  maxConcurrent;
48
62
  maxQueued;
49
63
  queueTimeoutMs;
64
+ maxConcurrentPerConsumer;
50
65
  unrefTimers;
51
66
  active = 0;
67
+ activeByConsumer = new Map();
52
68
  queue = [];
53
69
  now;
54
70
  stalledSince = null;
@@ -56,6 +72,7 @@ export class RequestQueue {
56
72
  this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
57
73
  this.maxQueued = opts.maxQueued ?? DEFAULT_MAX_QUEUED;
58
74
  this.queueTimeoutMs = opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
75
+ this.maxConcurrentPerConsumer = Math.max(0, opts.maxConcurrentPerConsumer ?? 0);
59
76
  this.unrefTimers = opts.unrefTimers ?? true;
60
77
  this.now = opts.now ?? Date.now;
61
78
  }
@@ -77,17 +94,32 @@ export class RequestQueue {
77
94
  if (this.stalledSince === null)
78
95
  this.stalledSince = this.now();
79
96
  }
97
+ /** A consumer is under its cap when there is no cap, no consumer, or room. */
98
+ underCap(consumer) {
99
+ if (!consumer || this.maxConcurrentPerConsumer <= 0)
100
+ return true;
101
+ return (this.activeByConsumer.get(consumer) ?? 0) < this.maxConcurrentPerConsumer;
102
+ }
103
+ admit(consumer) {
104
+ this.active++;
105
+ if (consumer)
106
+ this.activeByConsumer.set(consumer, (this.activeByConsumer.get(consumer) ?? 0) + 1);
107
+ this.updateStall();
108
+ }
80
109
  /**
81
110
  * Acquire a concurrency slot. Resolves when admitted; throws
82
111
  * `QueueFullError` when the queue is at its `maxQueued` cap, throws
83
112
  * `QueueTimeoutError` when a queued request waited longer than
84
- * `queueTimeoutMs`.
113
+ * `queueTimeoutMs`. `consumer` names who the request is for: with a
114
+ * per-consumer cap set, a consumer at its cap waits even while slots are
115
+ * free, and `release(consumer)` must be called with the same name.
85
116
  */
86
- async acquire() {
87
- const decision = decideAdmit(this.snapshot());
117
+ async acquire(consumer) {
118
+ const state = this.snapshot();
119
+ const gated = consumer ? decideConsumerAdmit(this.activeByConsumer.get(consumer) ?? 0, this.maxConcurrentPerConsumer, state) : null;
120
+ const decision = gated ?? decideAdmit(state);
88
121
  if (decision.action === 'admit') {
89
- this.active++;
90
- this.updateStall();
122
+ this.admit(consumer);
91
123
  return;
92
124
  }
93
125
  if (decision.action === 'reject') {
@@ -108,19 +140,31 @@ export class RequestQueue {
108
140
  // Opt-out for tests — see `unrefTimers` comment in RequestQueueOptions.
109
141
  if (this.unrefTimers)
110
142
  timeoutHandle.unref?.();
111
- const entry = { resolve, reject, enqueuedAt, timeoutHandle };
143
+ const entry = { resolve, reject, enqueuedAt, timeoutHandle, consumer };
112
144
  this.queue.push(entry);
113
145
  this.updateStall();
114
146
  });
115
147
  }
116
- /** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
117
- release() {
148
+ /**
149
+ * Release a slot. The first queued entry whose consumer is under its cap is
150
+ * admitted — FIFO among the admissible, so a capped consumer's waiters do
151
+ * not hold up anyone else's; they get in when that consumer releases.
152
+ */
153
+ release(consumer) {
118
154
  if (this.active > 0)
119
155
  this.active--;
120
- const next = this.queue.shift();
121
- if (next) {
156
+ if (consumer) {
157
+ const left = (this.activeByConsumer.get(consumer) ?? 0) - 1;
158
+ if (left <= 0)
159
+ this.activeByConsumer.delete(consumer);
160
+ else
161
+ this.activeByConsumer.set(consumer, left);
162
+ }
163
+ const idx = this.queue.findIndex((e) => this.underCap(e.consumer));
164
+ if (idx >= 0) {
165
+ const [next] = this.queue.splice(idx, 1);
122
166
  clearTimeout(next.timeoutHandle);
123
- this.active++;
167
+ this.admit(next.consumer);
124
168
  next.resolve();
125
169
  }
126
170
  // A release IS turnover — the thing whose absence defines the wedge — so
@@ -139,6 +183,8 @@ export class RequestQueue {
139
183
  maxConcurrent: this.maxConcurrent,
140
184
  maxQueued: this.maxQueued,
141
185
  stalledSince: this.stalledSince,
186
+ maxConcurrentPerConsumer: this.maxConcurrentPerConsumer,
187
+ consumersActive: this.activeByConsumer.size,
142
188
  };
143
189
  }
144
190
  }
@@ -225,6 +225,8 @@ export const HitsTab = {
225
225
  const r = newestFirst[state.selectedIdx];
226
226
  lines.push(truncate(' ' + brand('Selected') + dim(` ${formatTime(r.timestamp)}`), w));
227
227
  lines.push(' ' + renderKvRow('Account', r.account, w - 4));
228
+ if (r.consumer)
229
+ lines.push(' ' + renderKvRow('Consumer', r.consumer, w - 4));
228
230
  lines.push(' ' + renderKvRow('Model', r.model, w - 4));
229
231
  lines.push(' ' + renderKvRow('Billing bucket', billingBucketFromClaim(r.claim), w - 4));
230
232
  lines.push(' ' + renderKvRow('Tokens', tokenBreakdown(r), w - 4));
@@ -11,6 +11,12 @@ export interface UpstreamRejection {
11
11
  * every "this request was not served" verdict.
12
12
  */
13
13
  export declare const MODEL_UNROUTABLE = "model_unroutable";
14
+ /**
15
+ * Every seat in the pool is parked inside a live rate-limit window, so dario
16
+ * answered 429 itself, with `retry-after` at the earliest reset, and sent
17
+ * nothing upstream (dario#1244).
18
+ */
19
+ export declare const POOL_PARKED = "pool_parked";
14
20
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
15
21
  export declare function classifyUpstreamRejection(status: number, body: string): UpstreamRejection;
16
22
  /** Operator action paired with the failure class. Never suggest credential churn for billing. */
@@ -6,6 +6,12 @@
6
6
  * every "this request was not served" verdict.
7
7
  */
8
8
  export const MODEL_UNROUTABLE = 'model_unroutable';
9
+ /**
10
+ * Every seat in the pool is parked inside a live rate-limit window, so dario
11
+ * answered 429 itself, with `retry-after` at the earliest reset, and sent
12
+ * nothing upstream (dario#1244).
13
+ */
14
+ export const POOL_PARKED = 'pool_parked';
9
15
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
10
16
  export function classifyUpstreamRejection(status, body) {
11
17
  const normalized = body.toLowerCase();
package/docs/admin-api.md CHANGED
@@ -103,7 +103,11 @@ the window it was measured against rolls — for a `rejected` seat, when the
103
103
  rejection lifts), representative `claim` (e.g. `five_hour`), routing
104
104
  `status`, `request_count` (requests served), `rejected_count` /
105
105
  `last_rejected_at` (429s answered — a 429 serves nothing, so it is not a
106
- request), and `consecutive_auth_failures`. What each `status` means and what
106
+ request), `organization_id` (the organization the token belongs to, learned
107
+ from its responses and written to the record with its next refresh), `shares_window_with` (aliases whose last
108
+ reading names the same live window — one subscription under several aliases,
109
+ see [One subscription under two aliases](./multi-account-pool.md#one-subscription-under-two-aliases)),
110
+ and `consecutive_auth_failures`. What each `status` means and what
107
111
  to do about it: [Reading a seat's `status`](./multi-account-pool.md#reading-a-seats-status).
108
112
  It's the admin-token-gated equivalent of the proxy-key-gated `GET /accounts`
109
113
  pool view; a headless operator needs only the admin token to watch headroom.
@@ -41,6 +41,16 @@ Halts the proxy when an upstream response reports `representative-claim: overage
41
41
  | `DARIO_MAX_CONCURRENT` | `--max-concurrent=N` | `10` | in-flight ceiling |
42
42
  | `DARIO_MAX_QUEUED` | `--max-queued=N` | `128` | buffered waiting for a slot; over this, dario returns 429 `queue-full` |
43
43
  | `DARIO_QUEUE_TIMEOUT_MS` | `--queue-timeout=MS` | `60000` | a queued request waiting longer gets 504 `queue-timeout` |
44
+ | `DARIO_MAX_CONCURRENT_PER_CONSUMER` | `--max-concurrent-per-consumer=N` | `0` (off) | in-flight ceiling per consumer, keyed by the `x-dario-consumer` request header; a consumer at the cap waits in the queue while everyone else keeps flowing. See [Consumers](./multi-account-pool.md#consumers-who-a-request-is-for) |
45
+
46
+ ## Multi-instance
47
+
48
+ | Variable | Flag | Default | Notes |
49
+ |---|---|---|---|
50
+ | `DARIO_REFRESH_LOCK_URL` | — | unset | refresh-lock service; unset = single-instance behaviour. See [multi-instance.md](./multi-instance.md) |
51
+ | `DARIO_REFRESH_LOCK_TOKEN` | — | unset | bearer for the lock service |
52
+ | `DARIO_POOL_SHARED_STATE` | `--pool-shared-state` | off | share rate-limit readings and sticky bindings with the other instances through the lock service; fails open |
53
+ | `DARIO_POOL_SHARED_STATE_INTERVAL_MS` | `--pool-shared-state-interval=MS` | `2000` | how often to pull peers' readings |
44
54
 
45
55
  ## Template fidelity
46
56
 
@@ -113,7 +113,8 @@ is a proof that the tools / system_prompt / beta headers / field orders are
113
113
  byte-identical at the live version, so only the version string moves — the
114
114
  same deterministic-bump risk class `cc-drift-watch.yml` already auto-merges for
115
115
  `SUPPORTED_CC_RANGE.maxTested`. Auto-merge still gates on the required checks
116
- (build ×3, compat, test, docker-cap-drop-smoke); a red check leaves the PR open
116
+ (build ×3, live-test, CodeQL, actionlint, validate-package-json); compat runs
117
+ alongside but is not required. A red required check leaves the PR open
117
118
  with the bot branch preserved. A shape rebake (exit 2) changes the wire-shape
118
119
  contract, so a human reviews compat-test + the diff before merging.
119
120
 
@@ -8,7 +8,7 @@ This is opinionated. There are several ways to wire OpenClaw to dario; this is t
8
8
 
9
9
  - OpenClaw running locally, talking to dario at `localhost:3456`
10
10
  - All Claude API calls routed through your Pro / Max subscription via the Claude Code wire shape
11
- - OpenClaw's tool schema (`exec`, `process`, `web_search`, `web_fetch`, `browser`, `message`) auto-translated to CC's canonical set on the outbound path and rebuilt back on the inbound path — **no flag required**
11
+ - OpenClaw's tool schema (`exec`, `process`, `web_search`, `web_fetch`, `browser`) auto-translated to CC's canonical set on the outbound path and rebuilt back on the inbound path — **no flag required**; a tool outside the map (`message`, for one) rides a fallback slot
12
12
  - Your `openclaw.inbound_meta.v1` namespace stripped at the proxy boundary so Anthropic's billing classifier doesn't flip you to extra-usage
13
13
  - `dario doctor --usage` showing the OpenClaw traffic in your 5-hour bucket with `claim=five_hour (subscription)`
14
14
 
@@ -134,7 +134,7 @@ Watch the dario terminal — you should see one log line per request, looking li
134
134
  ...
135
135
  ```
136
136
 
137
- If you see `→ 200` lines and OpenClaw is making progress, you're good. dario's `client: 'unknown-non-cc'` structural fallback is silently auto-translating OpenClaw's `exec` / `process` / `web_search` / `web_fetch` / `browser` / `message` tools to CC's canonical set on the outbound path and rebuilding the OpenClaw shape on the inbound path — no flag, no config.
137
+ If you see `→ 200` lines and OpenClaw is making progress, you're good. dario's `client: 'unknown-non-cc'` structural fallback is silently auto-translating OpenClaw's `exec` / `process` / `web_search` / `web_fetch` / `browser` tools to CC's canonical set (anything outside the map, `message` included, rides a fallback slot) on the outbound path and rebuilding the OpenClaw shape on the inbound path — no flag, no config.
138
138
 
139
139
  ## Verifying subscription billing (the important part)
140
140
 
@@ -93,7 +93,7 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
93
93
 
94
94
  ## Reading a seat's `status`
95
95
 
96
- `GET /accounts` (and the admin API's `GET /admin/accounts`, in snake_case) report one `status` per seat. It is the routing verdict, and every value comes with the fields that explain it.
96
+ `GET /accounts` (and the admin API's `GET /admin/accounts`, in snake_case) report one `status` per seat. It is the routing verdict, and every value comes with the fields that explain it. Next to it, `action` is the last column of this table in one word: `none`, `wait` (the seat comes back on its own; `resetInMs` says when) or `regrant` (an auth-failure streak, which is a dead refresh token).
97
97
 
98
98
  | `status` | What it means | What to do |
99
99
  |---|---|---|
@@ -102,8 +102,36 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
102
102
  | `unknown` | No current observation: a seat that has served nothing yet, or a rejection whose window has rolled (`resetInMs: 0`) and that nothing has measured since. | Nothing; the next request measures it. |
103
103
  | `auth-cooldown` | Upstream answered 401/403 or `invalid_grant`. `consecutiveAuthFailures` tells a blip (1) from a dead refresh token (a streak); the cool-down doubles with the streak, from 1 minute to 30. | A streak means re-grant the seat — `dario accounts remove` + `add`, or the admin login flow under the same alias. A new grant starts the seat fresh: no carried-over cool-down, rejection or identity. See [Refresh-token grant age](#refresh-token-grant-age) for the 28-day wall behind most streaks. |
104
104
 
105
+ **When every seat is parked.** A pool whose seats are all `rejected` inside live windows does not probe them again: dario answers the request itself with `429`, `retry-after` set to the earliest reset, `x-dario-upstream-rejection: pool_parked`, and nothing sent upstream. One log line marks the transition (`pool parked: all 6 seats are over their rate-limit windows, earliest resets in 21m`). Before 6.0.35 every such request re-probed the earliest-reset seat, so `rejectedCount` on that seat grew by one per request — a seat reading `rejected_count: 500` next to `request_count: 1` was that, not a seat that needed a re-login. With a `--pool-fallback` armed, the request goes to the fallback instead, as before.
106
+
105
107
  The proxy logs every parking as it happens, once per window: `rate limited (429) on account "spare": 5h 104%, 7d 25%, claim five_hour, resets in 37m — parked until the window rolls`. The re-probes the all-exhausted fallback makes of an already-parked seat are logged only under `-v`.
106
108
 
109
+ `dario accounts list --live` prints the same view from the running proxy — status with its countdown, the reading and its age, requests served and 429s answered, the organization, shared windows, grant age — where the plain `dario accounts list` only knows what is on disk.
110
+
111
+ ## One subscription under two aliases
112
+
113
+ A pool of six is only six windows if the six tokens belong to six subscriptions. Two aliases granted from the same account — or from two accounts on one organization that share a plan — share one five-hour and one seven-day window, and the pool counts that window twice: both seats look like headroom, the busier one fills the window for both, and the other 429s on its first request with the same reading (the #1244 report).
114
+
115
+ Two facts make this visible:
116
+
117
+ - **`organizationId`** — the `anthropic-organization-id` every response carries, learned the first time a seat serves and written to its record with the seat's next token refresh. It is what to compare with the organization behind the usage page you are looking at: a reading that surprises you is usually a token on a different organization.
118
+ - **`sharesWindowWith`** — the other aliases whose last reading names the same live window (same representative claim, same reset second). Two independent windows all but never share a reset second; two readings of one window always do. This is the fact that matters for headroom, and it is deliberately not derived from the organization: seats on one organization can still have their own windows.
119
+
120
+ Both are on `GET /accounts` (`distinctWindows` at the top counts the windows the pool really has), on `GET /admin/accounts` as `organization_id` / `shares_window_with`, in `dario accounts list --live`, and in `dario doctor` (the `Organizations` row, from the ids on the records — so up to one refresh behind the running proxy). The proxy also says it once, when the second reading arrives: `seats "twin" and "busy" report the same five_hour window (resets 2026-09-07T13:12:00.000Z) — one subscription under two aliases; the pool has 2 distinct windows across 3 seats`.
121
+
122
+ What to do about it: nothing is broken — the pool routes on real headroom either way, and the duplicate seat simply parks on the first 429 until the window rolls. If the second alias was meant to be a second subscription, re-grant it while signed in to the right account.
123
+
124
+ ## Consumers: who a request is for
125
+
126
+ A pool shared by a team serves several people through one `DARIO_API_KEY`, and until now nothing said whose traffic went where. A request can now name its consumer, and dario attributes and, optionally, paces by it:
127
+
128
+ - **`x-dario-consumer: <name>`** — one printable token, up to 64 characters, no spaces. Set it per user in whatever fronts dario (LiteLLM's per-key headers, a reverse proxy, the client itself). This is the name the per-consumer cap keys on.
129
+ - **Without the header**, attribution falls back to a hash of the body's user id: the Anthropic `metadata.user_id` (Claude Code sends `user_<hash>_account_<uuid>_session_<uuid>`; the session part is dropped, so one person is one key across sessions) or the OpenAI `user` field. The key is `u_` plus twelve hex characters — no account id or raw user id becomes an analytics key. The fallback is attribution only: the body is parsed after the concurrency slot is taken, so only the header can pace.
130
+
131
+ Where it shows: `GET /analytics` gains `perConsumer` (requests, tokens, cache share, estimated cost, the seats the consumer landed on, last model) next to `perAccount`; every request log line and the `-v` usage line carry `consumer`; the TUI's Hits tab shows it on the selected request.
132
+
133
+ **Fairness.** `--max-concurrent-per-consumer=N` (`DARIO_MAX_CONCURRENT_PER_CONSUMER`) caps in-flight requests per named consumer. A consumer at the cap waits in the queue while slots are free for everyone else; when a slot frees, the first waiter whose consumer is under its cap is admitted, so one heavy user's backlog never holds up another user's next turn. Requests that name no consumer are never capped. Off by default — the plain `--max-concurrent` ceiling still applies to everyone together.
134
+
107
135
  Every request carries a `billingBucket` field (`subscription` / `subscription_fallback` / `extra_usage` / `api` / `unknown`) so you can see which bucket each request billed against and a `subscriptionPercent` headline number tells you at a glance whether dario is actually routing through your subscription or silently falling to API overage.
108
136
 
109
137
  ## Refresh-token grant age
@@ -1,6 +1,6 @@
1
1
  # Running more than one dario against the same accounts
2
2
 
3
- Short answer: **you can share credentials safely between instances, but dario is not HA.** Those are different claims, and the difference matters before you scale a Deployment to 2.
3
+ Short answer: **you can share credentials safely between instances, and with shared pool state on they also share what they know about the seats.** dario is still not a consensus system; the difference matters before you scale a Deployment to 2.
4
4
 
5
5
  This page covers what actually breaks with two instances, which part is solved, how to turn the fix on, and how to prove it works on your own infrastructure.
6
6
 
@@ -20,18 +20,22 @@ Two instances holding the same account both notice the token is near expiry, and
20
20
 
21
21
  A plain mutex does not fix this. The loser waits, acquires the lock, and then refreshes with a token that is *already* stale — it just loses more slowly. The fix is that the loser **adopts the winner's fresh credentials** instead of attempting its own. That is what dario's refresh lock does.
22
22
 
23
- ### 2. Rate-limit accounting — **not solved**
23
+ ### 2. Rate-limit accounting — **solved with shared state**
24
24
 
25
25
  `pool.ts` keeps `accounts: Map<string, PoolAccount>` in process memory, and updates it only from rate-limit headers on responses *that instance* saw.
26
26
 
27
- Two instances sharing an account each believe it has full headroom. Both route to it. Neither can see what the other is spending, so the pool overshoots the real 5-hour and 7-day windows and starts getting rejections it did not predict. Adding instances makes this worse, not better.
27
+ Two instances sharing an account each believe it has full headroom. Both route to it. Neither can see what the other is spending, so the pool overshoots the real 5-hour and 7-day windows and starts getting rejections it did not predict and each instance eats its own 429 to learn what the other already knew.
28
28
 
29
- ### 3. Session stickiness**not solved**
29
+ With `--pool-shared-state` every instance reports each reading it takes to the lock service and pulls the others' every two seconds, adopting any reading newer than its own. A 429 taken on one instance parks the seat on all of them within a pull; the listings say whose reading a seat carries (`readingFrom`), and `rejectedCount` stays each instance's own. The accounting is eventually consistent a pull interval behind, never ahead — which is what makes it approximate rather than wrong.
30
+
31
+ ### 3. Session stickiness — **solved with shared state**
30
32
 
31
33
  `computeStickyKey()` hashes the first user message and pins that conversation to one account, so its prompt cache stays warm. The binding lives in process memory.
32
34
 
33
35
  With two instances behind one Service, the same conversation can land on either, and get a different account each time. The prefix is re-cached per account, so you pay cache writes instead of reads. See [`docs/multi-account-pool.md`](./multi-account-pool.md) for why that costs real money.
34
36
 
37
+ With `--pool-shared-state` a binding made on one instance is published, and an instance that sees a conversation it holds no binding for asks the service before choosing — so the second turn of a conversation that started on the other replica reads the cache the first turn wrote. Failover rebindings are published the same way.
38
+
35
39
  ---
36
40
 
37
41
  ## So what should you actually run?
@@ -40,8 +44,8 @@ With two instances behind one Service, the same conversation can land on either,
40
44
  |---|---|
41
45
  | Zero-downtime restarts / rolling deploys | Two instances **with the refresh lock**. Brief overlap is fine; the credential race is the only thing that corrupts state, and the lock covers it. |
42
46
  | More throughput from more accounts | **One instance, more accounts in the pool.** The pool is the horizontal-scaling mechanism; a second instance is not. |
43
- | Survive a node failure | Two instances with the lock, and accept that rate-limit accounting is approximate while both are live. |
44
- | Precise rate-limit accounting | One instance. There is no shared-state mode today. |
47
+ | Survive a node failure | Two instances with the lock and shared pool state; accounting is a pull interval behind while both are live. |
48
+ | Precise rate-limit accounting | One instance. Shared state is eventually consistent (a pull interval behind), not exact. |
45
49
 
46
50
  dario starts in well under a second, so for most people a single replica with a sensible `restartPolicy` is the honest answer — which is roughly where [#993](https://github.com/askalf/dario/issues/993) landed too.
47
51
 
@@ -90,6 +94,23 @@ The lock is resilience layered on top of dario's job, not a new dependency dario
90
94
 
91
95
  ---
92
96
 
97
+ ## Turning shared pool state on
98
+
99
+ Same service, same two variables, one more switch on every instance:
100
+
101
+ ```
102
+ DARIO_REFRESH_LOCK_URL=http://<lock-host>:8080
103
+ DARIO_REFRESH_LOCK_TOKEN=<shared secret>
104
+ DARIO_POOL_SHARED_STATE=1 # or: dario proxy --pool-shared-state
105
+ DARIO_POOL_SHARED_STATE_INTERVAL_MS=2000 # optional; how often to pull peers' readings
106
+ ```
107
+
108
+ Both reference backends serve the three extra endpoints (`/pool/seat/<alias>`, `/pool/seats`, `/pool/sticky/<key>/{bind,get}`); redeploy the one you run. What crosses the wire is rate-limit snapshots, aliases and hashed sticky keys — no token, no message content.
109
+
110
+ The proxy says what it is doing at startup (`Pool shared state: on (instance …, via …, pulling peers every 2000ms; fails open)`), `GET /accounts` carries a `sharedState` block (instance id, last pull, readings adopted and reported, bindings pushed and adopted, errors), and each seat says whose reading it holds in `readingFrom`. A seat parked on a peer's 429 is logged on every instance: `seat "busy" parked by peer <id>'s reading: 5h 104%, 7d 25%, claim five_hour, resets in 37m`.
111
+
112
+ It fails open like the lock: an unreachable service is one log line per outage and each instance carries on with its own state until the service answers again.
113
+
93
114
  ## Proving it works on your own setup
94
115
 
95
116
  Do not take the above on trust. `test/integration/dual-instance-race.mjs` runs the real scenario: **two genuinely separate `node` processes**, each with its own isolated `~/.dario`, sharing nothing but the lock service, both racing to refresh the same account at the same instant.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.33",
4
- "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
3
+ "version": "6.0.35",
4
+ "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dario": "./dist/cli.js"
@@ -41,28 +41,34 @@
41
41
  "cch:calibrate": "node scripts/cch-calibrate.mjs",
42
42
  "fix:pkg": "node -e \"const fs=require('fs');fs.writeFileSync('package.json',JSON.stringify(JSON.parse(fs.readFileSync('package.json','utf-8')),null,2)+'\\n')\"",
43
43
  "audit:tui": "node tools/tui-audit/audit.mjs",
44
- "readme:assets": "node scripts/readme/hero.mjs && node scripts/readme/terminal.mjs && node scripts/readme/tui.mjs",
44
+ "readme:assets": "node scripts/readme/terminal.mjs && node scripts/readme/tui.mjs",
45
45
  "check:readme": "node scripts/check-readme-line-count.mjs && node scripts/check-readme-links.mjs"
46
46
  },
47
47
  "keywords": [
48
48
  "llm",
49
49
  "llm-router",
50
+ "llm-proxy",
50
51
  "multi-provider",
51
52
  "openai-compat",
52
53
  "openai",
53
- "openrouter",
54
- "groq",
55
- "litellm",
56
- "ollama",
54
+ "chatgpt",
55
+ "codex",
57
56
  "claude",
58
57
  "anthropic",
59
- "oauth",
60
- "proxy",
61
- "api",
62
- "subscription",
58
+ "claude-code",
63
59
  "claude-max",
64
60
  "claude-pro",
65
- "ai",
61
+ "subscription",
62
+ "oauth",
63
+ "proxy",
64
+ "cursor",
65
+ "cline",
66
+ "aider",
67
+ "agent-sdk",
68
+ "openrouter",
69
+ "litellm",
70
+ "ollama",
71
+ "groq",
66
72
  "cli",
67
73
  "developer-tools"
68
74
  ],