@askalf/dario 3.31.21 → 3.32.1

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/dist/cli.js CHANGED
@@ -181,8 +181,19 @@ async function proxy() {
181
181
  const passthrough = args.includes('--passthrough') || args.includes('--thin');
182
182
  const preserveTools = args.includes('--preserve-tools') || args.includes('--keep-tools');
183
183
  const hybridTools = args.includes('--hybrid-tools') || args.includes('--context-inject');
184
- if (preserveTools && hybridTools) {
185
- console.error('[dario] --preserve-tools and --hybrid-tools are mutually exclusive. Pick one.');
184
+ const mergeTools = args.includes('--merge-tools') || args.includes('--append-tools');
185
+ // The three modes shape the outbound `tools` array differently;
186
+ // combining any two would mean two different bodies. Caught here so
187
+ // the operator gets a clear error instead of one flag silently
188
+ // winning. startProxy enforces the same mutex defensively.
189
+ const toolModeCount = [preserveTools, hybridTools, mergeTools].filter(Boolean).length;
190
+ if (toolModeCount > 1) {
191
+ const picked = [
192
+ preserveTools && '--preserve-tools',
193
+ hybridTools && '--hybrid-tools',
194
+ mergeTools && '--merge-tools',
195
+ ].filter(Boolean).join(', ');
196
+ console.error(`[dario] tool-routing flags are mutually exclusive. Pick one (got: ${picked}).`);
186
197
  process.exit(1);
187
198
  }
188
199
  // Opt-out for v3.19.3's text-tool-client auto-detection. Operators who
@@ -268,6 +279,17 @@ async function proxy() {
268
279
  // the server side, so passing through a too-high value returns a clean
269
280
  // 400 rather than silently accepting beyond-model-max.
270
281
  const maxTokens = resolveMaxTokensFlag(args, process.env['DARIO_MAX_TOKENS']);
282
+ // --log-file <path> — append a one-line JSON record per completed
283
+ // request. Useful for backgrounded proxies where stdout is unobserved.
284
+ // Falls back to DARIO_LOG_FILE; off by default. Path is opened with
285
+ // append mode so multiple proxy restarts share a rolling history.
286
+ const logFile = parseLogFileFlag(args) ?? process.env['DARIO_LOG_FILE'] ?? undefined;
287
+ // --passthrough-betas=name1,name2 — operator-pinned beta allow-list.
288
+ // Names listed here are always forwarded to Anthropic regardless of
289
+ // CC's captured set or the client's own beta header; bypasses the
290
+ // billable-filter. Empty values are dropped. Falls back to
291
+ // DARIO_PASSTHROUGH_BETAS env var.
292
+ const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
271
293
  // Non-loopback bind without DARIO_API_KEY turns dario into an open
272
294
  // OAuth-subscription relay for anyone on the reachable network. Refuse
273
295
  // to start rather than rely on the operator to read the startup banner.
@@ -287,7 +309,56 @@ async function proxy() {
287
309
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
288
310
  process.exit(1);
289
311
  }
290
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens });
312
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas });
313
+ }
314
+ /**
315
+ * Parse `--passthrough-betas=<csv>` (or the env-var fallback) into a
316
+ * deduped, trimmed list. The CLI flag wins over the env var when both
317
+ * are set — that's the convention every other dario flag uses.
318
+ *
319
+ * Edge cases:
320
+ * - `--passthrough-betas=` (explicit empty) → returns []. The
321
+ * operator typed an empty value; this is the documented "clear the
322
+ * env-default, run with no pinned betas" override.
323
+ * - flag missing entirely → falls back to envVar.
324
+ * - empty entries / whitespace-only entries / duplicates are dropped.
325
+ */
326
+ export function parsePassthroughBetasFlag(args, envVar) {
327
+ const eqArg = args.find((a) => a.startsWith('--passthrough-betas='));
328
+ // When the flag is present at all (even with an empty value), it owns
329
+ // the result. Only fall back to the env var when the flag is absent.
330
+ const raw = eqArg !== undefined ? eqArg.slice('--passthrough-betas='.length) : envVar;
331
+ if (!raw)
332
+ return [];
333
+ const seen = new Set();
334
+ const out = [];
335
+ for (const piece of raw.split(',')) {
336
+ const trimmed = piece.trim();
337
+ if (trimmed.length > 0 && !seen.has(trimmed)) {
338
+ seen.add(trimmed);
339
+ out.push(trimmed);
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+ /**
345
+ * Parse `--log-file=<path>` or `--log-file <path>`. Returns the path
346
+ * string when present, undefined otherwise. An empty path (e.g.
347
+ * `--log-file=`) is treated as unset so the env-var fallback can apply.
348
+ */
349
+ function parseLogFileFlag(args) {
350
+ const eqArg = args.find(a => a.startsWith('--log-file='));
351
+ if (eqArg) {
352
+ const value = eqArg.slice('--log-file='.length);
353
+ return value.length > 0 ? value : undefined;
354
+ }
355
+ const idx = args.indexOf('--log-file');
356
+ if (idx >= 0 && idx + 1 < args.length) {
357
+ const value = args[idx + 1];
358
+ if (value && !value.startsWith('-'))
359
+ return value;
360
+ }
361
+ return undefined;
291
362
  }
292
363
  /**
293
364
  * Parse `--max-tokens=<N|client>` + `DARIO_MAX_TOKENS` env (dario#88).
@@ -671,11 +742,30 @@ async function help() {
671
742
  DARIO_API_KEY — with redacted previews and
672
743
  a targeted diagnosis (dario#97 class). Use
673
744
  --timeout-ms=N to adjust the 30s default.
745
+ dario doctor --bun-bootstrap
746
+ One-shot Bun installer. Closes the gap
747
+ between "doctor warned about Node-only TLS
748
+ fingerprint" and "Bun on PATH" without
749
+ copy-pasting a curl-to-shell line. Skips
750
+ when Bun is already installed. Pure
751
+ delegation to the official installer at
752
+ bun.com — dario does not vendor or pin a
753
+ Bun version.
674
754
  dario config Print the effective configuration (port,
675
755
  host, DARIO_API_KEY state, OAuth status,
676
756
  pool, backends, paths) with credentials
677
757
  redacted. Safe to paste into bug reports.
678
758
  --json for structured output.
759
+ dario usage Burn-rate summary of the running proxy's
760
+ traffic (last 60 min): requests, token
761
+ totals, subscription % vs. extra-usage,
762
+ per-account rotation if pool mode is on.
763
+ Hits /analytics on the local proxy. Works
764
+ only when proxy is running; for a one-off
765
+ rate-limit snapshot from Anthropic, see
766
+ \`dario doctor --usage\`. --port=N to target
767
+ a non-default port; --json for the raw
768
+ /analytics payload.
679
769
  dario upgrade npm install -g @askalf/dario@latest with a
680
770
  pre-flight current-vs-latest check.
681
771
 
@@ -691,6 +781,14 @@ async function help() {
691
781
  Loses subscription routing; use for custom agents
692
782
  --hybrid-tools Remap to CC tools, inject sessionId/requestId/etc.
693
783
  Keeps subscription routing for custom agents
784
+ --merge-tools Send CC's canonical tools first, append the
785
+ client's custom tools after (deduped by name).
786
+ Model can call either; tool calls flow back
787
+ unchanged. EXPERIMENTAL — Anthropic's billing
788
+ classifier may flip routing on the appended
789
+ tail. Validate with --verbose on the first
790
+ 1-2 requests. Mutually exclusive with
791
+ --preserve-tools and --hybrid-tools.
694
792
  --no-auto-detect Disable Cline/Kilo/Roo auto-preserve-tools
695
793
  (v3.19.3 behavior). Keeps CC fingerprint
696
794
  intact even when a text-tool client is
@@ -801,6 +899,20 @@ async function help() {
801
899
  --verbose, -v Log all requests
802
900
  --verbose=2, -vv Also dump redacted request bodies
803
901
  (env: DARIO_LOG_BODIES=1)
902
+ --log-file=PATH Append one JSON-ND record per completed
903
+ request to PATH. Useful for backgrounded
904
+ proxies where stdout is unobserved (where
905
+ --verbose can't help). Secrets scrubbed,
906
+ no request bodies. Env: DARIO_LOG_FILE.
907
+ --passthrough-betas=CSV Beta flags to ALWAYS forward upstream
908
+ regardless of CC's captured set or the
909
+ client's anthropic-beta header. Bypasses
910
+ the billable-beta filter. Per-account
911
+ rejection cache still applies (so a flag
912
+ upstream 400's gets dropped, not retried
913
+ forever). Use when you know a beta works
914
+ on your account but isn't in the captured
915
+ template. Env: DARIO_PASSTHROUGH_BETAS.
804
916
 
805
917
  Quick start:
806
918
  dario login # auto-detects Claude Code credentials
@@ -982,6 +1094,53 @@ async function doctor() {
982
1094
  const usage = args.includes('--usage');
983
1095
  const asJson = args.includes('--json');
984
1096
  const authCheck = args.includes('--auth-check');
1097
+ const bunBoot = args.includes('--bun-bootstrap');
1098
+ if (bunBoot) {
1099
+ // One-shot Bun installer. Closes the gap between "doctor warned
1100
+ // about Node-only TLS fingerprint" and "Bun is on PATH" without
1101
+ // making the user copy-paste a curl line from the README.
1102
+ // Probe first so we don't reinstall on a Bun-already-present host.
1103
+ const { probeBunVersion, bunBootstrap } = await import('./runtime-fingerprint.js');
1104
+ console.log('');
1105
+ console.log(' dario — Bun bootstrap');
1106
+ console.log(' ─────────────────────');
1107
+ console.log('');
1108
+ const existing = probeBunVersion();
1109
+ if (existing) {
1110
+ console.log(` Bun v${existing} already on PATH — nothing to install.`);
1111
+ console.log(' If dario is still running on Node, the auto-relaunch was bypassed (DARIO_NO_BUN set,');
1112
+ console.log(' or invoked through a wrapper that strips it). Re-run \`dario proxy\` directly.');
1113
+ console.log('');
1114
+ return;
1115
+ }
1116
+ console.log(' Bun is not on PATH. Running the official upstream installer:');
1117
+ console.log('');
1118
+ const result = await bunBootstrap();
1119
+ console.log('');
1120
+ if (result.exitCode === 0) {
1121
+ // Probe again — installer may write into a directory that the
1122
+ // current shell doesn't have on PATH yet (typical: ~/.bun/bin
1123
+ // appended to a profile that hasn't reloaded). We can't fix that
1124
+ // for the running shell; just call it out so the user knows what
1125
+ // to do next.
1126
+ const after = probeBunVersion();
1127
+ if (after) {
1128
+ console.log(` Bun v${after} installed. Re-run \`dario proxy\` to auto-relaunch under it.`);
1129
+ }
1130
+ else {
1131
+ console.log(' Installer reported success, but \`bun --version\` still fails from this shell.');
1132
+ console.log(' Open a new terminal (or source the profile the installer touched), then re-run');
1133
+ console.log(' \`dario doctor\` to confirm.');
1134
+ }
1135
+ console.log('');
1136
+ return;
1137
+ }
1138
+ console.error(` Installer exited with code ${result.exitCode}.`);
1139
+ console.error(` Manual fallback: ${result.runner}`);
1140
+ console.error(' Or visit https://bun.com for platform-specific instructions.');
1141
+ console.error('');
1142
+ process.exit(result.exitCode);
1143
+ }
985
1144
  if (authCheck) {
986
1145
  console.log('');
987
1146
  console.log(' dario — Auth Check');
@@ -1133,6 +1292,112 @@ async function upgrade() {
1133
1292
  console.log(' Upgrade complete. Run `dario --version` to confirm.');
1134
1293
  console.log('');
1135
1294
  }
1295
+ /**
1296
+ * `dario usage` — focused burn-rate summary of the running proxy's
1297
+ * traffic. Hits `/analytics` on the local proxy (default port 3456,
1298
+ * overridable with --port=N or DARIO_USAGE_PORT) and prints a
1299
+ * human-readable digest: requests in the last hour, token totals,
1300
+ * subscription % vs. extra-usage, per-account rotation if pool mode
1301
+ * is active.
1302
+ *
1303
+ * When the proxy isn't running on the expected port, prints a hint
1304
+ * pointing at `dario doctor --usage` (which fires a Haiku rate-limit
1305
+ * probe directly to Anthropic — different purpose, but the closest
1306
+ * substitute when there's no live proxy traffic to summarize).
1307
+ *
1308
+ * --json mode emits the raw /analytics payload for machine consumption
1309
+ * (CI dashboards, status bars, the MCP `usage` tool that wraps this).
1310
+ */
1311
+ async function usage() {
1312
+ const portArg = args.find(a => a.startsWith('--port='));
1313
+ const port = portArg
1314
+ ? parseInt(portArg.split('=')[1], 10)
1315
+ : process.env['DARIO_USAGE_PORT']
1316
+ ? parseInt(process.env['DARIO_USAGE_PORT'], 10)
1317
+ : 3456;
1318
+ const asJson = args.includes('--json');
1319
+ const url = `http://127.0.0.1:${port}/analytics`;
1320
+ let payload = null;
1321
+ let connectError = null;
1322
+ try {
1323
+ const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
1324
+ if (!res.ok) {
1325
+ connectError = `proxy responded ${res.status}`;
1326
+ }
1327
+ else {
1328
+ payload = await res.json();
1329
+ }
1330
+ }
1331
+ catch (err) {
1332
+ connectError = err instanceof Error ? err.message : String(err);
1333
+ }
1334
+ if (asJson) {
1335
+ if (payload) {
1336
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
1337
+ return;
1338
+ }
1339
+ process.stdout.write(JSON.stringify({ error: 'proxy not reachable', port, detail: connectError }, null, 2) + '\n');
1340
+ process.exit(1);
1341
+ }
1342
+ console.log('');
1343
+ console.log(' dario — Usage');
1344
+ console.log(' ─────────────');
1345
+ console.log('');
1346
+ if (!payload) {
1347
+ console.log(` Proxy not reachable on http://127.0.0.1:${port} (${connectError ?? 'no response'}).`);
1348
+ console.log(' `dario usage` summarizes traffic from a running proxy (live history).');
1349
+ console.log(' For a one-off rate-limit snapshot from Anthropic, run:');
1350
+ console.log('');
1351
+ console.log(' dario doctor --usage');
1352
+ console.log('');
1353
+ console.log(' Costs ~1 subscription request; works without a running proxy.');
1354
+ console.log('');
1355
+ process.exit(1);
1356
+ }
1357
+ // Pool mode response shape:
1358
+ // { window: { minutes, requests, ...stats }, allTime: {...},
1359
+ // perAccount, perModel, utilization, predictions }
1360
+ // Single-account mode response shape:
1361
+ // { mode: 'single-account', note: '...' }
1362
+ if (payload.mode === 'single-account') {
1363
+ console.log(' Mode: single-account');
1364
+ console.log('');
1365
+ console.log(` ${payload.note}`);
1366
+ console.log('');
1367
+ console.log(' For a live snapshot of your subscription rate limit, run:');
1368
+ console.log(' dario doctor --usage');
1369
+ console.log('');
1370
+ return;
1371
+ }
1372
+ const win = payload.window;
1373
+ const allTime = payload.allTime;
1374
+ const perAccount = payload.perAccount;
1375
+ console.log(' Mode: pool');
1376
+ console.log(` Window: last ${win?.minutes ?? 60} minutes`);
1377
+ console.log('');
1378
+ console.log(` Requests: ${win?.requests ?? 0}` + (allTime ? ` (all-time: ${allTime.requests ?? 0})` : ''));
1379
+ if (win && win.requests > 0) {
1380
+ console.log(` Input tokens: ${(win.totalInputTokens ?? 0).toLocaleString()}`);
1381
+ console.log(` Output tokens: ${(win.totalOutputTokens ?? 0).toLocaleString()}`);
1382
+ console.log(` Avg latency: ${win.avgLatencyMs ?? 0} ms`);
1383
+ if ((win.errorRate ?? 0) > 0) {
1384
+ console.log(` Error rate: ${((win.errorRate ?? 0) * 100).toFixed(1)}%`);
1385
+ }
1386
+ console.log(` Subscription %: ${win.subscriptionPercent ?? 0}%`);
1387
+ if ((win.estimatedCost ?? 0) > 0) {
1388
+ console.log(` Est. cost: $${(win.estimatedCost ?? 0).toFixed(4)} (would-be API cost)`);
1389
+ }
1390
+ }
1391
+ if (perAccount && Object.keys(perAccount).length > 0) {
1392
+ console.log('');
1393
+ console.log(' Per-account:');
1394
+ const aliasWidth = Math.max(...Object.keys(perAccount).map((a) => a.length));
1395
+ for (const [alias, stats] of Object.entries(perAccount)) {
1396
+ console.log(` ${alias.padEnd(aliasWidth)} ${stats.requests} req${stats.requests === 1 ? '' : 's'} (${stats.subscriptionPercent}% subscription)`);
1397
+ }
1398
+ }
1399
+ console.log('');
1400
+ }
1136
1401
  // Main
1137
1402
  const commands = {
1138
1403
  login,
@@ -1148,6 +1413,7 @@ const commands = {
1148
1413
  doctor,
1149
1414
  config,
1150
1415
  upgrade,
1416
+ usage,
1151
1417
  help,
1152
1418
  version,
1153
1419
  '--help': help,
package/dist/doctor.js CHANGED
@@ -210,6 +210,38 @@ export async function runChecks(opts = {}) {
210
210
  catch (err) {
211
211
  checks.push({ status: 'fail', label: 'Template', detail: `load failed: ${err.message}` });
212
212
  }
213
+ // ---- Per-request overhead surfacing.
214
+ // The CC system prompt + tool definitions are injected into every
215
+ // non-passthrough request and dominate the input-token cost on small
216
+ // turns. Anthropic caches them after the first hit (cache_creation
217
+ // tokens on call 1, then cache_read on subsequent calls within the
218
+ // 5-min/1-hr TTL), but non-CC users routing heavy tooling get
219
+ // surprised by the first-request charge. Surface the size up front
220
+ // so they can plan.
221
+ //
222
+ // No token estimate — char counts and tool count are factual; the
223
+ // tokenizer ratio varies enough between prose and tool-schema JSON
224
+ // (compressible structural keys) that any single divisor is
225
+ // misleading. Operators who want the exact number can read it off
226
+ // their first request's `cache_creation_input_tokens` once the proxy
227
+ // is warm. `--usage` adds the live snapshot for those who want it.
228
+ try {
229
+ const promptChars = CC_TEMPLATE.system_prompt?.length ?? 0;
230
+ const toolCount = (CC_TEMPLATE.tools ?? []).length;
231
+ const toolChars = JSON.stringify(CC_TEMPLATE.tools ?? []).length;
232
+ if (promptChars > 0 || toolCount > 0) {
233
+ checks.push({
234
+ status: 'info',
235
+ label: 'Overhead',
236
+ detail: `${promptChars.toLocaleString()} chars system prompt + ${toolCount} tool defs ` +
237
+ `(${toolChars.toLocaleString()} chars JSON-serialized) injected per non-passthrough ` +
238
+ `request. Cached after first hit; read-cost only on subsequent calls within ` +
239
+ `the 5-min/1-hr TTL. Exact token count surfaces as cache_creation_input_tokens ` +
240
+ `on the first response (or run \`dario doctor --usage\`).`,
241
+ });
242
+ }
243
+ }
244
+ catch { /* don't let overhead reporting break the doctor */ }
213
245
  // ---- Template drift
214
246
  try {
215
247
  const drift = detectDrift(CC_TEMPLATE);
@@ -429,6 +461,43 @@ export async function runChecks(opts = {}) {
429
461
  (expired > 0 ? `, ${expired} expired` : '') +
430
462
  (aliases.length < 2 ? ' (pool activates at 2+)' : ''),
431
463
  });
464
+ // Next-account-in-rotation surfacing. The proxy's per-request
465
+ // selector picks by max headroom (with 7d_<family> per-model
466
+ // bucket considered when a request's model family is known);
467
+ // doctor doesn't know the next request's model so it reports
468
+ // the family-agnostic pick. That's still the right preview for
469
+ // operators wondering "if I send a request right now, which
470
+ // account gets it?" — it matches `pool.select()` with no family
471
+ // hint, the same call the proxy uses when no model is parsed
472
+ // yet (e.g. on misshapen requests). Bypassed when only one
473
+ // account is loaded since "rotation" doesn't apply.
474
+ if (aliases.length >= 2) {
475
+ try {
476
+ const { AccountPool } = await import('./pool.js');
477
+ const pool = new AccountPool();
478
+ for (const acc of loaded) {
479
+ pool.add(acc.alias, {
480
+ accessToken: acc.accessToken,
481
+ refreshToken: acc.refreshToken,
482
+ expiresAt: acc.expiresAt,
483
+ deviceId: acc.deviceId,
484
+ accountUuid: acc.accountUuid,
485
+ });
486
+ }
487
+ const next = pool.select();
488
+ const ps = pool.status();
489
+ checks.push({
490
+ status: 'info',
491
+ label: 'Pool routing',
492
+ detail: next
493
+ ? `next: ${next.alias} (max-headroom select; ${ps.healthy}/${ps.accounts} healthy)`
494
+ : `no eligible account — all rejected or near-expiry (${ps.exhausted}/${ps.accounts} exhausted)`,
495
+ });
496
+ }
497
+ catch (err) {
498
+ checks.push({ status: 'warn', label: 'Pool routing', detail: `check failed: ${err.message}` });
499
+ }
500
+ }
432
501
  }
433
502
  }
434
503
  catch (err) {
@@ -282,7 +282,7 @@ export declare function _resetInstalledVersionProbeForTest(): void;
282
282
  */
283
283
  export declare const SUPPORTED_CC_RANGE: {
284
284
  readonly min: "1.0.0";
285
- readonly maxTested: "2.1.121";
285
+ readonly maxTested: "2.1.122";
286
286
  };
287
287
  /**
288
288
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -777,7 +777,7 @@ export function _resetInstalledVersionProbeForTest() {
777
777
  */
778
778
  export const SUPPORTED_CC_RANGE = {
779
779
  min: '1.0.0',
780
- maxTested: '2.1.121',
780
+ maxTested: '2.1.122',
781
781
  };
782
782
  /**
783
783
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -22,6 +22,32 @@ import type { McpTool } from './protocol.js';
22
22
  * tests can substitute pure synthetic data to avoid touching network /
23
23
  * filesystem / OAuth state.
24
24
  */
25
+ /**
26
+ * Shape returned by the `usage` data source. Mirrors the public subset
27
+ * of /analytics — keeps the MCP tool decoupled from internal Analytics
28
+ * record fields. Single-account mode returns `{ mode: 'single-account' }`
29
+ * with no stats since Analytics only collects in pool mode.
30
+ */
31
+ export interface UsageSummary {
32
+ mode: 'pool' | 'single-account';
33
+ reachable: boolean;
34
+ port?: number;
35
+ detail?: string;
36
+ window?: {
37
+ minutes: number;
38
+ requests: number;
39
+ totalInputTokens: number;
40
+ totalOutputTokens: number;
41
+ avgLatencyMs: number;
42
+ errorRate: number;
43
+ subscriptionPercent: number;
44
+ estimatedCost: number;
45
+ };
46
+ perAccount?: Record<string, {
47
+ requests: number;
48
+ subscriptionPercent: number;
49
+ }>;
50
+ }
25
51
  export interface ToolDataSources {
26
52
  doctor: () => Promise<Array<{
27
53
  status: string;
@@ -58,6 +84,8 @@ export interface ToolDataSources {
58
84
  templateSource: string;
59
85
  templateSchema: number | null;
60
86
  }>;
87
+ /** Burn-rate / consumption summary; see UsageSummary for the shape. */
88
+ usage: () => Promise<UsageSummary>;
61
89
  darioVersion: () => string;
62
90
  }
63
91
  export declare function buildToolRegistry(data: ToolDataSources): McpTool[];
package/dist/mcp/tools.js CHANGED
@@ -131,6 +131,56 @@ export function buildToolRegistry(data) {
131
131
  return textResult(lines.join('\n'));
132
132
  },
133
133
  },
134
+ {
135
+ name: 'usage',
136
+ description: 'Burn-rate summary of the running dario proxy\'s traffic over the last 60 minutes: requests, token totals, subscription % vs. extra-usage, per-account rotation if pool mode is on. Read-only — fetches /analytics from the local proxy. Returns a compact text summary; pair with the `dario usage --json` CLI for the full /analytics payload.',
137
+ inputSchema: emptyObjectSchema,
138
+ handler: async () => {
139
+ const u = await data.usage();
140
+ if (!u.reachable) {
141
+ const lines = [];
142
+ lines.push(`Proxy not reachable on port ${u.port ?? 3456}.`);
143
+ if (u.detail)
144
+ lines.push(`Detail: ${u.detail}`);
145
+ lines.push('');
146
+ lines.push('`usage` summarizes traffic from a running proxy. Start `dario proxy`, then re-call.');
147
+ lines.push('For a one-off rate-limit snapshot, run `dario doctor --usage` (~1 subscription request).');
148
+ return textResult(lines.join('\n'), true);
149
+ }
150
+ if (u.mode === 'single-account') {
151
+ return textResult([
152
+ 'Mode: single-account',
153
+ '',
154
+ 'Analytics history is collected only in pool mode (2+ accounts in ~/.dario/accounts/).',
155
+ 'For a one-off rate-limit snapshot from Anthropic, run `dario doctor --usage`.',
156
+ ].join('\n'));
157
+ }
158
+ const lines = [];
159
+ const w = u.window;
160
+ lines.push('Mode: pool');
161
+ lines.push(`Window: last ${w?.minutes ?? 60} minutes`);
162
+ lines.push(`Requests: ${w?.requests ?? 0}`);
163
+ if (w && w.requests > 0) {
164
+ lines.push(`Input tokens: ${w.totalInputTokens.toLocaleString()}`);
165
+ lines.push(`Output tokens: ${w.totalOutputTokens.toLocaleString()}`);
166
+ lines.push(`Avg latency: ${w.avgLatencyMs} ms`);
167
+ if (w.errorRate > 0)
168
+ lines.push(`Error rate: ${(w.errorRate * 100).toFixed(1)}%`);
169
+ lines.push(`Subscription %: ${w.subscriptionPercent}%`);
170
+ if (w.estimatedCost > 0)
171
+ lines.push(`Est. cost: $${w.estimatedCost.toFixed(4)} (would-be API cost)`);
172
+ }
173
+ if (u.perAccount && Object.keys(u.perAccount).length > 0) {
174
+ lines.push('');
175
+ lines.push('Per-account:');
176
+ const aliasWidth = Math.max(...Object.keys(u.perAccount).map((a) => a.length));
177
+ for (const [alias, stats] of Object.entries(u.perAccount)) {
178
+ lines.push(` ${alias.padEnd(aliasWidth)} ${stats.requests} req${stats.requests === 1 ? '' : 's'} (${stats.subscriptionPercent}% subscription)`);
179
+ }
180
+ }
181
+ return textResult(lines.join('\n'));
182
+ },
183
+ },
134
184
  ];
135
185
  }
136
186
  /**
@@ -187,9 +237,63 @@ export async function buildDefaultToolRegistry() {
187
237
  templateSchema: tmpl._schemaVersion ?? null,
188
238
  };
189
239
  },
240
+ usage: async () => fetchUsage(),
190
241
  darioVersion: () => pkgVersion,
191
242
  });
192
243
  }
244
+ /**
245
+ * Fetch the local proxy's `/analytics` endpoint and shape it into the
246
+ * MCP-tool surface. Port resolution mirrors `dario usage`:
247
+ * DARIO_USAGE_PORT, then DARIO_PORT (proxy's own default-port env), then
248
+ * 3456. 3-second timeout — we don't block the MCP client on a slow
249
+ * proxy.
250
+ *
251
+ * Failure modes are returned as `{ reachable: false, detail }` rather
252
+ * than thrown, so the tool handler can present a helpful message
253
+ * instead of a generic protocol error.
254
+ */
255
+ async function fetchUsage() {
256
+ const port = process.env.DARIO_USAGE_PORT
257
+ ? parseInt(process.env.DARIO_USAGE_PORT, 10)
258
+ : process.env.DARIO_PORT
259
+ ? parseInt(process.env.DARIO_PORT, 10)
260
+ : 3456;
261
+ try {
262
+ const res = await fetch(`http://127.0.0.1:${port}/analytics`, { signal: AbortSignal.timeout(3000) });
263
+ if (!res.ok) {
264
+ return { mode: 'pool', reachable: false, port, detail: `HTTP ${res.status}` };
265
+ }
266
+ const body = await res.json();
267
+ if (body.mode === 'single-account') {
268
+ return { mode: 'single-account', reachable: true, port };
269
+ }
270
+ const w = body.window;
271
+ return {
272
+ mode: 'pool',
273
+ reachable: true,
274
+ port,
275
+ window: {
276
+ minutes: w?.minutes ?? 60,
277
+ requests: w?.requests ?? 0,
278
+ totalInputTokens: w?.totalInputTokens ?? 0,
279
+ totalOutputTokens: w?.totalOutputTokens ?? 0,
280
+ avgLatencyMs: w?.avgLatencyMs ?? 0,
281
+ errorRate: w?.errorRate ?? 0,
282
+ subscriptionPercent: w?.subscriptionPercent ?? 0,
283
+ estimatedCost: w?.estimatedCost ?? 0,
284
+ },
285
+ perAccount: body.perAccount,
286
+ };
287
+ }
288
+ catch (err) {
289
+ return {
290
+ mode: 'pool',
291
+ reachable: false,
292
+ port,
293
+ detail: err instanceof Error ? err.message : String(err),
294
+ };
295
+ }
296
+ }
193
297
  async function readDarioVersion() {
194
298
  try {
195
299
  const { readFileSync } = await import('node:fs');