@askalf/dario 6.6.7 → 6.7.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/README.md CHANGED
@@ -337,6 +337,17 @@ curl localhost:3456/v1/messages \
337
337
 
338
338
  You get the Claude answer, exactly as you would have. Beside it, dario runs the same prompt past `gpt-5.6-sol` and writes both to `~/.dario/compare/<timestamp>-<model>.json`, in your own wire shape, so you are comparing like with like. The comparison cannot degrade the request it observes: it only reads bytes already on their way out, your request is never held open for it, and a comparison that fails, times out or has nowhere to go is dropped with the record still written. Both sides are stored as raw payloads, because extracting text is where a bug would quietly make two answers look more alike than they are.
339
339
 
340
+ Read them with **`dario compare`**: calls, success rate, median latency, how often each model's answer parsed as JSON, average length — and, above all, why any comparison was skipped. That last column is the point. The records had no reader until 6.7, and a week-long comparison on a box collected 919 of them without a single usable result, every one carrying its own reason inside the file. A log nobody can read is a log nobody reads.
341
+
342
+ ```
343
+ Records: 1,515 (2026-09-06 → 2026-09-13)
344
+ Compared: 1,515 with both sides
345
+
346
+ model calls 200s median valid JSON avg chars
347
+ gpt-5.6-luna 1515 100% 2736ms 98% 410
348
+ gpt-5.5 1515 99% 2729ms 100% 523
349
+ ```
350
+
340
351
  ## Many seats, one endpoint
341
352
 
342
353
  **Every dario is a pool.** A plain `dario login` is a pool of one; there is no separate mode to switch on. Hold more than one seat — a personal Max and a work Max, a couple of Pros, team seats — and the same `localhost:3456` routes every request to whichever seat has the most headroom, live, per request.
@@ -506,7 +517,7 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
506
517
  | `dario accounts list` / `add` / `remove` / `check <alias>` | Pool management; `check` sends one pinned request per model through the running proxy (admin API on) |
507
518
  | `dario backend list` / `add` / `remove` | OpenAI-compatible API-key backends |
508
519
  | `dario codex list` / `add` / `remove` | ChatGPT accounts (the long form of `dario add altman`) |
509
- | `dario usage` · `dario config` · `dario status` | Lifetime API-equivalent spend + burn rate for the last hour (`--card` writes the share card) · effective config, redacted · token health |
520
+ | `dario usage` · `dario compare` · `dario config` · `dario status` | Lifetime API-equivalent spend + burn rate for the last hour (`--card` writes the share card) · read the shadow-compare log · effective config, redacted · token health |
510
521
  | `dario resume` · `dario refresh` · `dario logout` · `dario upgrade` | Clear an overage halt · force a token refresh · delete credentials · safe self-update |
511
522
  | `dario mcp` · `dario subagent install` / `remove` / `status` | Reach dario from inside any MCP client, or from inside a Claude Code session, read-only |
512
523
 
package/dist/cli.js CHANGED
@@ -1594,6 +1594,13 @@ async function help() {
1594
1594
  down). --card[=file.svg] writes a share
1595
1595
  card of that number (default
1596
1596
  dario-api-equivalent.svg). (v6.6)
1597
+ dario compare Read the shadow-compare log written by
1598
+ requests carrying \`x-dario-compare\`:
1599
+ per-model calls, success rate, median
1600
+ latency, how often the answer parsed as
1601
+ JSON, average length — plus why any
1602
+ comparison was skipped. --dir=PATH to read
1603
+ elsewhere, --json for the summary. (v6.7)
1597
1604
  dario upgrade npm install -g @askalf/dario@latest with a
1598
1605
  pre-flight current-vs-latest check.
1599
1606
 
@@ -2574,6 +2581,29 @@ async function add() {
2574
2581
  process.exit(1);
2575
2582
  }
2576
2583
  // Main
2584
+ /**
2585
+ * `dario compare` — read the shadow-compare log.
2586
+ *
2587
+ * The records have existed since v6.0.0 with nothing to read them with, which
2588
+ * is how a week-long bake-off on a production box collected 919 failed
2589
+ * comparisons without anyone noticing (#1306): the reason was in the files,
2590
+ * and nobody opens 919 files. This prints the answer instead.
2591
+ *
2592
+ * --dir=PATH read somewhere other than ~/.dario/compare
2593
+ * --json the summary as JSON, for a dashboard or a diff over time
2594
+ */
2595
+ async function compare() {
2596
+ const { DEFAULT_COMPARE_DIR, readCompareDir, summarizeCompareRecords, formatCompareReport } = await import('./compare-report.js');
2597
+ const dirArg = args.find(a => a.startsWith('--dir='));
2598
+ const dir = dirArg ? dirArg.slice('--dir='.length) : DEFAULT_COMPARE_DIR;
2599
+ const summary = summarizeCompareRecords(readCompareDir(dir));
2600
+ if (args.includes('--json')) {
2601
+ process.stdout.write(JSON.stringify({ dir, ...summary }, null, 2) + '\n');
2602
+ return;
2603
+ }
2604
+ for (const line of formatCompareReport(summary, dir))
2605
+ console.log(line);
2606
+ }
2577
2607
  const commands = {
2578
2608
  login,
2579
2609
  status,
@@ -2592,6 +2622,7 @@ const commands = {
2592
2622
  config,
2593
2623
  upgrade,
2594
2624
  usage,
2625
+ compare,
2595
2626
  tui,
2596
2627
  help,
2597
2628
  version,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reading the shadow-compare log.
3
+ *
4
+ * dario has written one JSON record per compared request since v6.0.0 and
5
+ * shipped nothing to read them with. That gap is not cosmetic: a bake-off on
6
+ * the box collected 919 records over a week, every one of them a failed
7
+ * comparison, with the reason sitting inside the files (#1306). Nobody opened
8
+ * them, because opening 919 JSON blobs is not a thing anyone does by hand.
9
+ *
10
+ * So the log gets a reader. `dario compare` answers the question the records
11
+ * were collected for — which model is faster, which returns what you asked
12
+ * for, how often either failed — and prints the skip reasons, so a comparison
13
+ * that is not running says so on the first line instead of in file 700.
14
+ *
15
+ * Pure except for the directory read: the summary is computed from records the
16
+ * caller supplies, so the shapes below are testable without a filesystem.
17
+ */
18
+ export declare const DEFAULT_COMPARE_DIR: string;
19
+ /** One side of a comparison, as written by src/compare.ts. */
20
+ export interface CompareSideRecord {
21
+ status: number | null;
22
+ body: string;
23
+ ms: number;
24
+ }
25
+ export interface CompareRecordShape {
26
+ ts?: string;
27
+ primaryModel?: string;
28
+ comparedModel?: string;
29
+ primary?: CompareSideRecord | null;
30
+ compare?: CompareSideRecord | null;
31
+ skipped?: string;
32
+ }
33
+ export interface ModelStats {
34
+ model: string;
35
+ calls: number;
36
+ ok: number;
37
+ /** Median latency in ms, or null when nothing recorded one. */
38
+ medianMs: number | null;
39
+ /** Responses whose text parsed as JSON — the thing an extraction caller wants. */
40
+ json: number;
41
+ avgChars: number;
42
+ }
43
+ export interface CompareSummary {
44
+ records: number;
45
+ /** Records carrying BOTH sides — the only ones that compare anything. */
46
+ pairs: number;
47
+ first: string | null;
48
+ last: string | null;
49
+ perModel: ModelStats[];
50
+ /** Skip reason → count, most common first. */
51
+ skips: Array<{
52
+ reason: string;
53
+ count: number;
54
+ }>;
55
+ }
56
+ /**
57
+ * The assistant text inside a recorded body, whatever it is wrapped in: an
58
+ * Anthropic message, a legacy completion, or an SSE stream of text deltas.
59
+ * Returns '' when there is nothing quotable — a caller measuring "did this
60
+ * answer with JSON" wants the text, not the envelope.
61
+ */
62
+ export declare function textOfBody(body: string | undefined | null): string;
63
+ /** Whether text is JSON once a markdown fence is peeled off. */
64
+ export declare function looksLikeJson(text: string): boolean;
65
+ export declare function summarizeCompareRecords(records: readonly CompareRecordShape[]): CompareSummary;
66
+ /** Read every record in a directory, skipping anything unparseable. */
67
+ export declare function readCompareDir(dir: string): CompareRecordShape[];
68
+ /** The report `dario compare` prints. Two-space indent, like the other commands. */
69
+ export declare function formatCompareReport(s: CompareSummary, dir: string): string[];
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Reading the shadow-compare log.
3
+ *
4
+ * dario has written one JSON record per compared request since v6.0.0 and
5
+ * shipped nothing to read them with. That gap is not cosmetic: a bake-off on
6
+ * the box collected 919 records over a week, every one of them a failed
7
+ * comparison, with the reason sitting inside the files (#1306). Nobody opened
8
+ * them, because opening 919 JSON blobs is not a thing anyone does by hand.
9
+ *
10
+ * So the log gets a reader. `dario compare` answers the question the records
11
+ * were collected for — which model is faster, which returns what you asked
12
+ * for, how often either failed — and prints the skip reasons, so a comparison
13
+ * that is not running says so on the first line instead of in file 700.
14
+ *
15
+ * Pure except for the directory read: the summary is computed from records the
16
+ * caller supplies, so the shapes below are testable without a filesystem.
17
+ */
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ export const DEFAULT_COMPARE_DIR = join(homedir(), '.dario', 'compare');
22
+ /**
23
+ * The assistant text inside a recorded body, whatever it is wrapped in: an
24
+ * Anthropic message, a legacy completion, or an SSE stream of text deltas.
25
+ * Returns '' when there is nothing quotable — a caller measuring "did this
26
+ * answer with JSON" wants the text, not the envelope.
27
+ */
28
+ export function textOfBody(body) {
29
+ if (!body)
30
+ return '';
31
+ try {
32
+ const j = JSON.parse(body);
33
+ if (Array.isArray(j.content))
34
+ return j.content.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('');
35
+ if (typeof j.completion === 'string')
36
+ return j.completion;
37
+ }
38
+ catch { /* not JSON — an SSE stream, most likely */ }
39
+ const deltas = [...String(body).matchAll(/"type"\s*:\s*"text_delta"\s*,\s*"text"\s*:\s*("(?:[^"\\]|\\.)*")/g)];
40
+ if (deltas.length > 0) {
41
+ return deltas.map((m) => { try {
42
+ return JSON.parse(m[1]);
43
+ }
44
+ catch {
45
+ return '';
46
+ } }).join('');
47
+ }
48
+ return '';
49
+ }
50
+ /** Whether text is JSON once a markdown fence is peeled off. */
51
+ export function looksLikeJson(text) {
52
+ const s = String(text).trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
53
+ if (s.length === 0)
54
+ return false;
55
+ try {
56
+ JSON.parse(s);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ function median(values) {
64
+ if (values.length === 0)
65
+ return null;
66
+ const sorted = [...values].sort((a, b) => a - b);
67
+ return sorted[Math.floor(sorted.length / 2)];
68
+ }
69
+ export function summarizeCompareRecords(records) {
70
+ const acc = new Map();
71
+ const skips = new Map();
72
+ let pairs = 0;
73
+ let first = null;
74
+ let last = null;
75
+ const add = (model, side) => {
76
+ if (!model || !side)
77
+ return;
78
+ let s = acc.get(model);
79
+ if (!s) {
80
+ s = { calls: 0, ok: 0, ms: [], json: 0, chars: 0 };
81
+ acc.set(model, s);
82
+ }
83
+ s.calls++;
84
+ if (side.status === 200)
85
+ s.ok++;
86
+ if (typeof side.ms === 'number' && Number.isFinite(side.ms))
87
+ s.ms.push(side.ms);
88
+ const text = textOfBody(side.body);
89
+ s.chars += text.length;
90
+ if (looksLikeJson(text))
91
+ s.json++;
92
+ };
93
+ for (const r of records) {
94
+ if (typeof r.ts === 'string') {
95
+ first ??= r.ts;
96
+ last = r.ts;
97
+ }
98
+ add(r.primaryModel, r.primary);
99
+ if (r.skipped) {
100
+ skips.set(r.skipped, (skips.get(r.skipped) ?? 0) + 1);
101
+ continue;
102
+ }
103
+ add(r.comparedModel, r.compare);
104
+ if (r.primary && r.compare)
105
+ pairs++;
106
+ }
107
+ const perModel = [...acc]
108
+ .map(([model, s]) => ({
109
+ model,
110
+ calls: s.calls,
111
+ ok: s.ok,
112
+ medianMs: median(s.ms),
113
+ json: s.json,
114
+ avgChars: Math.round(s.chars / Math.max(1, s.calls)),
115
+ }))
116
+ .sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model));
117
+ return {
118
+ records: records.length,
119
+ pairs,
120
+ first,
121
+ last,
122
+ perModel,
123
+ skips: [...skips].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count),
124
+ };
125
+ }
126
+ /** Read every record in a directory, skipping anything unparseable. */
127
+ export function readCompareDir(dir) {
128
+ let names;
129
+ try {
130
+ names = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ const out = [];
136
+ for (const name of names) {
137
+ try {
138
+ out.push(JSON.parse(readFileSync(join(dir, name), 'utf-8')));
139
+ }
140
+ catch { /* a half-written or hand-edited file is not worth failing the report over */ }
141
+ }
142
+ return out;
143
+ }
144
+ const pct = (a, b) => (b === 0 ? '—' : `${Math.round((a / b) * 100)}%`);
145
+ /** The report `dario compare` prints. Two-space indent, like the other commands. */
146
+ export function formatCompareReport(s, dir) {
147
+ const out = [];
148
+ out.push('');
149
+ out.push(' dario — Shadow compare');
150
+ out.push(' ─────────────────────');
151
+ out.push('');
152
+ if (s.records === 0) {
153
+ out.push(` No records in ${dir}.`);
154
+ out.push('');
155
+ out.push(' A comparison is requested per-request with the `x-dario-compare: <model>` header,');
156
+ out.push(' and the model has to be one your ChatGPT plan lists. Nothing is compared by default.');
157
+ out.push('');
158
+ return out;
159
+ }
160
+ const span = s.first && s.last && s.first.slice(0, 10) !== s.last.slice(0, 10)
161
+ ? `${s.first.slice(0, 10)} → ${s.last.slice(0, 10)}`
162
+ : (s.first ?? '').slice(0, 10);
163
+ out.push(` Records: ${s.records.toLocaleString('en-US')}${span ? ` (${span})` : ''}`);
164
+ out.push(` Compared: ${s.pairs.toLocaleString('en-US')} with both sides`);
165
+ out.push('');
166
+ const width = Math.max(8, ...s.perModel.map((m) => m.model.length));
167
+ out.push(` ${'model'.padEnd(width)} calls 200s median valid JSON avg chars`);
168
+ for (const m of s.perModel) {
169
+ out.push(` ${m.model.padEnd(width)} ${String(m.calls).padStart(7)} ${pct(m.ok, m.calls).padStart(7)} `
170
+ + `${(m.medianMs === null ? '—' : `${m.medianMs}ms`).padStart(9)} ${pct(m.json, m.calls).padStart(12)} `
171
+ + `${String(m.avgChars).padStart(11)}`);
172
+ }
173
+ if (s.skips.length > 0) {
174
+ out.push('');
175
+ out.push(' Skipped comparisons:');
176
+ for (const { reason, count } of s.skips) {
177
+ out.push(` ${String(count).padStart(5)} ${reason.length > 96 ? `${reason.slice(0, 93)}…` : reason}`);
178
+ }
179
+ }
180
+ if (s.pairs === 0) {
181
+ out.push('');
182
+ out.push(' Nothing has both sides yet, so there is nothing to compare — see the skip reasons above.');
183
+ }
184
+ out.push('');
185
+ return out;
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.6.7",
3
+ "version": "6.7.1",
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": {