@askalf/dario 6.8.11 → 6.8.13
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 +3 -1
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +50 -2
- package/dist/codex-backend.js +5 -1
- package/dist/donuts.d.ts +46 -0
- package/dist/donuts.js +176 -0
- package/dist/metrics.d.ts +25 -0
- package/dist/metrics.js +116 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +72 -1
- package/docs/analytics.md +60 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -407,7 +407,9 @@ The rolling window forgets on every restart; the **ledger** does not. Since 6.6
|
|
|
407
407
|
Today $48.20 · Last 7d $413 · Last 30d $413
|
|
408
408
|
```
|
|
409
409
|
|
|
410
|
-
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; `--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](./docs/api-equivalent-spend.md).
|
|
410
|
+
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](./docs/api-equivalent-spend.md).
|
|
411
|
+
|
|
412
|
+
**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. `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](./docs/analytics.md).
|
|
411
413
|
|
|
412
414
|
## It tracks a moving target
|
|
413
415
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* dario logout — Remove saved credentials
|
|
11
11
|
*/
|
|
12
12
|
import { type EffortValue } from './cc-template.js';
|
|
13
|
+
/**
|
|
14
|
+
* Bare words after `proxy` (dario#1353). `dario proxy` takes flags only, but it
|
|
15
|
+
* read them by prefix and ignored everything else, so `dario proxy status`,
|
|
16
|
+
* typed by someone expecting a report, started a full proxy and ran the OAuth
|
|
17
|
+
* refresh timer against the shared credential for five days. Anything that is
|
|
18
|
+
* not a flag is an error now; the one obvious guess is an alias for the report.
|
|
19
|
+
*/
|
|
20
|
+
export declare function strayProxyArgs(argv: readonly string[]): string[];
|
|
13
21
|
/**
|
|
14
22
|
* Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
|
|
15
23
|
* `DARIO_SYSTEM_PROMPT` env-var fallback) into the value passed through
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// other startup side effect.
|
|
19
19
|
import { unlink, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
|
|
21
|
+
import { renderSpendDonuts } from './donuts.js';
|
|
21
22
|
import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE } from './keys.js';
|
|
22
23
|
import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
|
|
23
24
|
import { maskEmail, parsePoolHeadroomFloor } from './pool.js';
|
|
@@ -299,7 +300,28 @@ async function logout() {
|
|
|
299
300
|
}
|
|
300
301
|
}
|
|
301
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Bare words after `proxy` (dario#1353). `dario proxy` takes flags only, but it
|
|
305
|
+
* read them by prefix and ignored everything else, so `dario proxy status`,
|
|
306
|
+
* typed by someone expecting a report, started a full proxy and ran the OAuth
|
|
307
|
+
* refresh timer against the shared credential for five days. Anything that is
|
|
308
|
+
* not a flag is an error now; the one obvious guess is an alias for the report.
|
|
309
|
+
*/
|
|
310
|
+
export function strayProxyArgs(argv) {
|
|
311
|
+
// The command token is the first `proxy`, wherever it sits: `--no-tui` is a
|
|
312
|
+
// global flag that may precede it (review on dario#1353). A second bare
|
|
313
|
+
// `proxy` is a stray word like any other.
|
|
314
|
+
const command = argv.indexOf('proxy');
|
|
315
|
+
return argv.filter((a, i) => i !== command && !a.startsWith('-'));
|
|
316
|
+
}
|
|
302
317
|
async function proxy() {
|
|
318
|
+
const stray = strayProxyArgs(args);
|
|
319
|
+
if (stray.length === 1 && stray[0] === 'status')
|
|
320
|
+
return status();
|
|
321
|
+
if (stray.length > 0) {
|
|
322
|
+
console.error(`[dario] Unknown proxy argument "${stray[0]}". \`dario proxy\` takes flags only (--port=, --host=, ...); nothing was started. For a report run \`dario status\`.`);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
303
325
|
// v4: load ~/.dario/config.json once at startup so file-stored values
|
|
304
326
|
// serve as defaults below where no CLI flag / env var supplies one.
|
|
305
327
|
// Precedence per M1: defaults < file < env < CLI. Missing-file is
|
|
@@ -372,6 +394,10 @@ async function proxy() {
|
|
|
372
394
|
// proxies). Stops dario rotating a shared refresh token out from under an
|
|
373
395
|
// interactive Claude Code on the same machine.
|
|
374
396
|
const noClaudeAuth = args.includes('--no-claude-auth');
|
|
397
|
+
// Read-only token for /analytics*, /metrics and the /analytics/ui page:
|
|
398
|
+
// a scraper or a browser gets the numbers, never a request slot.
|
|
399
|
+
const analyticsTokenArg = args.find(a => a.startsWith('--analytics-token='));
|
|
400
|
+
const analyticsToken = analyticsTokenArg ? analyticsTokenArg.slice('--analytics-token='.length) : undefined;
|
|
375
401
|
const modelArg = args.find(a => a.startsWith('--model='));
|
|
376
402
|
const model = modelArg ? modelArg.split('=')[1] : undefined;
|
|
377
403
|
// --fast-model=MODEL: route Haiku-tier (CC sub-agent) requests to this
|
|
@@ -678,7 +704,7 @@ async function proxy() {
|
|
|
678
704
|
console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
|
|
679
705
|
process.exit(1);
|
|
680
706
|
}
|
|
681
|
-
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
|
|
707
|
+
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
|
|
682
708
|
}
|
|
683
709
|
/**
|
|
684
710
|
* `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
|
|
@@ -1716,7 +1742,9 @@ async function help() {
|
|
|
1716
1742
|
existing credentials and runs a fresh OAuth
|
|
1717
1743
|
flow — for when the refresh token is dead and
|
|
1718
1744
|
/health still reports access-token countdown.
|
|
1719
|
-
dario proxy [options] Start the API proxy server
|
|
1745
|
+
dario proxy [options] Start the API proxy server. Flags only: a bare
|
|
1746
|
+
word after "proxy" is an error, and "dario proxy
|
|
1747
|
+
status" prints the report instead of starting.
|
|
1720
1748
|
dario status Check authentication status
|
|
1721
1749
|
dario refresh Force token refresh
|
|
1722
1750
|
dario resume Clear the overage-guard halt on a running proxy.
|
|
@@ -1851,6 +1879,9 @@ async function help() {
|
|
|
1851
1879
|
down). --card[=file.svg] writes a share
|
|
1852
1880
|
card of that number (default
|
|
1853
1881
|
dario-api-equivalent.svg). (v6.6)
|
|
1882
|
+
--donut[=file.svg] writes it as three
|
|
1883
|
+
rings: by model, by key, subscription
|
|
1884
|
+
vs metered (default dario-spend-donuts.svg).
|
|
1854
1885
|
--by-key splits the lifetime number per
|
|
1855
1886
|
consumer: named key, x-dario-consumer
|
|
1856
1887
|
header, or hashed user id. (v6.8)
|
|
@@ -1876,6 +1907,11 @@ async function help() {
|
|
|
1876
1907
|
instead of --model, so Claude Code's cheap
|
|
1877
1908
|
sub-agents aren't upgraded to the forced model.
|
|
1878
1909
|
Same MODEL forms as --model. No effect unless set.
|
|
1910
|
+
--analytics-token=TOKEN Read-only credential for /analytics*, /metrics and
|
|
1911
|
+
the /analytics/ui page (env DARIO_ANALYTICS_TOKEN).
|
|
1912
|
+
Refused on /v1/*, /accounts, /status, /admin/*:
|
|
1913
|
+
a scraper or a browser gets the numbers, never
|
|
1914
|
+
a request slot. Gates nothing without DARIO_API_KEY.
|
|
1879
1915
|
--no-claude-auth Don't load or refresh the Claude OAuth token —
|
|
1880
1916
|
for OpenAI-only proxies (e.g. --model=openai:...).
|
|
1881
1917
|
Prevents dario rotating a shared refresh token out
|
|
@@ -2672,6 +2708,18 @@ async function usage() {
|
|
|
2672
2708
|
if (!asJson)
|
|
2673
2709
|
console.log(` Wrote ${cardPath} — ${formatUsd(lifetime.apiEquivalentCost)} API-equivalent since ${lifetime.since.slice(0, 10)}.`);
|
|
2674
2710
|
}
|
|
2711
|
+
// --donut / --donut=<file>: the three spend rings (by model, by key, by billing).
|
|
2712
|
+
const donutArg = args.find(a => a === '--donut' || a.startsWith('--donut='));
|
|
2713
|
+
const donutPath = donutArg ? (donutArg.includes('=') ? donutArg.slice('--donut='.length) : 'dario-spend-donuts.svg') : null;
|
|
2714
|
+
if (donutPath) {
|
|
2715
|
+
if (!lifetime) {
|
|
2716
|
+
console.error(` No lifetime numbers to draw${lifetimeNote ? ` (${lifetimeNote})` : ''}.`);
|
|
2717
|
+
process.exit(1);
|
|
2718
|
+
}
|
|
2719
|
+
await writeFile(donutPath, renderSpendDonuts(lifetime), 'utf8');
|
|
2720
|
+
if (!asJson)
|
|
2721
|
+
console.log(` Wrote ${donutPath} — spend by model, by key and by billing since ${lifetime.since.slice(0, 10)}.`);
|
|
2722
|
+
}
|
|
2675
2723
|
if (asJson) {
|
|
2676
2724
|
if (payload) {
|
|
2677
2725
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
package/dist/codex-backend.js
CHANGED
|
@@ -37,7 +37,11 @@ const CODEX_ORIGINATOR = 'codex_cli_rs';
|
|
|
37
37
|
* value tracks a released codex CLI. Bump it when the backend starts gating on
|
|
38
38
|
* a newer one — it is a constant precisely so that stays a one-line change.
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
// The backend gates newer models by the client version it is told: the same
|
|
41
|
+
// seat lists `gpt-6-astra` from 0.153.0 and not before, so an old default
|
|
42
|
+
// hides a model the plan already includes. Keep this at a released Codex CLI
|
|
43
|
+
// version.
|
|
44
|
+
export const CODEX_CLIENT_VERSION = process.env.DARIO_CODEX_CLIENT_VERSION || '0.155.0';
|
|
41
45
|
/**
|
|
42
46
|
* Which models a subscription may use is decided by the backend, not by us, and
|
|
43
47
|
* the set moves (it is per-account, and changes as new slugs ship). Hardcoded
|
package/dist/donuts.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend donuts — where the API-equivalent number comes from, as shares.
|
|
3
|
+
*
|
|
4
|
+
* Three rings from the ledger, no new data: by model, by consumer (named
|
|
5
|
+
* key / `x-dario-consumer`), and covered-vs-metered. Rendered as one SVG
|
|
6
|
+
* (`dario usage --donut`, `GET /analytics/donuts.svg`) and inside the
|
|
7
|
+
* server-rendered `/analytics/view` fragment that the `/analytics/ui` shell
|
|
8
|
+
* page loads. Same palette and frame as the share card in ledger.ts.
|
|
9
|
+
*
|
|
10
|
+
* Pure over a LedgerSummary / AnalyticsSummary so the geometry is testable
|
|
11
|
+
* without a proxy.
|
|
12
|
+
*/
|
|
13
|
+
import type { LedgerSummary } from './ledger.js';
|
|
14
|
+
import type { AnalyticsSummary } from './analytics.js';
|
|
15
|
+
import type { QueueSnapshot } from './request-queue.js';
|
|
16
|
+
/** What /analytics serves: the summary plus the queue snapshot riding along. */
|
|
17
|
+
export type AnalyticsView = AnalyticsSummary & {
|
|
18
|
+
queue?: QueueSnapshot;
|
|
19
|
+
};
|
|
20
|
+
export interface DonutSlice {
|
|
21
|
+
label: string;
|
|
22
|
+
value: number;
|
|
23
|
+
share: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const escapeHtml: (s: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Top `max` entries by value plus one "other" bucket; zero and negative
|
|
28
|
+
* values are dropped. Shares sum to 1 (or the array is empty).
|
|
29
|
+
*/
|
|
30
|
+
export declare function donutSlices(entries: Record<string, number>, max?: number): DonutSlice[];
|
|
31
|
+
/** One ring of arcs. A single slice is drawn as a full circle (an arc from a point to itself is empty). */
|
|
32
|
+
export declare function donutPaths(slices: readonly DonutSlice[], cx: number, cy: number, r: number, width: number): string;
|
|
33
|
+
/** The three-ring SVG. 640×320, the share card's frame. */
|
|
34
|
+
export declare function renderSpendDonuts(s: LedgerSummary): string;
|
|
35
|
+
/**
|
|
36
|
+
* The server-rendered fragment behind `/analytics/ui`: headline, the three
|
|
37
|
+
* rings, the rolling window, and a per-model table. Same gate as
|
|
38
|
+
* `/analytics`; the shell page fetches it with the token the viewer typed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function renderAnalyticsView(summary: AnalyticsView, lifetime: LedgerSummary | null, version: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* The static shell for `/analytics/ui`. Carries NO data — it is safe to
|
|
43
|
+
* serve without auth — and asks the viewer for the token once (kept in
|
|
44
|
+
* sessionStorage), then fetches `/analytics/view` every 60 s with it.
|
|
45
|
+
*/
|
|
46
|
+
export declare const ANALYTICS_UI_SHELL = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>dario analytics</title>\n<style>\n :root{color-scheme:dark}\n body{margin:0;background:#0a0a0f;color:#e5e7eb;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,\"Liberation Mono\",monospace;font-size:14px}\n header{display:flex;gap:12px;align-items:center;padding:14px 20px;border-bottom:1px solid #1f2937;background:linear-gradient(90deg,#7c3aed,#db2777) top/100% 4px no-repeat,#0a0a0f}\n header h1{font-size:14px;letter-spacing:2px;margin:0;color:#9ca3af;font-weight:600}\n header input{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px 10px;font:inherit;width:22em}\n header button{background:#7c3aed;color:#fff;border:0;border-radius:6px;padding:6px 12px;font:inherit;cursor:pointer}\n #status{color:#9ca3af;margin-left:auto}\n main{padding:20px;max-width:1100px;margin:0 auto}\n .headline{font-size:34px;font-weight:700;color:#fff;margin:6px 0 16px}\n .headline span{display:block;font-size:13px;font-weight:400;color:#9ca3af;margin-top:4px}\n .headline.muted{color:#6b7280}\n .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px}\n .stat{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:12px 14px}\n .stat .v{font-size:20px;font-weight:700;color:#fff}.stat .l{font-size:11px;color:#9ca3af;margin-top:2px}\n .rings svg{width:100%;max-width:640px;height:auto;display:block;margin:0 auto 18px}\n table{width:100%;border-collapse:collapse;margin:0 0 18px;font-size:12px}\n th,td{text-align:right;padding:6px 8px;border-bottom:1px solid #1f2937}th:first-child,td:first-child{text-align:left}\n th{color:#9ca3af;font-weight:500}\n .foot{color:#6b7280;font-size:11px}\n .err{color:#fca5a5;padding:20px;background:#1f1115;border:1px solid #7f1d1d;border-radius:12px}\n</style></head>\n<body>\n<header><h1>DARIO ANALYTICS</h1>\n <input id=\"tok\" type=\"password\" placeholder=\"analytics token or API key (blank on an unkeyed proxy)\" autocomplete=\"off\">\n <button id=\"go\">connect</button><span id=\"status\"></span></header>\n<main id=\"view\"></main>\n<script>\n(function(){\n var tok=document.getElementById('tok'),view=document.getElementById('view'),status=document.getElementById('status'),timer=null;\n try{tok.value=sessionStorage.getItem('dario.analytics.token')||''}catch(e){}\n function headers(){var h={};if(tok.value)h['Authorization']='Bearer '+tok.value;return h}\n function load(){\n fetch('/analytics/view',{headers:headers(),cache:'no-store'}).then(function(r){\n if(r.status===401){view.innerHTML='<div class=\"err\">401 \u2014 this proxy is keyed. Paste its analytics token (DARIO_ANALYTICS_TOKEN) or API key above.</div>';status.textContent='';return}\n if(!r.ok){view.innerHTML='<div class=\"err\">'+r.status+' from /analytics/view</div>';return}\n return r.text().then(function(html){view.innerHTML=html;status.textContent='live \u00B7 refreshes every 60 s';try{sessionStorage.setItem('dario.analytics.token',tok.value)}catch(e){}})\n }).catch(function(e){view.innerHTML='<div class=\"err\">'+String(e)+'</div>'});\n }\n function start(){if(timer)clearInterval(timer);load();timer=setInterval(load,60000)}\n document.getElementById('go').addEventListener('click',start);\n tok.addEventListener('keydown',function(e){if(e.key==='Enter')start()});\n start();\n})();\n</script>\n</body></html>\n";
|
package/dist/donuts.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { formatUsd } from './ledger.js';
|
|
2
|
+
const PALETTE = ['#7c3aed', '#db2777', '#2563eb', '#059669', '#d97706', '#0891b2'];
|
|
3
|
+
const OTHER = '#6b7280';
|
|
4
|
+
export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
5
|
+
/**
|
|
6
|
+
* Top `max` entries by value plus one "other" bucket; zero and negative
|
|
7
|
+
* values are dropped. Shares sum to 1 (or the array is empty).
|
|
8
|
+
*/
|
|
9
|
+
export function donutSlices(entries, max = 5) {
|
|
10
|
+
const rows = Object.entries(entries).filter(([, v]) => Number.isFinite(v) && v > 0).sort((a, b) => b[1] - a[1]);
|
|
11
|
+
const total = rows.reduce((n, [, v]) => n + v, 0);
|
|
12
|
+
if (total <= 0)
|
|
13
|
+
return [];
|
|
14
|
+
const head = rows.slice(0, max);
|
|
15
|
+
const rest = rows.slice(max).reduce((n, [, v]) => n + v, 0);
|
|
16
|
+
const slices = head.map(([label, value]) => ({ label, value, share: value / total }));
|
|
17
|
+
if (rest > 0)
|
|
18
|
+
slices.push({ label: 'other', value: rest, share: rest / total });
|
|
19
|
+
return slices;
|
|
20
|
+
}
|
|
21
|
+
const polar = (cx, cy, r, angle) => [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
|
|
22
|
+
/** One ring of arcs. A single slice is drawn as a full circle (an arc from a point to itself is empty). */
|
|
23
|
+
export function donutPaths(slices, cx, cy, r, width) {
|
|
24
|
+
if (slices.length === 0)
|
|
25
|
+
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#1f2937" stroke-width="${width}"/>`;
|
|
26
|
+
if (slices.length === 1)
|
|
27
|
+
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${PALETTE[0]}" stroke-width="${width}"/>`;
|
|
28
|
+
let start = -Math.PI / 2;
|
|
29
|
+
const out = [];
|
|
30
|
+
slices.forEach((s, i) => {
|
|
31
|
+
const sweep = s.share * 2 * Math.PI;
|
|
32
|
+
const end = start + sweep;
|
|
33
|
+
const [x1, y1] = polar(cx, cy, r, start);
|
|
34
|
+
const [x2, y2] = polar(cx, cy, r, end);
|
|
35
|
+
const large = sweep > Math.PI ? 1 : 0;
|
|
36
|
+
const color = s.label === 'other' ? OTHER : PALETTE[i % PALETTE.length];
|
|
37
|
+
out.push(`<path d="M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}" fill="none" stroke="${color}" stroke-width="${width}"/>`);
|
|
38
|
+
start = end;
|
|
39
|
+
});
|
|
40
|
+
return out.join('\n ');
|
|
41
|
+
}
|
|
42
|
+
const pct = (share) => `${Math.round(share * 100)}%`;
|
|
43
|
+
function ring(title, slices, cx, empty) {
|
|
44
|
+
const cy = 150;
|
|
45
|
+
const r = 62;
|
|
46
|
+
const width = 22;
|
|
47
|
+
const legend = slices.slice(0, 6).map((s, i) => {
|
|
48
|
+
const color = s.label === 'other' ? OTHER : PALETTE[i % PALETTE.length];
|
|
49
|
+
const y = 236 + i * 15;
|
|
50
|
+
const label = s.label.length > 18 ? s.label.slice(0, 17) + '…' : s.label;
|
|
51
|
+
return `<rect x="${cx - 95}" y="${y - 9}" width="9" height="9" rx="2" fill="${color}"/>` +
|
|
52
|
+
`<text x="${cx - 80}" y="${y}" font-size="11" fill="#d1d5db">${escapeHtml(label)}</text>` +
|
|
53
|
+
`<text x="${cx + 95}" y="${y}" font-size="11" fill="#9ca3af" text-anchor="end">${escapeHtml(formatUsd(s.value))} · ${pct(s.share)}</text>`;
|
|
54
|
+
}).join('\n ');
|
|
55
|
+
const centre = slices.length === 0
|
|
56
|
+
? `<text x="${cx}" y="${cy + 4}" font-size="11" fill="#6b7280" text-anchor="middle">${escapeHtml(empty)}</text>`
|
|
57
|
+
: `<text x="${cx}" y="${cy + 5}" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">${escapeHtml(formatUsd(slices.reduce((n, s) => n + s.value, 0)))}</text>`;
|
|
58
|
+
return `<text x="${cx}" y="58" font-size="12" fill="#9ca3af" text-anchor="middle" letter-spacing="1.5">${escapeHtml(title.toUpperCase())}</text>
|
|
59
|
+
${donutPaths(slices, cx, cy, r, width)}
|
|
60
|
+
${centre}
|
|
61
|
+
${legend}`;
|
|
62
|
+
}
|
|
63
|
+
/** The three-ring SVG. 640×320, the share card's frame. */
|
|
64
|
+
export function renderSpendDonuts(s) {
|
|
65
|
+
const byModel = donutSlices(Object.fromEntries(Object.entries(s.perModel).map(([m, v]) => [m, v.apiEquivalentCost + v.meteredCost])));
|
|
66
|
+
const byConsumer = donutSlices(Object.fromEntries(Object.entries(s.perConsumer).map(([c, v]) => [c, v.apiEquivalentCost + v.meteredCost])));
|
|
67
|
+
// Short on purpose: the legend column is 18 characters wide.
|
|
68
|
+
const byBilling = donutSlices({ 'subscription': s.apiEquivalentCost, 'metered': s.meteredCost });
|
|
69
|
+
const total = formatUsd(s.apiEquivalentCost + s.meteredCost);
|
|
70
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320" viewBox="0 0 640 320" role="img" aria-label="${escapeHtml(total)} of spend through dario, by model, by key and by billing">
|
|
71
|
+
<defs>
|
|
72
|
+
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
|
73
|
+
<stop offset="0" stop-color="#7c3aed"/>
|
|
74
|
+
<stop offset="1" stop-color="#db2777"/>
|
|
75
|
+
</linearGradient>
|
|
76
|
+
<clipPath id="card"><rect width="640" height="320" rx="20"/></clipPath>
|
|
77
|
+
</defs>
|
|
78
|
+
<rect width="640" height="320" rx="20" fill="#0a0a0f"/>
|
|
79
|
+
<rect x="0" y="0" width="640" height="6" fill="url(#accent)" clip-path="url(#card)"/>
|
|
80
|
+
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" fill="#e5e7eb">
|
|
81
|
+
<text x="40" y="34" font-size="12" fill="#9ca3af" letter-spacing="2">SPEND THROUGH DARIO · ${escapeHtml(total)} · since ${escapeHtml(s.since.slice(0, 10))}</text>
|
|
82
|
+
${ring('by model', byModel, 112, 'no traffic yet')}
|
|
83
|
+
${ring('by key', byConsumer, 320, 'no named keys')}
|
|
84
|
+
${ring('by billing', byBilling, 528, 'no traffic yet')}
|
|
85
|
+
<text x="600" y="308" font-size="11" fill="#6b7280" text-anchor="end">dario</text>
|
|
86
|
+
</g>
|
|
87
|
+
</svg>
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The server-rendered fragment behind `/analytics/ui`: headline, the three
|
|
92
|
+
* rings, the rolling window, and a per-model table. Same gate as
|
|
93
|
+
* `/analytics`; the shell page fetches it with the token the viewer typed.
|
|
94
|
+
*/
|
|
95
|
+
export function renderAnalyticsView(summary, lifetime, version) {
|
|
96
|
+
const w = summary.window;
|
|
97
|
+
const stat = (label, value) => `<div class="stat"><div class="v">${escapeHtml(value)}</div><div class="l">${escapeHtml(label)}</div></div>`;
|
|
98
|
+
const headline = lifetime
|
|
99
|
+
? `<div class="headline">${escapeHtml(formatUsd(lifetime.apiEquivalentCost))}<span> API-equivalent · covered by subscriptions · since ${escapeHtml(lifetime.since.slice(0, 10))} · ${lifetime.requests.toLocaleString('en-US')} requests</span></div>`
|
|
100
|
+
: '<div class="headline muted">ledger disabled <span>start without --no-ledger to keep lifetime spend</span></div>';
|
|
101
|
+
const rings = lifetime ? renderSpendDonuts(lifetime) : '';
|
|
102
|
+
const models = lifetime
|
|
103
|
+
? Object.entries(lifetime.perModel).sort((a, b) => (b[1].apiEquivalentCost + b[1].meteredCost) - (a[1].apiEquivalentCost + a[1].meteredCost))
|
|
104
|
+
: [];
|
|
105
|
+
const table = models.length
|
|
106
|
+
? `<table><thead><tr><th>model</th><th>requests</th><th>in</th><th>out</th><th>cache read</th><th>api-equivalent</th><th>metered</th></tr></thead><tbody>${models.map(([m, v]) => `<tr><td>${escapeHtml(m)}</td><td>${v.requests.toLocaleString('en-US')}</td><td>${v.inputTokens.toLocaleString('en-US')}</td><td>${v.outputTokens.toLocaleString('en-US')}</td><td>${v.cacheReadTokens.toLocaleString('en-US')}</td><td>${escapeHtml(formatUsd(v.apiEquivalentCost))}</td><td>${escapeHtml(formatUsd(v.meteredCost))}</td></tr>`).join('')}</tbody></table>`
|
|
107
|
+
: '';
|
|
108
|
+
const accounts = Object.entries(summary.perAccount).map(([a, s]) => `<tr><td>${escapeHtml(a)}</td><td>${s.requests.toLocaleString('en-US')}</td><td>${Math.round(s.currentUtil5h * 100)}%</td><td>${Math.round(s.currentUtil7d * 100)}%</td><td>${escapeHtml(s.lastClaim)}</td></tr>`).join('');
|
|
109
|
+
return `${headline}
|
|
110
|
+
<div class="stats">
|
|
111
|
+
${stat(`requests · last ${w.minutes} min`, w.requests.toLocaleString('en-US'))}
|
|
112
|
+
${stat('avg latency', `${Math.round(w.avgLatencyMs)} ms`)}
|
|
113
|
+
${stat('error rate', `${(w.errorRate * 100).toFixed(1)}%`)}
|
|
114
|
+
${stat('cached prompt', `${Math.round(w.cachedPromptPercent)}%`)}
|
|
115
|
+
${stat('in flight / queued', `${summary.queue?.active ?? 0} / ${summary.queue?.queued ?? 0}`)}
|
|
116
|
+
</div>
|
|
117
|
+
<div class="rings">${rings}</div>
|
|
118
|
+
${table}
|
|
119
|
+
${accounts ? `<table><thead><tr><th>seat</th><th>requests</th><th>5h</th><th>7d</th><th>last claim</th></tr></thead><tbody>${accounts}</tbody></table>` : ''}
|
|
120
|
+
<div class="foot">dario ${escapeHtml(version)} · rendered ${escapeHtml(new Date().toISOString().slice(0, 19).replace('T', ' '))} UTC</div>`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The static shell for `/analytics/ui`. Carries NO data — it is safe to
|
|
124
|
+
* serve without auth — and asks the viewer for the token once (kept in
|
|
125
|
+
* sessionStorage), then fetches `/analytics/view` every 60 s with it.
|
|
126
|
+
*/
|
|
127
|
+
export const ANALYTICS_UI_SHELL = `<!doctype html>
|
|
128
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
129
|
+
<title>dario analytics</title>
|
|
130
|
+
<style>
|
|
131
|
+
:root{color-scheme:dark}
|
|
132
|
+
body{margin:0;background:#0a0a0f;color:#e5e7eb;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:14px}
|
|
133
|
+
header{display:flex;gap:12px;align-items:center;padding:14px 20px;border-bottom:1px solid #1f2937;background:linear-gradient(90deg,#7c3aed,#db2777) top/100% 4px no-repeat,#0a0a0f}
|
|
134
|
+
header h1{font-size:14px;letter-spacing:2px;margin:0;color:#9ca3af;font-weight:600}
|
|
135
|
+
header input{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px 10px;font:inherit;width:22em}
|
|
136
|
+
header button{background:#7c3aed;color:#fff;border:0;border-radius:6px;padding:6px 12px;font:inherit;cursor:pointer}
|
|
137
|
+
#status{color:#9ca3af;margin-left:auto}
|
|
138
|
+
main{padding:20px;max-width:1100px;margin:0 auto}
|
|
139
|
+
.headline{font-size:34px;font-weight:700;color:#fff;margin:6px 0 16px}
|
|
140
|
+
.headline span{display:block;font-size:13px;font-weight:400;color:#9ca3af;margin-top:4px}
|
|
141
|
+
.headline.muted{color:#6b7280}
|
|
142
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px}
|
|
143
|
+
.stat{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:12px 14px}
|
|
144
|
+
.stat .v{font-size:20px;font-weight:700;color:#fff}.stat .l{font-size:11px;color:#9ca3af;margin-top:2px}
|
|
145
|
+
.rings svg{width:100%;max-width:640px;height:auto;display:block;margin:0 auto 18px}
|
|
146
|
+
table{width:100%;border-collapse:collapse;margin:0 0 18px;font-size:12px}
|
|
147
|
+
th,td{text-align:right;padding:6px 8px;border-bottom:1px solid #1f2937}th:first-child,td:first-child{text-align:left}
|
|
148
|
+
th{color:#9ca3af;font-weight:500}
|
|
149
|
+
.foot{color:#6b7280;font-size:11px}
|
|
150
|
+
.err{color:#fca5a5;padding:20px;background:#1f1115;border:1px solid #7f1d1d;border-radius:12px}
|
|
151
|
+
</style></head>
|
|
152
|
+
<body>
|
|
153
|
+
<header><h1>DARIO ANALYTICS</h1>
|
|
154
|
+
<input id="tok" type="password" placeholder="analytics token or API key (blank on an unkeyed proxy)" autocomplete="off">
|
|
155
|
+
<button id="go">connect</button><span id="status"></span></header>
|
|
156
|
+
<main id="view"></main>
|
|
157
|
+
<script>
|
|
158
|
+
(function(){
|
|
159
|
+
var tok=document.getElementById('tok'),view=document.getElementById('view'),status=document.getElementById('status'),timer=null;
|
|
160
|
+
try{tok.value=sessionStorage.getItem('dario.analytics.token')||''}catch(e){}
|
|
161
|
+
function headers(){var h={};if(tok.value)h['Authorization']='Bearer '+tok.value;return h}
|
|
162
|
+
function load(){
|
|
163
|
+
fetch('/analytics/view',{headers:headers(),cache:'no-store'}).then(function(r){
|
|
164
|
+
if(r.status===401){view.innerHTML='<div class="err">401 — this proxy is keyed. Paste its analytics token (DARIO_ANALYTICS_TOKEN) or API key above.</div>';status.textContent='';return}
|
|
165
|
+
if(!r.ok){view.innerHTML='<div class="err">'+r.status+' from /analytics/view</div>';return}
|
|
166
|
+
return r.text().then(function(html){view.innerHTML=html;status.textContent='live · refreshes every 60 s';try{sessionStorage.setItem('dario.analytics.token',tok.value)}catch(e){}})
|
|
167
|
+
}).catch(function(e){view.innerHTML='<div class="err">'+String(e)+'</div>'});
|
|
168
|
+
}
|
|
169
|
+
function start(){if(timer)clearInterval(timer);load();timer=setInterval(load,60000)}
|
|
170
|
+
document.getElementById('go').addEventListener('click',start);
|
|
171
|
+
tok.addEventListener('keydown',function(e){if(e.key==='Enter')start()});
|
|
172
|
+
start();
|
|
173
|
+
})();
|
|
174
|
+
</script>
|
|
175
|
+
</body></html>
|
|
176
|
+
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prometheus text exposition for `GET /metrics` (dario#1341).
|
|
3
|
+
*
|
|
4
|
+
* Everything here is derived from state dario already keeps — the rolling
|
|
5
|
+
* analytics window, the request queue, and the ledger — rendered in the
|
|
6
|
+
* text format every scraper reads. No new collection, no new state: the
|
|
7
|
+
* endpoint is a view, and a scrape costs the same as `GET /analytics`.
|
|
8
|
+
*
|
|
9
|
+
* Pure over its inputs so it is testable without a proxy. Label values are
|
|
10
|
+
* escaped per the exposition rules (backslash, double quote, newline).
|
|
11
|
+
*/
|
|
12
|
+
import type { AnalyticsSummary, RequestRecord } from './analytics.js';
|
|
13
|
+
import type { QueueSnapshot } from './request-queue.js';
|
|
14
|
+
import type { LedgerSummary } from './ledger.js';
|
|
15
|
+
export interface MetricsInput {
|
|
16
|
+
summary: AnalyticsSummary;
|
|
17
|
+
queue: QueueSnapshot;
|
|
18
|
+
lifetime: LedgerSummary | null;
|
|
19
|
+
/** Most recent records, newest last — the latency quantiles come from these. */
|
|
20
|
+
recent: readonly RequestRecord[];
|
|
21
|
+
version: string;
|
|
22
|
+
}
|
|
23
|
+
/** Nearest-rank quantile over a sorted ascending array. */
|
|
24
|
+
export declare function quantile(sorted: readonly number[], q: number): number;
|
|
25
|
+
export declare function renderPrometheus(input: MetricsInput): string;
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { billingBucketFromClaim } from './analytics.js';
|
|
2
|
+
const escapeLabel = (v) => v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
3
|
+
const labels = (kv) => {
|
|
4
|
+
const parts = Object.entries(kv).map(([k, v]) => `${k}="${escapeLabel(v)}"`);
|
|
5
|
+
return parts.length ? `{${parts.join(',')}}` : '';
|
|
6
|
+
};
|
|
7
|
+
const num = (n) => {
|
|
8
|
+
if (!Number.isFinite(n))
|
|
9
|
+
return n === Infinity ? '+Inf' : n === -Infinity ? '-Inf' : 'NaN';
|
|
10
|
+
return String(n);
|
|
11
|
+
};
|
|
12
|
+
/** Nearest-rank quantile over a sorted ascending array. */
|
|
13
|
+
export function quantile(sorted, q) {
|
|
14
|
+
if (sorted.length === 0)
|
|
15
|
+
return NaN;
|
|
16
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
|
|
17
|
+
return sorted[idx];
|
|
18
|
+
}
|
|
19
|
+
export function renderPrometheus(input) {
|
|
20
|
+
const { summary, queue, lifetime, recent, version } = input;
|
|
21
|
+
const out = [];
|
|
22
|
+
// `_total` names are counters by Prometheus convention; everything else here
|
|
23
|
+
// is a gauge. The latency summary is emitted by hand below.
|
|
24
|
+
const metric = (name, help, rows, type = name.endsWith('_total') ? 'counter' : 'gauge') => {
|
|
25
|
+
if (rows.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
out.push(`# HELP ${name} ${help}`);
|
|
28
|
+
out.push(`# TYPE ${name} ${type}`);
|
|
29
|
+
for (const [kv, v] of rows)
|
|
30
|
+
out.push(`${name}${labels(kv)} ${num(v)}`);
|
|
31
|
+
};
|
|
32
|
+
metric('dario_info', 'dario version, always 1.', [[{ version }, 1]]);
|
|
33
|
+
// ---- all-time (since proxy start) -------------------------------------
|
|
34
|
+
const all = summary.allTime;
|
|
35
|
+
metric('dario_requests_total', 'Requests served since the proxy started.', [[{}, all.requests]]);
|
|
36
|
+
metric('dario_tokens_total', 'Tokens since the proxy started, by kind.', [
|
|
37
|
+
[{ kind: 'input' }, all.totalInputTokens],
|
|
38
|
+
[{ kind: 'output' }, all.totalOutputTokens],
|
|
39
|
+
[{ kind: 'cache_read' }, all.totalCacheReadTokens],
|
|
40
|
+
[{ kind: 'cache_create' }, all.totalCacheCreateTokens],
|
|
41
|
+
[{ kind: 'thinking' }, all.totalThinkingTokens],
|
|
42
|
+
]);
|
|
43
|
+
metric('dario_estimated_cost_usd_total', 'API-equivalent cost of all traffic since the proxy started, USD at list price.', [[{}, all.estimatedCost]]);
|
|
44
|
+
metric('dario_error_rate', 'Share of requests that failed, 0..1, all-time.', [[{}, all.errorRate]]);
|
|
45
|
+
// ---- rolling window ----------------------------------------------------
|
|
46
|
+
const w = summary.window;
|
|
47
|
+
const win = { window_minutes: String(w.minutes) };
|
|
48
|
+
metric('dario_window_requests', 'Requests in the rolling window.', [[win, w.requests]]);
|
|
49
|
+
metric('dario_window_avg_latency_ms', 'Mean request latency in the rolling window, ms.', [[win, w.avgLatencyMs]]);
|
|
50
|
+
metric('dario_window_error_rate', 'Share of requests that failed in the rolling window, 0..1.', [[win, w.errorRate]]);
|
|
51
|
+
metric('dario_window_cached_prompt_percent', 'Share of prompt tokens served from cache in the rolling window, 0..100.', [[win, w.cachedPromptPercent]]);
|
|
52
|
+
metric('dario_window_estimated_cost_usd', 'API-equivalent cost of the rolling window, USD.', [[win, w.estimatedCost]]);
|
|
53
|
+
// ---- billing buckets (window) ------------------------------------------
|
|
54
|
+
const buckets = new Map();
|
|
55
|
+
for (const [claim, n] of Object.entries(w.claimBreakdown ?? {})) {
|
|
56
|
+
const b = billingBucketFromClaim(claim);
|
|
57
|
+
buckets.set(b, (buckets.get(b) ?? 0) + n);
|
|
58
|
+
}
|
|
59
|
+
metric('dario_window_billing_requests', 'Requests in the rolling window by billing bucket.', [...buckets.entries()].map(([bucket, n]) => [{ bucket }, n]));
|
|
60
|
+
// ---- per account -------------------------------------------------------
|
|
61
|
+
const accounts = Object.entries(summary.perAccount);
|
|
62
|
+
metric('dario_account_requests_total', 'Requests per pool seat since the proxy started.', accounts.map(([account, a]) => [{ account }, a.requests]));
|
|
63
|
+
metric('dario_account_utilization', 'Last reported rate-limit utilization per seat, 0..1.', accounts.flatMap(([account, a]) => [
|
|
64
|
+
[{ account, window: '5h' }, a.currentUtil5h],
|
|
65
|
+
[{ account, window: '7d' }, a.currentUtil7d],
|
|
66
|
+
]));
|
|
67
|
+
metric('dario_account_estimated_cost_usd', 'API-equivalent cost per seat since the proxy started, USD.', accounts.map(([account, a]) => [{ account }, a.estimatedCost]));
|
|
68
|
+
// ---- per model ---------------------------------------------------------
|
|
69
|
+
const models = Object.entries(summary.perModel);
|
|
70
|
+
metric('dario_model_requests_total', 'Requests per model since the proxy started.', models.map(([model, m]) => [{ model }, m.requests]));
|
|
71
|
+
metric('dario_model_estimated_cost_usd', 'API-equivalent cost per model since the proxy started, USD.', models.map(([model, m]) => [{ model }, m.estimatedCost]));
|
|
72
|
+
// ---- per consumer (named key / header) ---------------------------------
|
|
73
|
+
const consumers = Object.entries(summary.perConsumer);
|
|
74
|
+
metric('dario_consumer_requests_total', 'Requests per consumer (named key or x-dario-consumer) since the proxy started.', consumers.map(([consumer, c]) => [{ consumer }, c.requests]));
|
|
75
|
+
metric('dario_consumer_estimated_cost_usd', 'API-equivalent cost per consumer since the proxy started, USD.', consumers.map(([consumer, c]) => [{ consumer }, c.estimatedCost]));
|
|
76
|
+
// ---- queue ---------------------------------------------------------------
|
|
77
|
+
metric('dario_queue_active', 'Requests in flight upstream.', [[{}, queue.active]]);
|
|
78
|
+
metric('dario_queue_queued', 'Requests waiting for a slot.', [[{}, queue.queued]]);
|
|
79
|
+
metric('dario_queue_max_concurrent', 'Configured in-flight ceiling.', [[{}, queue.maxConcurrent]]);
|
|
80
|
+
metric('dario_queue_max_queued', 'Configured queue ceiling.', [[{}, queue.maxQueued]]);
|
|
81
|
+
metric('dario_queue_stalled', '1 when slots are held but nothing is turning over, else 0.', [[{}, queue.stalledSince ? 1 : 0]]);
|
|
82
|
+
metric('dario_queue_max_wait_ms', 'Longest a request has waited for a slot since start, ms.', [[{}, queue.maxWaitMs]]);
|
|
83
|
+
metric('dario_queue_consumers_active', 'Distinct consumers with a request in flight.', [[{}, queue.consumersActive]]);
|
|
84
|
+
// ---- latency quantiles over the recent records -------------------------
|
|
85
|
+
const lat = recent.map(r => r.latencyMs).filter(n => Number.isFinite(n)).sort((a, b) => a - b);
|
|
86
|
+
if (lat.length > 0) {
|
|
87
|
+
out.push('# HELP dario_request_latency_ms Request latency over the most recent records, ms (nearest-rank quantiles).');
|
|
88
|
+
out.push('# TYPE dario_request_latency_ms summary');
|
|
89
|
+
for (const q of [0.5, 0.9, 0.99])
|
|
90
|
+
out.push(`dario_request_latency_ms{quantile="${q}"} ${num(quantile(lat, q))}`);
|
|
91
|
+
out.push(`dario_request_latency_ms_sum ${num(lat.reduce((a, b) => a + b, 0))}`);
|
|
92
|
+
out.push(`dario_request_latency_ms_count ${lat.length}`);
|
|
93
|
+
}
|
|
94
|
+
// ---- predictions -------------------------------------------------------
|
|
95
|
+
const p = summary.predictions;
|
|
96
|
+
if (p.estimatedExhaustionMinutes !== null) {
|
|
97
|
+
metric('dario_predicted_exhaustion_minutes', 'Minutes until the current seat window is predicted to exhaust at the present burn rate.', [[{}, p.estimatedExhaustionMinutes]]);
|
|
98
|
+
}
|
|
99
|
+
metric('dario_burn_tokens_per_minute', 'Token burn rate over the rolling window.', [[{}, p.tokenBurnRate]]);
|
|
100
|
+
metric('dario_burn_cost_usd_per_minute', 'API-equivalent cost burn rate over the rolling window, USD/min.', [[{}, p.costBurnRate]]);
|
|
101
|
+
// ---- ledger (survives restarts) ----------------------------------------
|
|
102
|
+
if (lifetime) {
|
|
103
|
+
metric('dario_ledger_requests_total', 'Requests in the ledger (covered + metered, 2xx), lifetime.', [[{}, lifetime.requests]]);
|
|
104
|
+
metric('dario_ledger_api_equivalent_usd', 'What subscription-covered traffic would have cost on the metered API, lifetime, USD.', [[{}, lifetime.apiEquivalentCost]]);
|
|
105
|
+
metric('dario_ledger_metered_usd', 'What metered traffic actually cost at list price, lifetime, USD.', [[{}, lifetime.meteredCost]]);
|
|
106
|
+
metric('dario_ledger_recent_api_equivalent_usd', 'API-equivalent spend over trailing UTC-day windows, USD.', [
|
|
107
|
+
[{ window: 'today' }, lifetime.recent.today],
|
|
108
|
+
[{ window: '7d' }, lifetime.recent.last7d],
|
|
109
|
+
[{ window: '30d' }, lifetime.recent.last30d],
|
|
110
|
+
]);
|
|
111
|
+
metric('dario_ledger_model_api_equivalent_usd', 'Lifetime API-equivalent spend per model, USD.', Object.entries(lifetime.perModel).map(([model, m]) => [{ model, provider: m.provider }, m.apiEquivalentCost]));
|
|
112
|
+
metric('dario_ledger_model_requests_total', 'Lifetime requests per model in the ledger.', Object.entries(lifetime.perModel).map(([model, m]) => [{ model, provider: m.provider }, m.requests]));
|
|
113
|
+
metric('dario_ledger_consumer_api_equivalent_usd', 'Lifetime API-equivalent spend per consumer, USD.', Object.entries(lifetime.perConsumer).map(([consumer, c]) => [{ consumer }, c.apiEquivalentCost]));
|
|
114
|
+
}
|
|
115
|
+
return out.join('\n') + '\n';
|
|
116
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -280,6 +280,7 @@ interface ProxyOptions {
|
|
|
280
280
|
model?: string;
|
|
281
281
|
fastModel?: string;
|
|
282
282
|
noClaudeAuth?: boolean;
|
|
283
|
+
analyticsToken?: string;
|
|
283
284
|
/**
|
|
284
285
|
* Override the fetch used for UPSTREAM calls (api.anthropic.com). Test seam:
|
|
285
286
|
* it makes the request path hermetic, which the 400-recovery chain needs —
|
|
@@ -628,6 +629,14 @@ export declare function sanitizeError(err: unknown): string;
|
|
|
628
629
|
* API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
|
|
629
630
|
* If unset, requests are allowed (loopback-only default). Exported for tests.
|
|
630
631
|
*/
|
|
632
|
+
/**
|
|
633
|
+
* The read-only analytics surfaces — the only paths the analytics token
|
|
634
|
+
* (`--analytics-token` / `DARIO_ANALYTICS_TOKEN`) is accepted on. Exact
|
|
635
|
+
* matches on purpose: a prefix test would let a future `/analytics/reset`
|
|
636
|
+
* inherit read-only auth by accident.
|
|
637
|
+
*/
|
|
638
|
+
export declare const ANALYTICS_READ_PATHS: readonly string[];
|
|
639
|
+
export declare function isAnalyticsReadPath(urlPath: string): boolean;
|
|
631
640
|
export declare function authenticateRequest(headers: IncomingMessage['headers'], apiKeyBuf: Buffer | null): boolean;
|
|
632
641
|
/**
|
|
633
642
|
* Describe WHY authenticateRequest rejected, for operator-facing logs only.
|
package/dist/proxy.js
CHANGED
|
@@ -17,6 +17,8 @@ import { backfillIdentity } from './accounts.js';
|
|
|
17
17
|
import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
|
|
18
18
|
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
|
|
19
19
|
import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
|
|
20
|
+
import { renderPrometheus } from './metrics.js';
|
|
21
|
+
import { renderSpendDonuts, renderAnalyticsView, ANALYTICS_UI_SHELL } from './donuts.js';
|
|
20
22
|
import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey } from './keys.js';
|
|
21
23
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
22
24
|
import { notify as osNotify } from './notify.js';
|
|
@@ -1006,6 +1008,18 @@ export function sanitizeError(err) {
|
|
|
1006
1008
|
* API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
|
|
1007
1009
|
* If unset, requests are allowed (loopback-only default). Exported for tests.
|
|
1008
1010
|
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* The read-only analytics surfaces — the only paths the analytics token
|
|
1013
|
+
* (`--analytics-token` / `DARIO_ANALYTICS_TOKEN`) is accepted on. Exact
|
|
1014
|
+
* matches on purpose: a prefix test would let a future `/analytics/reset`
|
|
1015
|
+
* inherit read-only auth by accident.
|
|
1016
|
+
*/
|
|
1017
|
+
export const ANALYTICS_READ_PATHS = [
|
|
1018
|
+
'/analytics', '/analytics/ledger', '/analytics/stream', '/analytics/view', '/analytics/donuts.svg', '/metrics',
|
|
1019
|
+
];
|
|
1020
|
+
export function isAnalyticsReadPath(urlPath) {
|
|
1021
|
+
return ANALYTICS_READ_PATHS.includes(urlPath);
|
|
1022
|
+
}
|
|
1009
1023
|
export function authenticateRequest(headers, apiKeyBuf) {
|
|
1010
1024
|
if (!apiKeyBuf)
|
|
1011
1025
|
return true;
|
|
@@ -1970,6 +1984,17 @@ export async function startProxy(opts = {}) {
|
|
|
1970
1984
|
// Optional proxy authentication — pre-encode key buffer for performance
|
|
1971
1985
|
const apiKey = process.env.DARIO_API_KEY;
|
|
1972
1986
|
const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
|
|
1987
|
+
// Read-only analytics credential (dario#1341). Accepted ONLY on the
|
|
1988
|
+
// read-only surfaces listed in isAnalyticsReadPath — never on /v1/*, never
|
|
1989
|
+
// on /admin/*, never on /accounts — so a Grafana box or a browser tab can
|
|
1990
|
+
// hold it without holding request rights. On an unkeyed proxy it changes
|
|
1991
|
+
// nothing (everything is already open on loopback). When DARIO_API_KEY is
|
|
1992
|
+
// set, the root key keeps working on these paths too.
|
|
1993
|
+
const analyticsToken = opts.analyticsToken || process.env.DARIO_ANALYTICS_TOKEN || '';
|
|
1994
|
+
const analyticsTokenBuf = analyticsToken ? Buffer.from(analyticsToken) : null;
|
|
1995
|
+
if (analyticsTokenBuf && !apiKeyBuf) {
|
|
1996
|
+
console.warn('[dario] --analytics-token set but DARIO_API_KEY is not: /analytics and /metrics are already open on this proxy, the token gates nothing.');
|
|
1997
|
+
}
|
|
1973
1998
|
// Named keys (dario#1318): one credential per developer, hashes on disk,
|
|
1974
1999
|
// re-read when the file moves. Attribution and per-key limits ride on the
|
|
1975
2000
|
// match; the root DARIO_API_KEY keeps working beside them.
|
|
@@ -2515,7 +2540,19 @@ export async function startProxy(opts = {}) {
|
|
|
2515
2540
|
if (handled)
|
|
2516
2541
|
return;
|
|
2517
2542
|
}
|
|
2518
|
-
|
|
2543
|
+
// The dashboard shell carries no data, so it needs no credential: it is
|
|
2544
|
+
// the page that ASKS for the token and then fetches /analytics/view.
|
|
2545
|
+
if (urlPath === '/analytics/ui' && req.method === 'GET') {
|
|
2546
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
|
|
2547
|
+
res.end(ANALYTICS_UI_SHELL);
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
// Read-only analytics token: accepted on the read-only surfaces only.
|
|
2551
|
+
// Anything else falls through to the normal request auth below.
|
|
2552
|
+
const analyticsRead = isAnalyticsReadPath(urlPath) && req.method === 'GET';
|
|
2553
|
+
const requestAuth = (analyticsRead && analyticsTokenBuf && authenticateRequest(req.headers, analyticsTokenBuf))
|
|
2554
|
+
? { ok: true, key: null }
|
|
2555
|
+
: resolveRequestAuth(req);
|
|
2519
2556
|
if (!requestAuth.ok) {
|
|
2520
2557
|
if (verbose) {
|
|
2521
2558
|
// Silent auth rejects are hard to diagnose when a client's config
|
|
@@ -2673,6 +2710,40 @@ export async function startProxy(opts = {}) {
|
|
|
2673
2710
|
res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
|
|
2674
2711
|
return;
|
|
2675
2712
|
}
|
|
2713
|
+
// Prometheus text exposition of the same state (dario#1341). A view, not
|
|
2714
|
+
// new collection: a scrape costs what GET /analytics costs. Same gate.
|
|
2715
|
+
if (urlPath === '/metrics' && req.method === 'GET') {
|
|
2716
|
+
const body = renderPrometheus({
|
|
2717
|
+
summary: analytics.summary(),
|
|
2718
|
+
queue: queue.snapshot(),
|
|
2719
|
+
lifetime: ledger ? ledger.summary() : null,
|
|
2720
|
+
recent: analytics.recent(1000),
|
|
2721
|
+
version: darioVersion(),
|
|
2722
|
+
});
|
|
2723
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', ...SECURITY_HEADERS });
|
|
2724
|
+
res.end(body);
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
// Spend donuts — by model, by key, by billing — from the ledger. The same
|
|
2728
|
+
// SVG `dario usage --donut` writes to disk.
|
|
2729
|
+
if (urlPath === '/analytics/donuts.svg' && req.method === 'GET') {
|
|
2730
|
+
if (!ledger) {
|
|
2731
|
+
res.writeHead(404, JSON_HEADERS);
|
|
2732
|
+
res.end(JSON.stringify({ error: 'ledger disabled', hint: 'start without --no-ledger / DARIO_LEDGER=0' }));
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', ...SECURITY_HEADERS });
|
|
2736
|
+
res.end(renderSpendDonuts(ledger.summary()));
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
// The server-rendered body behind /analytics/ui. Gated like /analytics;
|
|
2740
|
+
// the shell fetches it with whatever the viewer typed.
|
|
2741
|
+
if (urlPath === '/analytics/view' && req.method === 'GET') {
|
|
2742
|
+
const s = analytics.summary();
|
|
2743
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
|
|
2744
|
+
res.end(renderAnalyticsView({ ...s, queue: queue.snapshot() }, ledger ? ledger.summary() : null, darioVersion()));
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2676
2747
|
// The ledger's per-day table, for anyone charting it. `lifetime` on
|
|
2677
2748
|
// /analytics is the summary; this is the data behind it.
|
|
2678
2749
|
if (urlPath === '/analytics/ledger' && req.method === 'GET') {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Analytics, `/metrics`, and the dashboard
|
|
2
|
+
|
|
3
|
+
dario keeps two kinds of numbers. The **rolling window** (`src/analytics.ts`) lives in memory and forgets on restart: per-request records with tokens, latency, seat, model, consumer and billing claim, summarised over the last 60 minutes and since start. The **ledger** (`src/ledger.ts`, `~/.dario/ledger.json`) survives restarts: one row per day, per model, per billing bucket, priced at read time from the published rate cards.
|
|
4
|
+
|
|
5
|
+
Every surface below is a view over those two. Nothing here collects anything new.
|
|
6
|
+
|
|
7
|
+
## Surfaces
|
|
8
|
+
|
|
9
|
+
| path | what | format |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `GET /analytics` | window summary + `queue` snapshot + `lifetime` ledger summary | JSON |
|
|
12
|
+
| `GET /analytics/ledger` | the ledger's per-day table | JSON |
|
|
13
|
+
| `GET /analytics/stream` | live tail of request records (drives the TUI) | SSE |
|
|
14
|
+
| `GET /metrics` | the same state as Prometheus text exposition | text/plain 0.0.4 |
|
|
15
|
+
| `GET /analytics/donuts.svg` | spend by model / by key / by billing | SVG |
|
|
16
|
+
| `GET /analytics/view` | server-rendered dashboard body | HTML fragment |
|
|
17
|
+
| `GET /analytics/ui` | the dashboard shell (no data; asks for the token, loads `/view` every 60 s) | HTML page |
|
|
18
|
+
|
|
19
|
+
CLI: `dario usage` prints the ledger; `--json` dumps `/analytics`; `--card[=file]` writes the share card; `--donut[=file]` writes the three rings.
|
|
20
|
+
|
|
21
|
+
## Who can read them
|
|
22
|
+
|
|
23
|
+
By default the proxy binds loopback and these paths need no credential. With `DARIO_API_KEY` set, they need the key like everything else.
|
|
24
|
+
|
|
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
|
+
|
|
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 fetch of `/analytics/view`.
|
|
28
|
+
|
|
29
|
+
## `/metrics` families
|
|
30
|
+
|
|
31
|
+
Names ending in `_total` are counters; everything else is a gauge. Labels are escaped per the exposition format.
|
|
32
|
+
|
|
33
|
+
| family | labels | source |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `dario_info` | `version` | always 1 |
|
|
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
|
+
| `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` |
|
|
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
|
+
| `dario_model_requests_total`, `dario_model_estimated_cost_usd` | `model` | since start |
|
|
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` | — | request queue |
|
|
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
|
+
| `dario_predicted_exhaustion_minutes` (omitted when unknown), `dario_burn_tokens_per_minute`, `dario_burn_cost_usd_per_minute` | — | window predictions |
|
|
45
|
+
| `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) |
|
|
46
|
+
|
|
47
|
+
Latency here is end-to-end through dario as the client saw it. Time-to-first-token and the split between dario's own overhead and the provider's time are not recorded per request today; they are the natural next columns on `RequestRecord` if a scrape wants them.
|
|
48
|
+
|
|
49
|
+
A minimal scrape config:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
scrape_configs:
|
|
53
|
+
- job_name: dario
|
|
54
|
+
static_configs: [{ targets: ['127.0.0.1:3456'] }]
|
|
55
|
+
authorization: { credentials: '<DARIO_ANALYTICS_TOKEN>' }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## The rings
|
|
59
|
+
|
|
60
|
+
`dario usage --donut` and `/analytics/donuts.svg` render the ledger's lifetime spend as three rings: **by model**, **by key** (empty until a named key or `x-dario-consumer` has traffic), and **subscription vs metered**. Each ring keeps the top five and folds the rest into *other*. Shares are of API-equivalent plus metered spend, so a model that only ever ran on an API key still shows.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.8.
|
|
3
|
+
"version": "6.8.13",
|
|
4
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": {
|