@askalf/dario 6.8.10 → 6.8.12
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/admin-api.d.ts +3 -1
- package/dist/admin-api.js +2 -2
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +115 -7
- package/dist/codex-accounts.d.ts +30 -2
- package/dist/codex-accounts.js +33 -3
- package/dist/donuts.d.ts +46 -0
- package/dist/donuts.js +176 -0
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/metrics.d.ts +25 -0
- package/dist/metrics.js +116 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +80 -7
- 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/admin-api.d.ts
CHANGED
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
* against it.
|
|
76
76
|
*/
|
|
77
77
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
78
|
+
import type { CodexSeatState } from './codex-accounts.js';
|
|
78
79
|
import { type KeyStore } from './keys.js';
|
|
79
80
|
/** Persisted account metadata surfaced by `GET /admin/accounts`. */
|
|
80
81
|
export interface AdminAccountRecord {
|
|
@@ -150,9 +151,10 @@ export interface AdminAccountLive {
|
|
|
150
151
|
}
|
|
151
152
|
/** An audited admin action — see `AdminDeps.audit`. Never carries secrets. */
|
|
152
153
|
/** One stored ChatGPT seat as `GET /admin/codex/accounts` reports it. */
|
|
153
|
-
export interface AdminCodexAccountRecord {
|
|
154
|
+
export interface AdminCodexAccountRecord extends CodexSeatState {
|
|
154
155
|
alias: string;
|
|
155
156
|
expiresAt: number;
|
|
157
|
+
/** The clock's opinion. `status` is the proxy's (dario#1343). */
|
|
156
158
|
needsRefresh: boolean;
|
|
157
159
|
}
|
|
158
160
|
export interface AdminAuditEvent {
|
package/dist/admin-api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { maskEmail } from './pool.js';
|
|
2
2
|
import { timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
|
|
4
|
-
import { startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, loadAllCodexAccounts, listCodexAccountAliases, codexAccountNeedsRefresh, parseCodexManualPaste, } from './codex-accounts.js';
|
|
4
|
+
import { startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, loadAllCodexAccounts, listCodexAccountAliases, codexAccountNeedsRefresh, codexSeatStatus, parseCodexManualPaste, } from './codex-accounts.js';
|
|
5
5
|
import { parseManualPaste } from './oauth.js';
|
|
6
6
|
import { grantAge } from './refresh-grant.js';
|
|
7
7
|
import { createKey, revokeKey, rotateKey, parseExpiry, publicKey, KEY_NAME_RE } from './keys.js';
|
|
@@ -153,7 +153,7 @@ async function doCompleteCodexLogin(alias, rawCode, now, deps, remote) {
|
|
|
153
153
|
async function listCodexAccountRecords() {
|
|
154
154
|
const all = await loadAllCodexAccounts();
|
|
155
155
|
return all
|
|
156
|
-
.map((a) => ({ alias: a.alias, expiresAt: a.expiresAt, needsRefresh: codexAccountNeedsRefresh(a) }))
|
|
156
|
+
.map((a) => ({ alias: a.alias, expiresAt: a.expiresAt, needsRefresh: codexAccountNeedsRefresh(a), ...codexSeatStatus(a.alias) }))
|
|
157
157
|
.sort((x, y) => x.alias.localeCompare(y.alias));
|
|
158
158
|
}
|
|
159
159
|
/** On-disk account inventory — the default `AdminDeps.listAccounts`. */
|
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
|
|
@@ -144,6 +152,28 @@ export interface LivePayload {
|
|
|
144
152
|
* them, so a legacy payload can be driven straight through it in a test.
|
|
145
153
|
*/
|
|
146
154
|
export declare function formatLiveAccountsListing(payload: LivePayload, port: number, now: number): string[];
|
|
155
|
+
/**
|
|
156
|
+
* `dario accounts list --live` — the running proxy's view of the pool
|
|
157
|
+
* (dario#1244): status with its countdown, the reading and its age, 429s
|
|
158
|
+
* answered, the organization, and which seats share a window. The on-disk
|
|
159
|
+
* listing knows none of that. Returns false when no proxy answered, so the
|
|
160
|
+
* caller falls back to the on-disk listing.
|
|
161
|
+
*/
|
|
162
|
+
/** One seat as `GET /codex` reports it — the fields the live listing prints. */
|
|
163
|
+
export interface LiveCodexSeat {
|
|
164
|
+
alias: string;
|
|
165
|
+
expiresInMs: number;
|
|
166
|
+
requestCount: number;
|
|
167
|
+
status: 'ok' | 'cooling' | 'refresh-failed';
|
|
168
|
+
cooldownRemainingMs: number;
|
|
169
|
+
lastRefreshError: {
|
|
170
|
+
at: number;
|
|
171
|
+
status: number;
|
|
172
|
+
message: string;
|
|
173
|
+
} | null;
|
|
174
|
+
}
|
|
175
|
+
/** Lines for `dario codex list --live` — pure, so the shape is testable without a proxy. */
|
|
176
|
+
export declare function formatLiveCodexListing(accounts: readonly LiveCodexSeat[], port: number): string[];
|
|
147
177
|
/**
|
|
148
178
|
* Decide whether this module is being invoked as the CLI entry point or
|
|
149
179
|
* imported as a library. Pure, exported for tests; the file-bottom uses
|
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
|
|
@@ -1134,13 +1160,67 @@ export function formatLiveAccountsListing(payload, port, now) {
|
|
|
1134
1160
|
lines.push('');
|
|
1135
1161
|
return lines;
|
|
1136
1162
|
}
|
|
1163
|
+
/** Lines for `dario codex list --live` — pure, so the shape is testable without a proxy. */
|
|
1164
|
+
export function formatLiveCodexListing(accounts, port) {
|
|
1165
|
+
const out = ['', ` dario — Codex accounts (live, from http://127.0.0.1:${port}/codex)`, ' ───────────────────────────────────────', ''];
|
|
1166
|
+
if (accounts.length === 0) {
|
|
1167
|
+
out.push(' No Codex accounts.', '');
|
|
1168
|
+
return out;
|
|
1169
|
+
}
|
|
1170
|
+
for (const a of accounts) {
|
|
1171
|
+
const mins = Math.floor(Math.max(0, a.expiresInMs) / 60000);
|
|
1172
|
+
const expiry = a.expiresInMs > 0 ? `${mins}m` : 'expired';
|
|
1173
|
+
let state;
|
|
1174
|
+
if (a.status === 'refresh-failed') {
|
|
1175
|
+
const e = a.lastRefreshError;
|
|
1176
|
+
state = `refresh-failed (${e ? `${e.status}: ${e.message}` : 'token endpoint refused'}) — re-add the seat`;
|
|
1177
|
+
}
|
|
1178
|
+
else if (a.status === 'cooling') {
|
|
1179
|
+
state = `cooling ${Math.ceil(a.cooldownRemainingMs / 1000)}s — the backend declined it; selection skips it until then`;
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
state = 'ok';
|
|
1183
|
+
}
|
|
1184
|
+
out.push(` ${a.alias.padEnd(20)} ${state}`);
|
|
1185
|
+
out.push(` ${''.padEnd(20)} token expires in ${expiry}, ${a.requestCount} request${a.requestCount === 1 ? '' : 's'} served`);
|
|
1186
|
+
}
|
|
1187
|
+
out.push('');
|
|
1188
|
+
return out;
|
|
1189
|
+
}
|
|
1137
1190
|
/**
|
|
1138
|
-
* `dario
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1191
|
+
* `dario codex list --live` — the running proxy's view of each seat (dario#1343):
|
|
1192
|
+
* what it will do with it, not what the clock says. The on-disk listing cannot
|
|
1193
|
+
* know that a seat is cooling or that its refresh was refused; only the process
|
|
1194
|
+
* that tried does. Returns false when no proxy answered, so the caller falls
|
|
1195
|
+
* back to the on-disk listing.
|
|
1143
1196
|
*/
|
|
1197
|
+
async function codexListLive() {
|
|
1198
|
+
const { loadConfig } = await import('./config-file.js');
|
|
1199
|
+
const fileCfg = loadConfig().config;
|
|
1200
|
+
const portArg = args.find(a => a.startsWith('--port='));
|
|
1201
|
+
const port = (portArg ? parseInt(portArg.split('=')[1], 10) : undefined)
|
|
1202
|
+
?? (process.env['DARIO_PORT'] ? parseInt(process.env['DARIO_PORT'], 10) : undefined)
|
|
1203
|
+
?? fileCfg.port ?? 3456;
|
|
1204
|
+
const headers = {};
|
|
1205
|
+
if (process.env['DARIO_API_KEY'])
|
|
1206
|
+
headers['x-api-key'] = process.env['DARIO_API_KEY'];
|
|
1207
|
+
let payload = null;
|
|
1208
|
+
try {
|
|
1209
|
+
const res = await fetch(`http://127.0.0.1:${port}/codex`, { headers, signal: AbortSignal.timeout(3000) });
|
|
1210
|
+
if (res.ok)
|
|
1211
|
+
payload = await res.json();
|
|
1212
|
+
else
|
|
1213
|
+
console.log(` (proxy on http://127.0.0.1:${port} answered ${res.status} to /codex — showing the on-disk listing)`);
|
|
1214
|
+
}
|
|
1215
|
+
catch (err) {
|
|
1216
|
+
console.log(` (no proxy on http://127.0.0.1:${port}: ${err instanceof Error ? err.message : String(err)} — showing the on-disk listing)`);
|
|
1217
|
+
}
|
|
1218
|
+
if (!payload || !Array.isArray(payload.accounts))
|
|
1219
|
+
return false;
|
|
1220
|
+
for (const line of formatLiveCodexListing(payload.accounts, port))
|
|
1221
|
+
console.log(line);
|
|
1222
|
+
return true;
|
|
1223
|
+
}
|
|
1144
1224
|
async function accountsListLive() {
|
|
1145
1225
|
const { loadConfig } = await import('./config-file.js');
|
|
1146
1226
|
const fileCfg = loadConfig().config;
|
|
@@ -1444,6 +1524,10 @@ async function accounts() {
|
|
|
1444
1524
|
*/
|
|
1445
1525
|
async function codex() {
|
|
1446
1526
|
const sub = args[1];
|
|
1527
|
+
if ((!sub || sub === 'list') && args.includes('--live')) {
|
|
1528
|
+
if (await codexListLive())
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1447
1531
|
if (!sub || sub === 'list') {
|
|
1448
1532
|
const aliases = await listCodexAccountAliases();
|
|
1449
1533
|
console.log('');
|
|
@@ -1467,6 +1551,8 @@ async function codex() {
|
|
|
1467
1551
|
console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
|
|
1468
1552
|
}
|
|
1469
1553
|
console.log('');
|
|
1554
|
+
console.log(' (what the proxy will do with each seat — cooling, refresh refused — is `dario codex list --live` on a running proxy)');
|
|
1555
|
+
console.log('');
|
|
1470
1556
|
return;
|
|
1471
1557
|
}
|
|
1472
1558
|
if (sub === 'add') {
|
|
@@ -1656,7 +1742,9 @@ async function help() {
|
|
|
1656
1742
|
existing credentials and runs a fresh OAuth
|
|
1657
1743
|
flow — for when the refresh token is dead and
|
|
1658
1744
|
/health still reports access-token countdown.
|
|
1659
|
-
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.
|
|
1660
1748
|
dario status Check authentication status
|
|
1661
1749
|
dario refresh Force token refresh
|
|
1662
1750
|
dario resume Clear the overage-guard halt on a running proxy.
|
|
@@ -1791,6 +1879,9 @@ async function help() {
|
|
|
1791
1879
|
down). --card[=file.svg] writes a share
|
|
1792
1880
|
card of that number (default
|
|
1793
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).
|
|
1794
1885
|
--by-key splits the lifetime number per
|
|
1795
1886
|
consumer: named key, x-dario-consumer
|
|
1796
1887
|
header, or hashed user id. (v6.8)
|
|
@@ -1816,6 +1907,11 @@ async function help() {
|
|
|
1816
1907
|
instead of --model, so Claude Code's cheap
|
|
1817
1908
|
sub-agents aren't upgraded to the forced model.
|
|
1818
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.
|
|
1819
1915
|
--no-claude-auth Don't load or refresh the Claude OAuth token —
|
|
1820
1916
|
for OpenAI-only proxies (e.g. --model=openai:...).
|
|
1821
1917
|
Prevents dario rotating a shared refresh token out
|
|
@@ -2612,6 +2708,18 @@ async function usage() {
|
|
|
2612
2708
|
if (!asJson)
|
|
2613
2709
|
console.log(` Wrote ${cardPath} — ${formatUsd(lifetime.apiEquivalentCost)} API-equivalent since ${lifetime.since.slice(0, 10)}.`);
|
|
2614
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
|
+
}
|
|
2615
2723
|
if (asJson) {
|
|
2616
2724
|
if (payload) {
|
|
2617
2725
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
package/dist/codex-accounts.d.ts
CHANGED
|
@@ -51,8 +51,36 @@ export interface CodexRefreshFailure {
|
|
|
51
51
|
message: string;
|
|
52
52
|
}
|
|
53
53
|
/** Last remembered refresh failure for an alias, or null. Read-only view for
|
|
54
|
-
* the admin surface (`GET /codex`) — never triggers an upstream call.
|
|
55
|
-
|
|
54
|
+
* the admin surface (`GET /codex`) — never triggers an upstream call.
|
|
55
|
+
*
|
|
56
|
+
* Remembered means the proxy would still refuse to retry: the same
|
|
57
|
+
* `now < retryAt` test `refreshNow` applies. Past that instant the next
|
|
58
|
+
* request will try the token endpoint again, so the failure is no longer what
|
|
59
|
+
* the proxy will do with the seat and must not be reported as such (review on
|
|
60
|
+
* dario#1343: the old read returned the entry until something overwrote it,
|
|
61
|
+
* which could tell an operator to re-add a seat that was about to recover).
|
|
62
|
+
* The expired entry is dropped here so every reader agrees. */
|
|
63
|
+
export declare function getCodexRefreshFailure(alias: string, now?: number): CodexRefreshFailure | null;
|
|
64
|
+
export type CodexSeatStatus = 'ok' | 'cooling' | 'refresh-failed';
|
|
65
|
+
export interface CodexSeatState {
|
|
66
|
+
/** What the proxy will do with this seat right now. */
|
|
67
|
+
status: CodexSeatStatus;
|
|
68
|
+
/** ms until a declined seat is offered again; 0 when it is not cooling. */
|
|
69
|
+
cooldownRemainingMs: number;
|
|
70
|
+
/** The last token-endpoint rejection still remembered (about a minute), or null. */
|
|
71
|
+
lastRefreshError: CodexRefreshFailure | null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The seat as the proxy sees it, not as the clock sees it (dario#1343). A stored
|
|
75
|
+
* token can be a day from its stated expiry while the backend rejects every
|
|
76
|
+
* request made with it; `expiresAt` alone reported such a seat as healthy for
|
|
77
|
+
* six hours. Two in-memory facts say otherwise: a refresh the token endpoint
|
|
78
|
+
* refused (the seat needs re-adding), and a cool-down after the backend
|
|
79
|
+
* declined it (selection is skipping it). Read from memory only — no request,
|
|
80
|
+
* no refresh, no credential in the answer. An alias the proxy has never touched
|
|
81
|
+
* reads `ok`, which is the truth: nothing is known against it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function codexSeatStatus(alias: string, now?: number): CodexSeatState;
|
|
56
84
|
/** Test seam — forget every remembered failure. */
|
|
57
85
|
export declare function _resetCodexRefreshFailuresForTest(): void;
|
|
58
86
|
/**
|
package/dist/codex-accounts.js
CHANGED
|
@@ -205,10 +205,40 @@ export class CodexCredentialsUnavailableError extends Error {
|
|
|
205
205
|
const REFRESH_FAILURE_TTL_MS = 60 * 1000;
|
|
206
206
|
const refreshFailures = new Map();
|
|
207
207
|
/** Last remembered refresh failure for an alias, or null. Read-only view for
|
|
208
|
-
* the admin surface (`GET /codex`) — never triggers an upstream call.
|
|
209
|
-
|
|
208
|
+
* the admin surface (`GET /codex`) — never triggers an upstream call.
|
|
209
|
+
*
|
|
210
|
+
* Remembered means the proxy would still refuse to retry: the same
|
|
211
|
+
* `now < retryAt` test `refreshNow` applies. Past that instant the next
|
|
212
|
+
* request will try the token endpoint again, so the failure is no longer what
|
|
213
|
+
* the proxy will do with the seat and must not be reported as such (review on
|
|
214
|
+
* dario#1343: the old read returned the entry until something overwrote it,
|
|
215
|
+
* which could tell an operator to re-add a seat that was about to recover).
|
|
216
|
+
* The expired entry is dropped here so every reader agrees. */
|
|
217
|
+
export function getCodexRefreshFailure(alias, now = Date.now()) {
|
|
210
218
|
const hit = refreshFailures.get(alias);
|
|
211
|
-
|
|
219
|
+
if (!hit)
|
|
220
|
+
return null;
|
|
221
|
+
if (now >= hit.retryAt) {
|
|
222
|
+
refreshFailures.delete(alias);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return { at: hit.at, status: hit.status, message: hit.message };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The seat as the proxy sees it, not as the clock sees it (dario#1343). A stored
|
|
229
|
+
* token can be a day from its stated expiry while the backend rejects every
|
|
230
|
+
* request made with it; `expiresAt` alone reported such a seat as healthy for
|
|
231
|
+
* six hours. Two in-memory facts say otherwise: a refresh the token endpoint
|
|
232
|
+
* refused (the seat needs re-adding), and a cool-down after the backend
|
|
233
|
+
* declined it (selection is skipping it). Read from memory only — no request,
|
|
234
|
+
* no refresh, no credential in the answer. An alias the proxy has never touched
|
|
235
|
+
* reads `ok`, which is the truth: nothing is known against it.
|
|
236
|
+
*/
|
|
237
|
+
export function codexSeatStatus(alias, now = Date.now()) {
|
|
238
|
+
const lastRefreshError = getCodexRefreshFailure(alias, now);
|
|
239
|
+
const cooldownRemainingMs = codexCooldownRemainingMs(alias);
|
|
240
|
+
const status = lastRefreshError ? 'refresh-failed' : cooldownRemainingMs > 0 ? 'cooling' : 'ok';
|
|
241
|
+
return { status, cooldownRemainingMs, lastRefreshError };
|
|
212
242
|
}
|
|
213
243
|
/** Test seam — forget every remembered failure. */
|
|
214
244
|
export function _resetCodexRefreshFailuresForTest() {
|
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
|
+
`;
|
|
@@ -496,7 +496,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
|
|
|
496
496
|
*/
|
|
497
497
|
export declare const SUPPORTED_CC_RANGE: {
|
|
498
498
|
readonly min: "1.0.0";
|
|
499
|
-
readonly maxTested: "2.1.
|
|
499
|
+
readonly maxTested: "2.1.276";
|
|
500
500
|
};
|
|
501
501
|
/**
|
|
502
502
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/live-fingerprint.js
CHANGED
|
@@ -1194,7 +1194,7 @@ export function detectDrift(t, installedOverride) {
|
|
|
1194
1194
|
*/
|
|
1195
1195
|
export const SUPPORTED_CC_RANGE = {
|
|
1196
1196
|
min: '1.0.0',
|
|
1197
|
-
maxTested: '2.1.
|
|
1197
|
+
maxTested: '2.1.276',
|
|
1198
1198
|
};
|
|
1199
1199
|
/**
|
|
1200
1200
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
|
@@ -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';
|
|
@@ -45,7 +47,7 @@ import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequest
|
|
|
45
47
|
import { isClaudeServableModel } from './claude-model.js';
|
|
46
48
|
import { MODEL_UNROUTABLE } from './upstream-rejection.js';
|
|
47
49
|
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
48
|
-
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled,
|
|
50
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, CodexCredentialsUnavailableError, resetCodexPresenceCache, codexSeatStatus, } from './codex-accounts.js';
|
|
49
51
|
import { route as routeProvider } from './provider-adapter.js';
|
|
50
52
|
import { selectPoolFallbackModels } from './pool-fallback-tier.js';
|
|
51
53
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.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
|
|
@@ -2649,11 +2686,13 @@ export async function startProxy(opts = {}) {
|
|
|
2649
2686
|
needsRefresh: codexAccountNeedsRefresh(a),
|
|
2650
2687
|
models: peekCodexModelSlugs(a.alias) ?? [],
|
|
2651
2688
|
requestCount: codexRequestCounts.get(a.alias) ?? 0,
|
|
2652
|
-
// Why an account that LOOKS present is serving nothing:
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
|
|
2689
|
+
// Why an account that LOOKS present is serving nothing (dario#1343):
|
|
2690
|
+
// `status` is what the proxy will do with the seat, `cooldownRemainingMs`
|
|
2691
|
+
// how long selection skips it after the backend declined it, and
|
|
2692
|
+
// `lastRefreshError` the token-endpoint rejection remembered in-process
|
|
2693
|
+
// for a minute (DEV-179a412f). Read from memory only — a status read
|
|
2694
|
+
// never spends or exposes a credential, so there is no token in any of it.
|
|
2695
|
+
...codexSeatStatus(a.alias),
|
|
2657
2696
|
}));
|
|
2658
2697
|
res.writeHead(200, JSON_HEADERS);
|
|
2659
2698
|
res.end(JSON.stringify({
|
|
@@ -2671,6 +2710,40 @@ export async function startProxy(opts = {}) {
|
|
|
2671
2710
|
res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
|
|
2672
2711
|
return;
|
|
2673
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
|
+
}
|
|
2674
2747
|
// The ledger's per-day table, for anyone charting it. `lifetime` on
|
|
2675
2748
|
// /analytics is the summary; this is the data behind it.
|
|
2676
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.12",
|
|
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": {
|