@askalf/dario 3.31.17 → 3.31.19

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.d.ts CHANGED
@@ -48,3 +48,27 @@ export declare function parseBooleanEnv(value: string | undefined): boolean | un
48
48
  * Set(['thinking','env']) — value "thinking,env" → preserve listed
49
49
  */
50
50
  export declare function resolvePreserveOrchestrationTags(args: string[], env: string | undefined): Set<string> | undefined;
51
+ /**
52
+ * Decide whether this module is being invoked as the CLI entry point or
53
+ * imported as a library. Pure, exported for tests; the file-bottom uses
54
+ * it with `process.argv[1]` + `import.meta.url` + `fs.realpathSync`.
55
+ *
56
+ * The pre-v3.31.19 implementation was a strict string compare —
57
+ * import.meta.url === pathToFileURL(process.argv[1]).href
58
+ * — which silently failed on every npm-global install because the bin
59
+ * shim path (e.g. `/usr/local/bin/dario`) is a symlink to `dist/cli.js`.
60
+ * `argv[1]` arrived as the *symlink* path while `import.meta.url`
61
+ * resolved through the symlink to the real file. They never matched,
62
+ * the guard returned false, and the entire CLI body was gated out —
63
+ * `dario doctor`, `dario proxy`, every command produced zero output and
64
+ * exited 0. Reported as dario#143 by @tetsuco.
65
+ *
66
+ * The fix: also check the symlink-resolved path. `realpathSync`
67
+ * canonicalizes the argv[1] symlink into the same on-disk path that
68
+ * `import.meta.url` already represents, so a global-install bin-shim
69
+ * invocation matches. Direct invocation (`node dist/cli.js`) still
70
+ * matches via the first leg. Test-side imports of named exports still
71
+ * don't match either leg, which preserves the original purpose of the
72
+ * guard from #137 (v3.31.15).
73
+ */
74
+ export declare function isMainEntry(argv1: string | undefined | null, moduleHref: string, realpath?: (p: string) => string): boolean;
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // just want `parsePositiveIntEnv`) doesn't trigger a Bun relaunch or any
18
18
  // other startup side effect.
19
19
  import { unlink } from 'node:fs/promises';
20
+ import { realpathSync } from 'node:fs';
20
21
  import { join } from 'node:path';
21
22
  import { homedir } from 'node:os';
22
23
  import { pathToFileURL } from 'node:url';
@@ -652,6 +653,12 @@ async function help() {
652
653
  the server's verdict — the single reliable
653
654
  signal for scope-policy drift (dario#42/#71
654
655
  class). One GET to claude.ai; no PII.
656
+ dario doctor --usage Fire one minimal Haiku request through your
657
+ OAuth and surface the rate-limit snapshot:
658
+ All-models 5h/7d, per-model 7d buckets
659
+ (Sonnet only, Opus only when Anthropic ships
660
+ them), overage. Mirrors the user-dashboard
661
+ usage page. Costs ~1 subscription request.
655
662
  dario doctor --json Emit the check report as structured JSON
656
663
  for machine consumption (claude-bridge
657
664
  /status, CI scripts, etc.) instead of the
@@ -972,6 +979,7 @@ async function mcp() {
972
979
  async function doctor() {
973
980
  const { runChecks, formatChecks, formatChecksJson, exitCodeFor, runAuthCheck } = await import('./doctor.js');
974
981
  const probe = args.includes('--probe');
982
+ const usage = args.includes('--usage');
975
983
  const asJson = args.includes('--json');
976
984
  const authCheck = args.includes('--auth-check');
977
985
  if (authCheck) {
@@ -1008,7 +1016,7 @@ async function doctor() {
1008
1016
  console.log('');
1009
1017
  process.exit(result.verdict === 'match' ? 0 : 1);
1010
1018
  }
1011
- const checks = await runChecks({ probe });
1019
+ const checks = await runChecks({ probe, usage });
1012
1020
  if (asJson) {
1013
1021
  // JSON mode is meant for machine consumption (claude-bridge /status,
1014
1022
  // deepdive health checks, CI scripts) — no decorative header, no
@@ -1147,14 +1155,44 @@ const commands = {
1147
1155
  '--version': version,
1148
1156
  '-V': version,
1149
1157
  };
1150
- // Main-entry guard. Only run the Bun auto-relaunch and handler dispatch when
1151
- // this module is the direct entry point — importing it (from tests or for
1152
- // a library helper like `parsePositiveIntEnv`) must NOT start the proxy.
1153
- // Before this guard, `import { parsePositiveIntEnv } from './cli.js'` would
1154
- // fall through to `command = args[0] ?? 'proxy'` and fire `handler()`, which
1155
- // tried to run `startProxy()` and failed the test with "Not authenticated".
1156
- const isDirectEntry = typeof process.argv[1] === 'string' &&
1157
- import.meta.url === pathToFileURL(process.argv[1]).href;
1158
+ /**
1159
+ * Decide whether this module is being invoked as the CLI entry point or
1160
+ * imported as a library. Pure, exported for tests; the file-bottom uses
1161
+ * it with `process.argv[1]` + `import.meta.url` + `fs.realpathSync`.
1162
+ *
1163
+ * The pre-v3.31.19 implementation was a strict string compare
1164
+ * import.meta.url === pathToFileURL(process.argv[1]).href
1165
+ * — which silently failed on every npm-global install because the bin
1166
+ * shim path (e.g. `/usr/local/bin/dario`) is a symlink to `dist/cli.js`.
1167
+ * `argv[1]` arrived as the *symlink* path while `import.meta.url`
1168
+ * resolved through the symlink to the real file. They never matched,
1169
+ * the guard returned false, and the entire CLI body was gated out —
1170
+ * `dario doctor`, `dario proxy`, every command produced zero output and
1171
+ * exited 0. Reported as dario#143 by @tetsuco.
1172
+ *
1173
+ * The fix: also check the symlink-resolved path. `realpathSync`
1174
+ * canonicalizes the argv[1] symlink into the same on-disk path that
1175
+ * `import.meta.url` already represents, so a global-install bin-shim
1176
+ * invocation matches. Direct invocation (`node dist/cli.js`) still
1177
+ * matches via the first leg. Test-side imports of named exports still
1178
+ * don't match either leg, which preserves the original purpose of the
1179
+ * guard from #137 (v3.31.15).
1180
+ */
1181
+ export function isMainEntry(argv1, moduleHref, realpath = realpathSync) {
1182
+ if (typeof argv1 !== 'string' || argv1.length === 0)
1183
+ return false;
1184
+ if (moduleHref === pathToFileURL(argv1).href)
1185
+ return true;
1186
+ try {
1187
+ return moduleHref === pathToFileURL(realpath(argv1)).href;
1188
+ }
1189
+ catch {
1190
+ return false;
1191
+ }
1192
+ }
1193
+ // Main-entry guard. Only run the Bun auto-relaunch and handler dispatch
1194
+ // when this module is the direct CLI entry point.
1195
+ const isDirectEntry = isMainEntry(process.argv[1], import.meta.url);
1158
1196
  if (isDirectEntry) {
1159
1197
  // Bun auto-relaunch for TLS fingerprint fidelity. Only meaningful when
1160
1198
  // dario is the direct entry — if we're imported, whoever imported us
package/dist/doctor.d.ts CHANGED
@@ -50,6 +50,16 @@ export interface RunChecksOptions {
50
50
  * GET to `claude.ai` and runs in parallel with the other checks.
51
51
  */
52
52
  probe?: boolean;
53
+ /**
54
+ * Opt-in: fire a minimal `POST /v1/messages` through the user's OAuth
55
+ * (Haiku, `max_tokens=1`) to capture the current rate-limit snapshot,
56
+ * including the unified buckets AND the per-model buckets Anthropic
57
+ * started carving in late April 2026 (`7d_sonnet-utilization` etc).
58
+ * Surfaces "All models X%, Sonnet only Y%" the way the user dashboard
59
+ * does. Enable with `dario doctor --usage`; costs ~1 subscription
60
+ * request.
61
+ */
62
+ usage?: boolean;
53
63
  }
54
64
  /**
55
65
  * Run every available health check. Never throws — each check is
package/dist/doctor.js CHANGED
@@ -282,6 +282,135 @@ export async function runChecks(opts = {}) {
282
282
  });
283
283
  }
284
284
  }
285
+ // ---- Usage snapshot (opt-in, --usage).
286
+ // Fires one `POST /v1/messages` via the loaded OAuth (Haiku, max_tokens=1)
287
+ // to capture the current rate-limit snapshot including the per-model
288
+ // buckets Anthropic started carving around 2026-04-25. Surfaces the
289
+ // `All models` vs `Sonnet only` split the way the user dashboard does.
290
+ // Direct-to-Anthropic, not through the proxy — the proxy doesn't need
291
+ // to be running for `dario doctor --usage`.
292
+ if (opts.usage) {
293
+ try {
294
+ const { parseRateLimits } = await import('./pool.js');
295
+ const { billingBucketFromClaim } = await import('./analytics.js');
296
+ // Probe routing decision: Anthropic's subscription path rejects
297
+ // non-CC-shaped requests on Sonnet/Opus (returns 429 with no
298
+ // rate-limit headers). Haiku accepts the raw shape. So:
299
+ // - If a local `dario proxy` is listening, route through it —
300
+ // the proxy injects the full CC template and all three families
301
+ // succeed, giving us the _sonnet / _opus / _haiku per-model
302
+ // bucket headers on a single round trip each.
303
+ // - Else fall back to direct-to-Anthropic with Haiku only.
304
+ // Unified buckets surface but per-model buckets won't.
305
+ const dario_base = process.env.DARIO_TEST_URL || 'http://127.0.0.1:3456';
306
+ let probeEndpoint = `${dario_base}/v1/messages`;
307
+ let probeHeaders = {
308
+ 'content-type': 'application/json',
309
+ 'anthropic-version': '2023-06-01',
310
+ 'authorization': 'Bearer dario',
311
+ };
312
+ let proxyAvailable = false;
313
+ try {
314
+ const healthRes = await fetch(`${dario_base}/health`, { signal: AbortSignal.timeout(800) });
315
+ proxyAvailable = healthRes.ok;
316
+ }
317
+ catch { /* proxy not running */ }
318
+ if (!proxyAvailable) {
319
+ const { getAccessToken } = await import('./oauth.js');
320
+ const token = await getAccessToken();
321
+ probeEndpoint = 'https://api.anthropic.com/v1/messages';
322
+ probeHeaders = {
323
+ 'content-type': 'application/json',
324
+ 'anthropic-version': '2023-06-01',
325
+ 'anthropic-beta': 'oauth-2025-04-20',
326
+ 'authorization': `Bearer ${token}`,
327
+ };
328
+ checks.push({
329
+ status: 'info',
330
+ label: 'Usage probe',
331
+ detail: 'dario proxy not running — probing direct. Per-model buckets visible only when probing through a running proxy (start `dario proxy` in another terminal and re-run).',
332
+ });
333
+ }
334
+ // Probe each family in parallel. Anthropic only returns the
335
+ // per-model 7d bucket header on a request TO that family.
336
+ const families = [
337
+ { family: 'haiku', model: 'claude-haiku-4-5' },
338
+ { family: 'sonnet', model: 'claude-sonnet-4-6' },
339
+ { family: 'opus', model: 'claude-opus-4-7' },
340
+ ];
341
+ const probe = async (model) => {
342
+ const res = await fetch(probeEndpoint, {
343
+ method: 'POST',
344
+ headers: probeHeaders,
345
+ body: JSON.stringify({
346
+ model,
347
+ max_tokens: 1,
348
+ messages: [{ role: 'user', content: 'ok' }],
349
+ }),
350
+ signal: AbortSignal.timeout(15_000),
351
+ });
352
+ // Consume the body so the socket releases; we only care about headers.
353
+ await res.text().catch(() => '');
354
+ // Ignore 429/4xx snapshots without useful rate-limit headers.
355
+ if (!res.headers.get('anthropic-ratelimit-unified-status'))
356
+ return null;
357
+ return parseRateLimits(res.headers);
358
+ };
359
+ const results = await Promise.all(families.map(f => probe(f.model).catch(() => null)));
360
+ // Use the first non-null snapshot for the unified view — they
361
+ // should all agree on the unified buckets (same account, same moment).
362
+ const firstOk = results.find(s => s !== null);
363
+ if (!firstOk)
364
+ throw new Error('all probe requests failed');
365
+ const bucket = billingBucketFromClaim(firstOk.claim);
366
+ const pct = (n) => `${(n * 100).toFixed(1)}%`;
367
+ checks.push({
368
+ status: firstOk.util5h >= 0.90 ? 'warn' : 'ok',
369
+ label: 'Usage 5h (all)',
370
+ detail: `${pct(firstOk.util5h)} used • status=${firstOk.status} • claim=${firstOk.claim} (${bucket})`,
371
+ });
372
+ checks.push({
373
+ status: firstOk.util7d >= 0.90 ? 'warn' : 'ok',
374
+ label: 'Usage 7d (all)',
375
+ detail: `${pct(firstOk.util7d)} used`,
376
+ });
377
+ // Merge per-model buckets across all probes — each probe's response
378
+ // carries at most its own family bucket; union them for display.
379
+ const mergedPerModel = {};
380
+ for (const s of results) {
381
+ if (!s)
382
+ continue;
383
+ for (const [family, util] of Object.entries(s.perModel7d)) {
384
+ mergedPerModel[family] = util;
385
+ }
386
+ }
387
+ for (const [family, util] of Object.entries(mergedPerModel).sort()) {
388
+ const divergence = util - firstOk.util7d;
389
+ const marker = Math.abs(divergence) > 0.05
390
+ ? ` • Δ vs 7d(all): ${divergence >= 0 ? '+' : ''}${(divergence * 100).toFixed(1)}pp`
391
+ : '';
392
+ checks.push({
393
+ status: util >= 0.90 ? 'warn' : 'ok',
394
+ label: `Usage 7d (${family} only)`,
395
+ detail: `${pct(util)} used${marker}`,
396
+ });
397
+ }
398
+ if (firstOk.overageUtil > 0) {
399
+ checks.push({
400
+ status: firstOk.overageUtil >= 0.90 ? 'warn' : 'info',
401
+ label: 'Usage overage',
402
+ detail: `${pct(firstOk.overageUtil)} of configured monthly spend`,
403
+ });
404
+ }
405
+ }
406
+ catch (err) {
407
+ checks.push({
408
+ status: 'warn',
409
+ label: 'Usage snapshot',
410
+ detail: `probe failed: ${err.message}`,
411
+ });
412
+ }
413
+ }
285
414
  // ---- Account pool
286
415
  try {
287
416
  const { listAccountAliases, loadAllAccounts } = await import('./accounts.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.31.17",
3
+ "version": "3.31.19",
4
4
  "description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
5
5
  "type": "module",
6
6
  "bin": {