@askalf/dario 6.0.32 → 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/docs/admin-api.md CHANGED
@@ -95,12 +95,24 @@ All endpoints accept the token as `authorization: Bearer <token>` or
95
95
  | `DELETE /admin/accounts/<alias>` | — | `{ alias, removed }` (`404` if no such alias) |
96
96
 
97
97
  `GET /admin/accounts` is the monitoring surface: each entry carries the
98
- persisted metadata (`alias`, `scopes`, `expires_in_ms`) **plus live pool
99
- status whenever pool mode is active** — `util5h` / `util7d` utilization,
100
- representative `claim` (e.g. `five_hour`), routing `status`,
101
- `request_count`, and `consecutive_auth_failures`. It's the admin-token-gated
102
- equivalent of the proxy-key-gated `GET /accounts` pool view; a headless
103
- operator needs only the admin token to watch headroom.
98
+ persisted metadata (`alias`, `scopes`, `expires_in_ms`, the grant-age fields)
99
+ **plus live pool status whenever pool mode is active** — `util5h` / `util7d`
100
+ utilization with `last_observed_at` / `util_age_ms` (how old that reading is;
101
+ it does not tick while a seat is parked), `reset_at` / `reset_in_ms` (when
102
+ the window it was measured against rolls — for a `rejected` seat, when the
103
+ rejection lifts), representative `claim` (e.g. `five_hour`), routing
104
+ `status`, `request_count` (requests served), `rejected_count` /
105
+ `last_rejected_at` (429s answered — a 429 serves nothing, so it is not a
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
111
+ to do about it: [Reading a seat's `status`](./multi-account-pool.md#reading-a-seats-status).
112
+ It's the admin-token-gated equivalent of the proxy-key-gated `GET /accounts`
113
+ pool view; a headless operator needs only the admin token to watch headroom.
114
+ Completing a login for an alias that is already in the pool re-grants it: the
115
+ seat starts fresh — no carried-over rejection, cool-down or identity.
104
116
 
105
117
  ## Pinning a request to one seat
106
118
 
@@ -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
 
@@ -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
 
@@ -91,6 +91,45 @@ curl http://localhost:3456/accounts # per-account utilization, claim, sticky
91
91
  curl http://localhost:3456/analytics # per-account / per-model stats, burn rate, exhaustion predictions
92
92
  ```
93
93
 
94
+ ## Reading a seat's `status`
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.
97
+
98
+ | `status` | What it means | What to do |
99
+ |---|---|---|
100
+ | `allowed` | The seat's last response was a 200 with headroom. `util5h` / `util7d` are that response's reading — a ratio against 1.0, so `0.42` is 42% — `lastObservedAt` / `utilAgeMs` say how old it is, `resetAt` / `resetInMs` when its representative window rolls. | Nothing. |
101
+ | `rejected` | The seat's last response was a 429: the organization behind its token is over the window named by `claim` (`five_hour`, `seven_day`, …). `util5h: 1.04` is 104% of the five-hour window, not 1%. `rejectedCount` / `lastRejectedAt` say the seat was tried — a 429 serves nothing, so `requestCount` does not move — and `resetInMs` says how long it stays parked. Requests route around it; it returns on its own when the window rolls. | Nothing — the window clears itself. If the reading surprises you (your usage page for that account says 0%), the token belongs to a different organization than the page you are looking at, or to the same organization as another seat: the reading is Anthropic's own, taken on that token. `dario accounts check <alias>` asks the seat directly. |
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
+ | `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
+
105
+ 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
+
107
+ `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.
108
+
109
+ ## One subscription under two aliases
110
+
111
+ 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).
112
+
113
+ Two facts make this visible:
114
+
115
+ - **`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.
116
+ - **`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.
117
+
118
+ 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`.
119
+
120
+ 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.
121
+
122
+ ## Consumers: who a request is for
123
+
124
+ 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:
125
+
126
+ - **`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.
127
+ - **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.
128
+
129
+ 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.
130
+
131
+ **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.
132
+
94
133
  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.
95
134
 
96
135
  ## 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.32",
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.34",
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
  ],