@askalf/dario 6.15.0 → 6.17.0
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 +19 -21
- package/dist/analytics-ui.html +835 -0
- package/dist/analytics.d.ts +26 -1
- package/dist/analytics.js +51 -3
- package/dist/body-identity.d.ts +25 -0
- package/dist/body-identity.js +68 -0
- package/dist/cc-template.js +3 -14
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +44 -7
- package/dist/codex-backend.js +12 -4
- package/dist/config-file.d.ts +1 -1
- package/dist/config-file.js +2 -2
- package/dist/credits-rung.d.ts +72 -0
- package/dist/credits-rung.js +133 -0
- package/dist/doctor-core.d.ts +2 -0
- package/dist/doctor-core.js +47 -10
- package/dist/donuts.d.ts +18 -17
- package/dist/donuts.js +46 -54
- package/dist/ledger.d.ts +20 -0
- package/dist/ledger.js +40 -1
- package/dist/metrics.js +2 -1
- package/dist/midstream.js +6 -2
- package/dist/openai-backend.js +8 -3
- package/dist/pool.d.ts +18 -1
- package/dist/pool.js +31 -10
- package/dist/proxy.d.ts +14 -1
- package/dist/proxy.js +351 -105
- package/dist/relaunch-signals.d.ts +35 -0
- package/dist/relaunch-signals.js +41 -0
- package/dist/stream-drain.d.ts +4 -3
- package/dist/stream-drain.js +4 -3
- package/dist/tui/tabs/config.js +2 -2
- package/dist/upstream-deadline.d.ts +25 -0
- package/dist/upstream-deadline.js +76 -0
- package/docs/analytics.md +10 -8
- package/docs/api-equivalent-spend.md +9 -2
- package/docs/commands.md +1 -1
- package/docs/configuration.md +1 -0
- package/docs/faq.md +5 -0
- package/docs/multi-account-pool.md +3 -1
- package/docs/pool-keys-and-analytics.md +2 -0
- package/docs/two-plans.md +2 -0
- package/docs/who-its-for.md +2 -2
- package/package.json +8 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal hand-off for the Bun relaunch.
|
|
3
|
+
*
|
|
4
|
+
* Under Node, the CLI relaunches itself under Bun as a child and parks (cli.ts). The
|
|
5
|
+
* child owns the proxy and its graceful shutdown (drain, ledger and key flush), but a
|
|
6
|
+
* SIGTERM sent to the wrapper never reached it. In a container the wrapper is PID 1,
|
|
7
|
+
* and the kernel drops a signal PID 1 has no handler for, so `docker stop` waited out
|
|
8
|
+
* its whole grace period and then SIGKILLed both processes: every recreate took the
|
|
9
|
+
* full stop timeout and the shutdown flush never ran.
|
|
10
|
+
*
|
|
11
|
+
* `forwardSignals` passes SIGTERM and SIGINT on to the child, and `relaunchExitCode`
|
|
12
|
+
* turns the child's exit into the wrapper's. Both take their process and child as
|
|
13
|
+
* arguments, so they are tested without spawning anything.
|
|
14
|
+
*/
|
|
15
|
+
/** The signals the wrapper passes on. Each one starts the child's graceful shutdown. */
|
|
16
|
+
export declare const FORWARDED_SIGNALS: readonly ["SIGTERM", "SIGINT"];
|
|
17
|
+
export interface RelaunchChild {
|
|
18
|
+
kill(signal: NodeJS.Signals): boolean;
|
|
19
|
+
readonly exitCode: number | null;
|
|
20
|
+
readonly signalCode: NodeJS.Signals | null;
|
|
21
|
+
}
|
|
22
|
+
export interface SignalSource {
|
|
23
|
+
on(event: NodeJS.Signals, listener: () => void): unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pass each forwarded signal on to the child while it is still running. Once the
|
|
27
|
+
* wrapper has a handler it no longer exits on the signal itself: it exits when the
|
|
28
|
+
* child does, through the child's exit listener.
|
|
29
|
+
*/
|
|
30
|
+
export declare function forwardSignals(child: RelaunchChild, source?: SignalSource): void;
|
|
31
|
+
/**
|
|
32
|
+
* The wrapper's exit code for how the child ended: its own code, or 128 plus the
|
|
33
|
+
* signal number when a signal killed it, the way a shell reports it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function relaunchExitCode(code: number | null, signal: NodeJS.Signals | null): number;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal hand-off for the Bun relaunch.
|
|
3
|
+
*
|
|
4
|
+
* Under Node, the CLI relaunches itself under Bun as a child and parks (cli.ts). The
|
|
5
|
+
* child owns the proxy and its graceful shutdown (drain, ledger and key flush), but a
|
|
6
|
+
* SIGTERM sent to the wrapper never reached it. In a container the wrapper is PID 1,
|
|
7
|
+
* and the kernel drops a signal PID 1 has no handler for, so `docker stop` waited out
|
|
8
|
+
* its whole grace period and then SIGKILLed both processes: every recreate took the
|
|
9
|
+
* full stop timeout and the shutdown flush never ran.
|
|
10
|
+
*
|
|
11
|
+
* `forwardSignals` passes SIGTERM and SIGINT on to the child, and `relaunchExitCode`
|
|
12
|
+
* turns the child's exit into the wrapper's. Both take their process and child as
|
|
13
|
+
* arguments, so they are tested without spawning anything.
|
|
14
|
+
*/
|
|
15
|
+
import { constants } from 'node:os';
|
|
16
|
+
/** The signals the wrapper passes on. Each one starts the child's graceful shutdown. */
|
|
17
|
+
export const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT'];
|
|
18
|
+
/**
|
|
19
|
+
* Pass each forwarded signal on to the child while it is still running. Once the
|
|
20
|
+
* wrapper has a handler it no longer exits on the signal itself: it exits when the
|
|
21
|
+
* child does, through the child's exit listener.
|
|
22
|
+
*/
|
|
23
|
+
export function forwardSignals(child, source = process) {
|
|
24
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
25
|
+
source.on(signal, () => {
|
|
26
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
27
|
+
child.kill(signal);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The wrapper's exit code for how the child ended: its own code, or 128 plus the
|
|
33
|
+
* signal number when a signal killed it, the way a shell reports it.
|
|
34
|
+
*/
|
|
35
|
+
export function relaunchExitCode(code, signal) {
|
|
36
|
+
if (code !== null)
|
|
37
|
+
return code;
|
|
38
|
+
if (signal !== null)
|
|
39
|
+
return 128 + (constants.signals[signal] ?? 0);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
package/dist/stream-drain.d.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` flips the tradeoff: when
|
|
16
16
|
* the downstream client disconnects, dario suppresses the upstream abort
|
|
17
17
|
* and keeps the reader loop spinning until the upstream emits its final
|
|
18
|
-
* event
|
|
19
|
-
*
|
|
18
|
+
* event, or until the upstream timeout ends a stream that has gone quiet
|
|
19
|
+
* (a dead upstream is not waited on). Writes to the closed `res` are gated off;
|
|
20
20
|
* the reads and any accumulator state (analytics, tool-map) continue so
|
|
21
21
|
* the captured usage numbers are complete rather than truncated.
|
|
22
22
|
*
|
|
@@ -48,7 +48,8 @@ export type ClientCloseAction = 'abort' | 'drain' | 'noop';
|
|
|
48
48
|
* `'noop'` — already finished / already aborted; handler should return.
|
|
49
49
|
* `'abort'` — fire `upstreamAbort.abort()` (the v3.24-and-earlier default).
|
|
50
50
|
* `'drain'` — leave upstream alive; gate off client writes; let the
|
|
51
|
-
* read loop consume to EOF (
|
|
51
|
+
* read loop consume to EOF (until the stream goes quiet
|
|
52
|
+
* for UPSTREAM_TIMEOUT_MS).
|
|
52
53
|
*/
|
|
53
54
|
export declare function decideOnClientClose(writableEnded: boolean, upstreamAborted: boolean, drainOnClose: boolean): ClientCloseAction;
|
|
54
55
|
/**
|
package/dist/stream-drain.js
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` flips the tradeoff: when
|
|
16
16
|
* the downstream client disconnects, dario suppresses the upstream abort
|
|
17
17
|
* and keeps the reader loop spinning until the upstream emits its final
|
|
18
|
-
* event
|
|
19
|
-
*
|
|
18
|
+
* event, or until the upstream timeout ends a stream that has gone quiet
|
|
19
|
+
* (a dead upstream is not waited on). Writes to the closed `res` are gated off;
|
|
20
20
|
* the reads and any accumulator state (analytics, tool-map) continue so
|
|
21
21
|
* the captured usage numbers are complete rather than truncated.
|
|
22
22
|
*
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
* `'noop'` — already finished / already aborted; handler should return.
|
|
48
48
|
* `'abort'` — fire `upstreamAbort.abort()` (the v3.24-and-earlier default).
|
|
49
49
|
* `'drain'` — leave upstream alive; gate off client writes; let the
|
|
50
|
-
* read loop consume to EOF (
|
|
50
|
+
* read loop consume to EOF (until the stream goes quiet
|
|
51
|
+
* for UPSTREAM_TIMEOUT_MS).
|
|
51
52
|
*/
|
|
52
53
|
export function decideOnClientClose(writableEnded, upstreamAborted, drainOnClose) {
|
|
53
54
|
if (writableEnded || upstreamAborted)
|
package/dist/tui/tabs/config.js
CHANGED
|
@@ -38,7 +38,7 @@ const FIELDS = [
|
|
|
38
38
|
{ path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
|
|
39
39
|
{ path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
|
|
40
40
|
{ path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
|
|
41
|
-
{ path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default), "expiring-first" or "fill-first"' },
|
|
41
|
+
{ path: 'pool.strategy', label: 'Pool strategy', type: 'string', hint: '"headroom" (default), "headroom-by-plan", "expiring-first" or "fill-first"' },
|
|
42
42
|
// ── Overage-guard (v4.1, dario#288) ─────────────────────────
|
|
43
43
|
{ path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
|
|
44
44
|
{ path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
|
|
@@ -271,7 +271,7 @@ function commitEdit(state) {
|
|
|
271
271
|
*/
|
|
272
272
|
const STRING_ENUMS = {
|
|
273
273
|
'overageGuard.behavior': ['halt', 'warn'],
|
|
274
|
-
'pool.strategy': ['headroom', 'fill-first', 'expiring-first'],
|
|
274
|
+
'pool.strategy': ['headroom', 'headroom-by-plan', 'fill-first', 'expiring-first'],
|
|
275
275
|
};
|
|
276
276
|
function doSave(state) {
|
|
277
277
|
try {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Injectable clock and timers; tests drive them by hand. */
|
|
2
|
+
export interface DeadlineTimers {
|
|
3
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
4
|
+
clearTimeout: (handle: unknown) => void;
|
|
5
|
+
/** A monotonic ms clock: a wall-clock jump must not end a live stream. */
|
|
6
|
+
now: () => number;
|
|
7
|
+
}
|
|
8
|
+
export interface UpstreamDeadline {
|
|
9
|
+
/**
|
|
10
|
+
* The response is a 2xx and its body is about to be read: from here `ms`
|
|
11
|
+
* is an idle gap, counted from now. Only the first call has an effect.
|
|
12
|
+
*/
|
|
13
|
+
streaming(): void;
|
|
14
|
+
/** A chunk arrived from upstream. Resets the idle gap; ignored before `streaming()`. */
|
|
15
|
+
touch(): void;
|
|
16
|
+
/** Disarm for good, without calling `onTimeout`. Safe to call more than once. */
|
|
17
|
+
clear(): void;
|
|
18
|
+
/** True once `onTimeout` has run. */
|
|
19
|
+
readonly fired: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Arms the headers phase at once. `ms` goes through clampTimerMs, so a value
|
|
23
|
+
* past Node's timer ceiling waits as long as a timer can, not 1 ms.
|
|
24
|
+
*/
|
|
25
|
+
export declare function upstreamDeadline(ms: number, onTimeout: () => void, timers?: DeadlineTimers): UpstreamDeadline;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The upstream timeout, in two phases.
|
|
3
|
+
*
|
|
4
|
+
* Until the response starts streaming, `ms` caps the whole wait: connect,
|
|
5
|
+
* send, and the time to first byte (a retry inside the same request counts
|
|
6
|
+
* against the same budget). Once a 2xx body is being read, `ms` is the
|
|
7
|
+
* longest gap allowed between two chunks instead. A single timer armed for
|
|
8
|
+
* the whole request used to abort agent turns that were still streaming
|
|
9
|
+
* after five minutes (max_tokens is 64000), and the mid-stream guard then
|
|
10
|
+
* re-sent the full prompt to continue, or truncated an open tool_use block.
|
|
11
|
+
* A stream that stops sending still dies `ms` after its last chunk.
|
|
12
|
+
*
|
|
13
|
+
* `touch()` runs once per chunk, so it only records the time: the timer is
|
|
14
|
+
* re-armed for the remainder when it fires early, not on every chunk.
|
|
15
|
+
*/
|
|
16
|
+
import { clampTimerMs } from './timer-ms.js';
|
|
17
|
+
const defaultTimers = {
|
|
18
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
19
|
+
clearTimeout: (h) => clearTimeout(h),
|
|
20
|
+
now: () => performance.now(),
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Arms the headers phase at once. `ms` goes through clampTimerMs, so a value
|
|
24
|
+
* past Node's timer ceiling waits as long as a timer can, not 1 ms.
|
|
25
|
+
*/
|
|
26
|
+
export function upstreamDeadline(ms, onTimeout, timers = defaultTimers) {
|
|
27
|
+
const limit = clampTimerMs(ms);
|
|
28
|
+
let phase = 'headers';
|
|
29
|
+
let lastChunkAt = 0;
|
|
30
|
+
let handle = null;
|
|
31
|
+
let fired = false;
|
|
32
|
+
const disarm = () => {
|
|
33
|
+
if (handle !== null) {
|
|
34
|
+
timers.clearTimeout(handle);
|
|
35
|
+
handle = null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const arm = (delay) => {
|
|
39
|
+
handle = timers.setTimeout(onFire, clampTimerMs(delay));
|
|
40
|
+
};
|
|
41
|
+
function onFire() {
|
|
42
|
+
handle = null;
|
|
43
|
+
if (phase === 'done')
|
|
44
|
+
return;
|
|
45
|
+
if (phase === 'idle') {
|
|
46
|
+
const idle = timers.now() - lastChunkAt;
|
|
47
|
+
if (idle < limit) {
|
|
48
|
+
arm(limit - idle);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
phase = 'done';
|
|
53
|
+
fired = true;
|
|
54
|
+
onTimeout();
|
|
55
|
+
}
|
|
56
|
+
arm(limit);
|
|
57
|
+
return {
|
|
58
|
+
streaming() {
|
|
59
|
+
if (phase !== 'headers')
|
|
60
|
+
return;
|
|
61
|
+
phase = 'idle';
|
|
62
|
+
lastChunkAt = timers.now();
|
|
63
|
+
disarm();
|
|
64
|
+
arm(limit);
|
|
65
|
+
},
|
|
66
|
+
touch() {
|
|
67
|
+
if (phase === 'idle')
|
|
68
|
+
lastChunkAt = timers.now();
|
|
69
|
+
},
|
|
70
|
+
clear() {
|
|
71
|
+
phase = 'done';
|
|
72
|
+
disarm();
|
|
73
|
+
},
|
|
74
|
+
get fired() { return fired; },
|
|
75
|
+
};
|
|
76
|
+
}
|
package/docs/analytics.md
CHANGED
|
@@ -8,13 +8,13 @@ Every surface below is a view over those two. Nothing here collects anything new
|
|
|
8
8
|
|
|
9
9
|
| path | what | format |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| `GET /analytics` | window summary + `queue` snapshot + `lifetime` ledger summary | JSON |
|
|
12
|
-
| `GET /analytics/ledger` | the ledger's per-day table | JSON |
|
|
11
|
+
| `GET /analytics` | window summary (with `window.perFiveMinutes`, the hour in twelve bins) + `queue` snapshot + `overage` (the halt state, for a reader who cannot open `/admin/resume`) + `lifetime` ledger summary | JSON |
|
|
12
|
+
| `GET /analytics/ledger` | the ledger's per-day table, with `priced`: the same rows as USD at each day's rate | JSON |
|
|
13
13
|
| `GET /analytics/stream` | live tail of request records (drives the TUI) | SSE |
|
|
14
14
|
| `GET /metrics` | the same state as Prometheus text exposition | text/plain 0.0.4 |
|
|
15
15
|
| `GET /analytics/donuts.svg` | spend by model / by key / by billing | SVG |
|
|
16
|
-
| `GET /analytics/view` | server-rendered
|
|
17
|
-
| `GET /analytics/ui` | the dashboard
|
|
16
|
+
| `GET /analytics/view` | the server-rendered summary (the page before 6.17; still served) | HTML fragment |
|
|
17
|
+
| `GET /analytics/ui` | the dashboard (no data of its own; asks for the token, then reads the routes above, `/accounts`, `/codex` and `/health` with it and tails `/analytics/stream`) | HTML page |
|
|
18
18
|
|
|
19
19
|
CLI: `dario usage` prints the ledger; `--json` dumps `/analytics`; `--card[=file]` writes the share card; `--donut[=file]` writes the three rings.
|
|
20
20
|
|
|
@@ -24,7 +24,7 @@ By default the proxy binds loopback and these paths need no credential. With `DA
|
|
|
24
24
|
|
|
25
25
|
`--analytics-token=<secret>` (or `DARIO_ANALYTICS_TOKEN`) adds a **read-only** credential accepted on exactly the paths in the table above, on `GET` only. It is refused on `/v1/*`, `/accounts`, `/status`, `/admin/*`, and any non-GET. That is the point: a Grafana box or a browser tab can hold the numbers without holding request rights. The root key keeps working on the analytics paths too. On an unkeyed proxy the token gates nothing, and the proxy says so at startup.
|
|
26
26
|
|
|
27
|
-
`/analytics/ui` itself is served without a credential because it contains no data. It stores the token you type in `sessionStorage` and sends it as a bearer on every
|
|
27
|
+
`/analytics/ui` itself is served without a credential because it contains no data. It stores the token you type in `sessionStorage` and sends it as a bearer on every read. With the analytics token it shows spend, routing, the live feed and the ledger; the seats panel needs an API key, because `/accounts` and `/codex` refuse the read-only token, and the keys panel needs the admin API (`DARIO_ADMIN=1`, the admin token). The page loads nothing from outside the proxy, follows the system theme (with a toggle), has a table view behind every chart, and opened from disk (`dist/analytics-ui.html`) it runs on sample data.
|
|
28
28
|
|
|
29
29
|
## `/metrics` families
|
|
30
30
|
|
|
@@ -35,19 +35,21 @@ Names ending in `_total` are counters; everything else is a gauge. Labels are es
|
|
|
35
35
|
| `dario_info` | `version` | always 1 |
|
|
36
36
|
| `dario_requests_total`, `dario_tokens_total{kind}`, `dario_estimated_cost_usd_total`, `dario_error_rate` | `kind` ∈ input, output, cache_read, cache_create, thinking | since start |
|
|
37
37
|
| `dario_window_requests`, `_avg_latency_ms`, `_error_rate`, `_cached_prompt_percent`, `_estimated_cost_usd` | `window_minutes` | rolling window |
|
|
38
|
-
| `dario_window_billing_requests` | `bucket` ∈ subscription, subscription_fallback, extra_usage, api, unknown | claims folded by `billingBucketFromClaim` |
|
|
38
|
+
| `dario_window_billing_requests` | `bucket` ∈ subscription, subscription_fallback, extra_usage, api, credits, unknown | claims folded by `billingBucketFromClaim` |
|
|
39
39
|
| `dario_account_requests_total`, `dario_account_estimated_cost_usd`, `dario_account_utilization{window}` | `account`, `window` ∈ 5h, 7d | last rate-limit headers per seat |
|
|
40
40
|
| `dario_model_requests_total`, `dario_model_estimated_cost_usd` | `model` | since start |
|
|
41
41
|
| `dario_consumer_requests_total`, `dario_consumer_estimated_cost_usd` | `consumer` (named key or `x-dario-consumer`) | since start |
|
|
42
|
-
| `dario_queue_active`, `_queued`, `_max_concurrent`, `_max_queued`, `_stalled`, `_max_wait_ms`, `_consumers_active` |
|
|
42
|
+
| `dario_queue_active`, `_queued`, `_max_concurrent`, `_max_queued`, `_stalled`, `_max_wait_ms`, `_consumers_active` | (none) | request queue |
|
|
43
43
|
| `dario_request_latency_ms{quantile}` + `_sum`, `_count` | `quantile` ∈ 0.5, 0.9, 0.99 | nearest-rank over the most recent 1,000 records |
|
|
44
44
|
| `dario_queue_wait_ms`, `dario_pacing_wait_ms`, `dario_upstream_ttfb_ms`, `dario_upstream_latency_ms`, `dario_overhead_ms` — each `{quantile}` + `_sum`, `_count` | `quantile` ∈ 0.5, 0.9, 0.99 | the timing split (below), nearest-rank over the recent records that carry one |
|
|
45
45
|
| `dario_window_avg_upstream_ttfb_ms`, `_avg_upstream_latency_ms`, `_avg_overhead_ms`, `_avg_queue_wait_ms`, `_avg_pacing_wait_ms` | `window_minutes` | the split averaged over the rolling window; omitted until a request has carried one |
|
|
46
|
-
| `dario_predicted_exhaustion_minutes` (omitted when unknown), `dario_burn_tokens_per_minute`, `dario_burn_cost_usd_per_minute` |
|
|
46
|
+
| `dario_predicted_exhaustion_minutes` (omitted when unknown), `dario_burn_tokens_per_minute`, `dario_burn_total_tokens_per_minute`, `dario_burn_cost_usd_per_minute` | (none) | window predictions |
|
|
47
47
|
| `dario_ledger_requests_total`, `_api_equivalent_usd`, `_metered_usd`, `_recent_api_equivalent_usd{window}`, `_model_api_equivalent_usd{model,provider}`, `_model_requests_total{model,provider}`, `_consumer_api_equivalent_usd{consumer}` | `window` ∈ today, 7d, 30d | ledger (absent when the ledger is off) |
|
|
48
48
|
|
|
49
49
|
`dario_request_latency_ms` is the number dario has always kept: dispatch to response end, as the client saw it. Since 6.9 every request also carries the split below, so the one figure can be read as its parts.
|
|
50
50
|
|
|
51
|
+
The two token burn gauges differ in scope: `dario_burn_tokens_per_minute` is input and output only, cache reads and writes are not in it, while `dario_burn_total_tokens_per_minute` counts every kind. On a cached prompt the second is many times the first. `/analytics` carries the same pair as `predictions.tokenBurnRate` and `predictions.totalTokenBurnRate`.
|
|
52
|
+
|
|
51
53
|
## The timing split
|
|
52
54
|
|
|
53
55
|
A request's wall-clock time through dario is five stamps (`src/timing.ts`), all in milliseconds, all on dario's clock:
|
|
@@ -36,7 +36,10 @@ Two columns per row:
|
|
|
36
36
|
- **metered** — billed per token anyway: an API key upstream (`api`) or
|
|
37
37
|
Anthropic's paid overage (`overage` → `extra_usage`). That money was spent.
|
|
38
38
|
It is reported on its own line ("Paid per token on top") and never counted
|
|
39
|
-
as saved.
|
|
39
|
+
as saved. The credits rung of the failover chain (`credits:<model>`) lands
|
|
40
|
+
in this column too: those credits were spent. The ledger keeps the two
|
|
41
|
+
columns only; the rolling window and the dashboard tell credits, API key
|
|
42
|
+
and extra usage apart.
|
|
40
43
|
|
|
41
44
|
A mid-stream continuation (6.1) is two upstream requests and records as two
|
|
42
45
|
rows, one per provider, each with the tokens that leg actually consumed.
|
|
@@ -124,7 +127,11 @@ by the anonymous traffic.
|
|
|
124
127
|
```
|
|
125
128
|
|
|
126
129
|
`GET /analytics/ledger` is the file itself: `{ path, version, since, updated,
|
|
127
|
-
days: { "YYYY-MM-DD": { "<model>": { covered: {…}, metered: {…} } } } }
|
|
130
|
+
days: { "YYYY-MM-DD": { "<model>": { covered: {…}, metered: {…} } } } }`, plus
|
|
131
|
+
`priced`, the same rows as money: `{ days: { "YYYY-MM-DD": { "<model>": {
|
|
132
|
+
covered: 1.23, metered: 0.04 } } }, consumers: {…} }`, each bucket priced at
|
|
133
|
+
that day's rate by the function the summary uses, so a reader charting the
|
|
134
|
+
table (the dashboard, a script) needs no price list of its own.
|
|
128
135
|
|
|
129
136
|
## Pricing on the ChatGPT leg
|
|
130
137
|
|
package/docs/commands.md
CHANGED
|
@@ -53,7 +53,7 @@ This page is the per-flag reference. For environment variables grouped by task
|
|
|
53
53
|
| `--session-rotate-jitter=<ms>` / `DARIO_SESSION_JITTER_MS` | Jitter sampled once per session at creation — hides the exact idle floor. (v3.28) | `0` |
|
|
54
54
|
| `--session-max-age=<ms>` / `DARIO_SESSION_MAX_AGE_MS` | Hard ceiling on a session-id's lifetime regardless of activity. (v3.28) | off |
|
|
55
55
|
| `--session-per-client` / `DARIO_SESSION_PER_CLIENT=1` | Split session-id registry by a per-client header so multi-UI fan-out doesn't collapse onto one id. (v3.28) | off |
|
|
56
|
-
| `--pool-strategy=<headroom\|fill-first\|expiring-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the floor, then spills to the next
|
|
56
|
+
| `--pool-strategy=<headroom\|headroom-by-plan\|fill-first\|expiring-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the floor, then spills to the next: primary/backup semantics, alias naming (`1-main`, `2-overflow`) picks the fill order; `expiring-first` does the same in order of each seat's 7-day reset, soonest first, so capacity that expires soonest is spent first. `headroom-by-plan` (opt-in) is headroom with each seat's slack counted by plan size (Max Nx counts N, Pro 1). Sticky bindings behave identically under all of them. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
|
|
57
57
|
| `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
|
|
58
58
|
| `--family-fallback=<family=model,...>` / `DARIO_FAMILY_FALLBACK` / config `familyFallback` | Strictly opt-in. When a rate limit keeps every pool seat off a model family, serve that family's requests as `<model>` on the Claude pool, while some seat can serve the target's family. Written for Fable: its included credit runs out while the seat still has room for Opus, so `fable=claude-opus-5-5` keeps Fable traffic on the subscription instead of a local 429 until the credit resets. The target must be a `claude-*` model of another family; one step, never chained. Runs before `--pool-fallback`, so a pool with room for the target never leaves the Claude pool. Skipped in upstream API-key mode, under `--model`/`--fast-model` and for a seat-pinned request. A `[1m]` tag on the request carries over. Every switched response carries `x-dario-family-fallback: <requested model>`. Empty flag value disables, overriding env + config. See [Family fallback](./multi-account-pool.md#family-fallback). | off |
|
|
59
59
|
| `--system-prompt=<verbatim\|partial\|aggressive\|filepath>` / `DARIO_SYSTEM_PROMPT` | System-prompt mode for outbound CC-shaped requests. `partial` strips behavioral constraints (Tone-and-style, Text-output, scope/verbosity/comment bullets) for ~1.2–2.8× output capability on open-ended work. `aggressive` adds prompt-level RLHF restatement removal (<3% over partial — alignment is RLHF-trained). `<filepath>` fully replaces the slot with file contents. Empirically validated as unfingerprinted by the billing classifier — see [`system-prompt.md`](./system-prompt.md) and [`research/system-prompt-classifier-study.md`](./research/system-prompt-classifier-study.md). (v3.34) | `verbatim` |
|
package/docs/configuration.md
CHANGED
|
@@ -41,6 +41,7 @@ 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_UPSTREAM_TIMEOUT_MS` | `--upstream-timeout=MS` | `300000` (5 min) | until the response streams, the whole wait for upstream (504 `upstream-timeout`); while a 2xx streams, the longest gap between two chunks, so a long answer that is still sending is never cut. `0` keeps the default |
|
|
44
45
|
| `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
|
|
|
46
47
|
## Named keys
|
package/docs/faq.md
CHANGED
|
@@ -30,6 +30,11 @@ Two questions to verify at any time — and the exact check to run the day a rev
|
|
|
30
30
|
|
|
31
31
|
No config change is needed on the user side, split or no split — same install, same `localhost:3456`, same `ANTHROPIC_BASE_URL=http://localhost:3456` env var.
|
|
32
32
|
|
|
33
|
+
**Max plans now include monthly API credits. Do I still need dario?**
|
|
34
|
+
Since October 2026 a Max 5x plan includes $100 of Claude API credits a month and Max 20x includes $200 ([Monthly API credits for Max and Team plans](https://support.claude.com/en/articles/17154008)). They are spent through a Console API key on the Claude API and the Agent SDK, not in Claude Code, they expire at the end of each billing cycle, and when they are gone API requests stop. Your seat's own five-hour and weekly windows are a different bucket, and Anthropic's note on the change says third-party apps may still use them ([the October 7 update](https://support.claude.com/en/articles/15036540)). dario is how every tool draws on the seat first, so the credits are not the only thing between you and a meter. Run `dario usage` for a week: the API-equivalent figure is what your seat absorbed, and the comparison with $100 or $200 is yours to make.
|
|
35
|
+
|
|
36
|
+
The credits can be the last rung of the failover chain. Put the Console key in `DARIO_CREDITS_API_KEY` and name the rung in the chain, `--pool-fallback=credits:claude-sonnet-5`: when every seat is parked, the request goes to the Claude API on that key as a plain API request, both wire shapes, the response says `x-dario-pool-fallback: credits:claude-sonnet-5`, `/analytics` and the dashboard show it under its own bucket, `credits`, and the ledger counts it as metered, so the receipt stays honest ([failover](./two-plans.md#failover-between-subscriptions)). A metered openai-compat backend can be a rung the same way, shown under API key. Neither is on until you name it.
|
|
37
|
+
|
|
33
38
|
**Do I need Claude Code installed?**
|
|
34
39
|
Recommended for the Claude backend, not strictly required. With CC installed, `dario login` picks up your credentials automatically, and the live template extractor reads your CC binary on every startup so the template stays current. Without CC, dario runs its own OAuth flow and falls back to the bundled template snapshot (scrubbed of host context at bake time as of v3.21). Drift detection warns you if your installed CC doesn't match the captured template, so upgrade windows don't silently ship stale templates.
|
|
35
40
|
|
|
@@ -33,6 +33,8 @@ Alias order is the operator's knob: name seats `1-main` / `2-overflow` to pick t
|
|
|
33
33
|
|
|
34
34
|
`--pool-strategy=expiring-first` is fill-first ordered by **when each seat's capacity expires** instead of by alias: new conversations fill the seat whose 7-day window resets soonest (`anthropic-ratelimit-unified-7d-reset`) until it drains to the floor, then spill to the next-soonest; failover follows the same order. Subscription capacity is use-it-or-lose-it, so this is the order that gets the most of it used — the case it was built for is a seat that just got a limit reset, or joined the pool mid-week, sitting beside one whose window has days left. A seat with no 7-day reading yet, or whose window has already rolled, goes last; ties break by alias, so a pool with no readings behaves exactly like fill-first.
|
|
35
35
|
|
|
36
|
+
`--pool-strategy=headroom-by-plan` is headroom with each seat's headroom counted by its plan size: a Max Nx seat counts N times (from the profile's `rate_limit_tier`), Pro and any plan dario cannot read count once. A Max 20x seat at 30% headroom then outranks a Pro at 90%. It is opt-in and never the default, because the ratios dario reads are account-wide (they include the owner's own use of the seat), no absolute capacity reaches the wire, and the window that binds matters more than the plan name: a smaller plan often has more real room. Use it when your seats are otherwise idle and you want the bigger plan to carry proportionally more. Floors, parking and sticky bindings work exactly as under `headroom`.
|
|
37
|
+
|
|
36
38
|
## Session stickiness
|
|
37
39
|
|
|
38
40
|
Multi-turn agent sessions pin to one account for the life of the conversation, so the Anthropic prompt cache isn't destroyed by account rotation between turns.
|
|
@@ -202,7 +204,7 @@ Where it shows: `GET /analytics` gains `perConsumer` (requests, tokens, cache sh
|
|
|
202
204
|
|
|
203
205
|
**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.
|
|
204
206
|
|
|
205
|
-
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.
|
|
207
|
+
Every request carries a `billingBucket` field (`subscription` / `subscription_fallback` / `extra_usage` / `api` / `credits` / `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.
|
|
206
208
|
|
|
207
209
|
## Refresh-token grant age
|
|
208
210
|
|
|
@@ -73,6 +73,8 @@ The rolling window forgets on every restart; the **ledger** does not. Since 6.6
|
|
|
73
73
|
|
|
74
74
|
Only served requests count. Traffic that was metered anyway — an API key upstream, or Anthropic's paid `extra_usage` overage — is kept in its own column and reported as spent, not saved. `dario usage --card` writes the headline as a 640×320 SVG you can drop in a README or a post, and `--donut` writes the same number as three rings — by model, by key, subscription vs metered; `--no-ledger` / `DARIO_LEDGER=0` turns the file off, `DARIO_LEDGER_PATH` moves it, and `GET /analytics/ledger` is the per-day table behind the number. Details: [api-equivalent-spend.md](api-equivalent-spend.md).
|
|
75
75
|
|
|
76
|
+
A request served on a Max plan's API credits (the `credits:<model>` rung of the failover chain, key in `DARIO_CREDITS_API_KEY`, [failover](two-plans.md#failover-between-subscriptions)) is counted as spent the same way in the ledger; `/analytics`, `/metrics` and the dashboard show it under its own bucket, `credits`.
|
|
77
|
+
|
|
76
78
|
**Scrape it, or open it.** `GET /metrics` is the same state as Prometheus text exposition — window, seats, models, consumers, queue, latency quantiles, burn rates, ledger — so Grafana reads dario like anything else. Since 6.9 every request also carries its **timing split**: queue wait, governor sleep, the provider's time to first byte, the provider's total, and what is left — dario's own overhead — as `x-dario-*-ms` response headers on the request itself, as `window.timing` on `/analytics`, and as five more `/metrics` families, so "was that Anthropic or the proxy?" is a `curl -i` away ([details](analytics.md#the-timing-split)). `GET /analytics/ui` is a self-contained dashboard page with the headline, the rings and the tables, refreshing every minute. Both sit behind the same gate as `/analytics`; `--analytics-token` (env `DARIO_ANALYTICS_TOKEN`) adds a **read-only** credential accepted on those paths and nowhere else, so a scraper or a browser can hold the numbers without holding request rights. Families and the gate: [analytics.md](analytics.md).
|
|
77
79
|
|
|
78
80
|
---
|
package/docs/two-plans.md
CHANGED
|
@@ -44,6 +44,8 @@ That is a **chain**, read left to right; each provider takes the first entry it
|
|
|
44
44
|
|
|
45
45
|
A single-entry chain is one-way and means what it always meant, so an existing config is unaffected. Failover is opt-in: without `--pool-fallback`, a drained pool still returns its honest 429/503. Only a **429 or 5xx** fails over; a 400 surfaces, because a bad request that fails over just reproduces itself on the other provider and buries the real cause. A 429 also cools that provider for a bounded interval, its `retry-after` if it sent one and 60 s otherwise, never longer than 15 min, and an entry that already declined is not asked again within the same request. When every entry is cooling, the request ends on one honest `429` with a `retry-after` instead of a retry storm. The Claude entry has to be a model the pool can actually serve, checked positively against the live catalog, so a typo can't trade a recoverable 429 for an unrecoverable 404.
|
|
46
46
|
|
|
47
|
+
An entry written `credits:<model>` is a rung on the API credits a Max plan includes ($100 a month on Max 5x, $200 on Max 20x, spent through a Console key). It is opt-in twice: the entry has to be in the chain, and the key has to be in `DARIO_CREDITS_API_KEY` (env only, never a flag, so it never lands in argv). When every seat is drained or cooling and no subscription rung can serve, the request goes to `api.anthropic.com` on the Messages path as a plain API request: the client's own body with the model swapped, `x-api-key`, no Claude Code template and no OAuth beta. Both wire shapes work, since the Claude path already translates OpenAI-shape requests. The response says `x-dario-pool-fallback: credits:<model>`. The rolling window, `/metrics` and the dashboard file the request under its own billing bucket, `credits`, next to extra usage and API key; the persistent ledger counts it in its metered total, as spent, the way it counts an API key. The overage guard does not halt on it: a rung you configured is not a surprise bill. A subscription rung always goes first; between the credits rung and an openai-compat backend, the chain's own left-to-right order decides. Tier maps take it too (`opus:credits:claude-opus-5`). Without the key the entry is inert, the startup line says so and `dario doctor` says so in its Failover line.
|
|
48
|
+
|
|
47
49
|
The chain also covers a stream that dies **mid-answer**. Until 6.1 that was the one failure nothing could catch: bytes were on the wire, so the socket reset, the in-band `overloaded_error`, the codex `response.failed` all ended the stream where they happened, with no `message_stop`, and the client threw away every word it already had. Now dario finishes the same stream — on the same model first, a fresh request through its own front door; on the other subscription when that delivers nothing or dies too. The resume picks up inside the still-open content block, a comment marks each seam (`: dario continuation claude-opus-5 (same model) after 1240 chars`), and the client sees one message. The model is asked to repeat the last few words verbatim and dario trims the repeat, so the join is rendered by a model and cut by a parser, never guessed. Text only — a cut inside a tool call ends as it always did. On by default, `--no-midstream-continue` turns it off; without a chain the second hop is simply not there. [How it works](midstream-continuation.md).
|
|
48
50
|
|
|
49
51
|
`dario doctor` tells you which of these you are actually in:
|
package/docs/who-its-for.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Who it's for
|
|
2
2
|
|
|
3
|
-
**Best fit:** developers juggling multiple LLM tools and per-tool API keys · Claude Pro/Max subscribers who want their plan usable everywhere, not just in Claude Code · ChatGPT Plus/Pro subscribers who want their plan in OpenAI-compatible harnesses · teams running local or hosted OpenAI-compat servers who want one stable local endpoint · Agent SDK users who want subscription routing with zero code change · power users wanting multi-account pooling with 429 failover.
|
|
3
|
+
**Best fit:** developers juggling multiple LLM tools and per-tool API keys · Claude Pro/Max subscribers who want their plan usable everywhere, not just in Claude Code · ChatGPT Plus/Pro subscribers who want their plan in OpenAI-compatible harnesses · teams running local or hosted OpenAI-compat servers who want one stable local endpoint · Agent SDK users who want subscription routing with zero code change · power users wanting multi-account pooling with 429 failover · anyone who wants one number for what their plans absorbed across every tool.
|
|
4
4
|
|
|
5
5
|
**Not a fit:** you need vendor-managed production SLAs (use the provider APIs) · you want a hosted multi-tenant team platform with dashboards and SSO (dario is a single-owner local proxy) · you want a chat UI (use claude.ai).
|
|
6
6
|
|
|
7
|
-
**How it compares.** Only one of these routes a consumer subscription; the others route API keys, and that is the whole split.
|
|
7
|
+
**How it compares.** Only one of these routes a consumer subscription and prices every request against the API rate card; the others route API keys, and that is the whole split.
|
|
8
8
|
|
|
9
9
|
| Tool | What it is | When it wins |
|
|
10
10
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK
|
|
3
|
+
"version": "6.17.0",
|
|
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, opt-in failover when one hits its limit, and a ledger of what each plan absorbed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dario": "./dist/cli.js"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "tsc && cp src/cc-template-data.json dist/",
|
|
25
|
+
"build": "tsc && cp src/cc-template-data.json src/analytics-ui.html dist/",
|
|
26
26
|
"test": "node --test test/all.test.mjs",
|
|
27
27
|
"test:serial": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/live-fingerprint.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/cc-authorize-probe-classifier.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/doctor-identity-drift.mjs && node test/doctor-oauth-pool.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/durable-token-persist.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs && node test/context-bleed.mjs && node test/passthrough-header-forwarding.mjs && node test/capture-provenance.mjs && node test/sanitize-messages.mjs && node test/platform-tools.mjs && node test/strict-template-flags.mjs && node test/request-queue.mjs && node test/effort-flag.mjs && node test/template-invariants.mjs",
|
|
28
28
|
"audit": "npm audit --production --audit-level=high",
|
|
@@ -71,7 +71,11 @@
|
|
|
71
71
|
"ollama",
|
|
72
72
|
"groq",
|
|
73
73
|
"cli",
|
|
74
|
-
"developer-tools"
|
|
74
|
+
"developer-tools",
|
|
75
|
+
"usage-ledger",
|
|
76
|
+
"dashboard",
|
|
77
|
+
"api-credits",
|
|
78
|
+
"rate-limits"
|
|
75
79
|
],
|
|
76
80
|
"author": "askalf (https://github.com/askalf)",
|
|
77
81
|
"license": "MIT",
|