@askalf/dario 6.16.0 → 6.17.0

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/donuts.js CHANGED
@@ -1,3 +1,16 @@
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` page
8
+ * loaded before 6.17 (still served). 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 { readFileSync } from 'node:fs';
1
14
  import { formatUsd } from './ledger.js';
2
15
  const PALETTE = ['#7c3aed', '#db2777', '#2563eb', '#059669', '#d97706', '#0891b2'];
3
16
  const OTHER = '#6b7280';
@@ -90,7 +103,7 @@ export function renderSpendDonuts(s) {
90
103
  /**
91
104
  * The server-rendered fragment behind `/analytics/ui`: headline, the three
92
105
  * 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.
106
+ * `/analytics`; the page before 6.17 fetched it with the token the viewer typed; it is still served.
94
107
  */
95
108
  export function renderAnalyticsView(summary, lifetime, version) {
96
109
  const w = summary.window;
@@ -119,58 +132,37 @@ ${table}
119
132
  ${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
133
  <div class="foot">dario ${escapeHtml(version)} · rendered ${escapeHtml(new Date().toISOString().slice(0, 19).replace('T', ' '))} UTC</div>`;
121
134
  }
135
+ /** The page served when the build ran tsc without the copy step: say so instead of a 500. */
136
+ const ANALYTICS_UI_MISSING = '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>dario</title></head><body><p>This build is missing <code>analytics-ui.html</code> next to <code>donuts.js</code>; run <code>npm run build</code>.</p></body></html>';
122
137
  /**
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.
138
+ * A reader of the dashboard page that reads it once: the first read that succeeds is kept for
139
+ * every call after it. A failed read answers with the missing-file page and keeps nothing, so
140
+ * copying the file in takes effect on the next request without a restart. The reader is a
141
+ * parameter so the test can count the reads.
126
142
  */
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
- `;
143
+ export function analyticsUiLoader(read) {
144
+ let page = null;
145
+ return () => {
146
+ if (page !== null)
147
+ return page;
148
+ try {
149
+ page = read();
150
+ return page;
151
+ }
152
+ catch {
153
+ return ANALYTICS_UI_MISSING;
154
+ }
155
+ };
156
+ }
157
+ /**
158
+ * The dashboard behind `/analytics/ui`: `dist/analytics-ui.html`, copied from `src/` by the
159
+ * build and read once, on the first request. It carries no data and no credential. It asks for
160
+ * the analytics token or an API key once (kept in sessionStorage) and reads `/analytics`,
161
+ * `/accounts`, `/codex`, `/health`, `/analytics/ledger` and `/admin/keys` with it, and tails
162
+ * `/analytics/stream`; it loads nothing from outside the proxy. The
163
+ * `window.DARIO_COCKPIT = "served"` line in its head tells the page a proxy is serving it, so it
164
+ * goes live at once; the same file opened from disk runs on sample data, because the page checks
165
+ * the protocol before the marker. `/analytics/view` and the rings above are unchanged for the CLI
166
+ * and for anything that embedded them.
167
+ */
168
+ export const analyticsUiPage = analyticsUiLoader(() => readFileSync(new URL('./analytics-ui.html', import.meta.url), 'utf8'));
package/dist/ledger.d.ts CHANGED
@@ -142,6 +142,11 @@ export declare function dayKey(atMs: number): string;
142
142
  * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
143
143
  * the header) are covered — the request was served, and nothing says it was
144
144
  * billed per token.
145
+ *
146
+ * `credits` is metered too: the credits rung spends the monthly API credits
147
+ * a Max plan includes, at API rates, from a balance with an end. That is
148
+ * money the plan handed over and the request used up, not a bill that never
149
+ * arrived, so it is reported as spent and never counted as saved.
145
150
  */
146
151
  export declare function ledgerBucketFor(record: Pick<RequestRecord, 'status' | 'claim'>): LedgerBucket | null;
147
152
  /**
@@ -169,6 +174,19 @@ export declare function consumerDayUsage(file: LedgerFile, consumer: string, now
169
174
  /** `1234` → `1.2k`, `1234567` → `1.2M`; below a thousand, the number itself. */
170
175
  export declare function formatTokenCount(n: number): string;
171
176
  export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
177
+ /** One row of the table as money: the bucket's USD at that day's rate, present where the bucket is. */
178
+ export type PricedLedgerRow = Partial<Record<LedgerBucket, number>>;
179
+ /**
180
+ * The table priced row for row, the way summarizeLedger prices it: what
181
+ * /analytics/ledger sends next to the raw cells, so a reader charting them
182
+ * (the dashboard, a script) needs no price list of its own and a pricing
183
+ * correction here reaches every reader at once.
184
+ */
185
+ export interface PricedLedger {
186
+ days: Record<string, Record<string, PricedLedgerRow>>;
187
+ consumers: Record<string, Record<string, Record<string, PricedLedgerRow>>>;
188
+ }
189
+ export declare function priceLedger(file: LedgerFile): PricedLedger;
172
190
  /**
173
191
  * Read a ledger file for display without a running proxy (`dario usage`
174
192
  * when the proxy is down). Missing file → null; unreadable → null with the
@@ -204,6 +222,8 @@ export declare class Ledger {
204
222
  };
205
223
  /** The raw per-day table, for /analytics/ledger. */
206
224
  snapshot(): LedgerFile;
225
+ /** The same table as money, sent next to it on /analytics/ledger. */
226
+ priced(): PricedLedger;
207
227
  private scheduleFlush;
208
228
  /** Write now if anything changed. Serialized; a failure is logged, not thrown. */
209
229
  flush(): Promise<void>;
package/dist/ledger.js CHANGED
@@ -71,12 +71,17 @@ function dayMs(day) {
71
71
  * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
72
72
  * the header) are covered — the request was served, and nothing says it was
73
73
  * billed per token.
74
+ *
75
+ * `credits` is metered too: the credits rung spends the monthly API credits
76
+ * a Max plan includes, at API rates, from a balance with an end. That is
77
+ * money the plan handed over and the request used up, not a bill that never
78
+ * arrived, so it is reported as spent and never counted as saved.
74
79
  */
75
80
  export function ledgerBucketFor(record) {
76
81
  if (record.status < 200 || record.status >= 300)
77
82
  return null;
78
83
  const bucket = billingBucketFromClaim(record.claim);
79
- return bucket === 'api' || bucket === 'extra_usage' ? 'metered' : 'covered';
84
+ return bucket === 'api' || bucket === 'extra_usage' || bucket === 'credits' ? 'metered' : 'covered';
80
85
  }
81
86
  function isCell(v) {
82
87
  if (!v || typeof v !== 'object')
@@ -365,6 +370,36 @@ export function summarizeLedger(file, path, now = Date.now()) {
365
370
  perConsumer: summarizeLedgerConsumers(file, now),
366
371
  };
367
372
  }
373
+ export function priceLedger(file) {
374
+ const priceRow = (day, model, row) => {
375
+ const at = dayMs(day);
376
+ const out = {};
377
+ if (row.covered)
378
+ out.covered = round(costOfTokens(model, at, row.covered));
379
+ if (row.metered)
380
+ out.metered = round(costOfTokens(model, at, row.metered));
381
+ return out;
382
+ };
383
+ const days = {};
384
+ for (const [day, models] of Object.entries(file.days)) {
385
+ const priced = {};
386
+ for (const [model, row] of Object.entries(models))
387
+ priced[model] = priceRow(day, model, row);
388
+ days[day] = priced;
389
+ }
390
+ const consumers = {};
391
+ for (const [day, byConsumer] of Object.entries(file.consumers ?? {})) {
392
+ const priced = {};
393
+ for (const [consumer, models] of Object.entries(byConsumer)) {
394
+ const rows = {};
395
+ for (const [model, row] of Object.entries(models))
396
+ rows[model] = priceRow(day, model, row);
397
+ priced[consumer] = rows;
398
+ }
399
+ consumers[day] = priced;
400
+ }
401
+ return { days, consumers };
402
+ }
368
403
  /**
369
404
  * Read a ledger file for display without a running proxy (`dario usage`
370
405
  * when the proxy is down). Missing file → null; unreadable → null with the
@@ -438,6 +473,10 @@ export class Ledger {
438
473
  snapshot() {
439
474
  return JSON.parse(JSON.stringify(this.file));
440
475
  }
476
+ /** The same table as money, sent next to it on /analytics/ledger. */
477
+ priced() {
478
+ return priceLedger(this.file);
479
+ }
441
480
  scheduleFlush() {
442
481
  this.dirty = true;
443
482
  if (this.timer)
package/dist/metrics.js CHANGED
@@ -130,7 +130,8 @@ export function renderPrometheus(input) {
130
130
  if (p.estimatedExhaustionMinutes !== null) {
131
131
  metric('dario_predicted_exhaustion_minutes', 'Minutes until the current seat window is predicted to exhaust at the present burn rate.', [[{}, p.estimatedExhaustionMinutes]]);
132
132
  }
133
- metric('dario_burn_tokens_per_minute', 'Token burn rate over the rolling window.', [[{}, p.tokenBurnRate]]);
133
+ metric('dario_burn_tokens_per_minute', 'Input and output token burn rate over the rolling window; cache reads and writes are not in it.', [[{}, p.tokenBurnRate]]);
134
+ metric('dario_burn_total_tokens_per_minute', 'Token burn rate over the rolling window, every kind: input, output, cache reads and cache writes.', [[{}, p.totalTokenBurnRate]]);
134
135
  metric('dario_burn_cost_usd_per_minute', 'API-equivalent cost burn rate over the rolling window, USD/min.', [[{}, p.costBurnRate]]);
135
136
  // ---- ledger (survives restarts) ----------------------------------------
136
137
  if (lifetime) {
package/dist/proxy.d.ts CHANGED
@@ -671,6 +671,16 @@ interface ProxyOptions {
671
671
  * a CLI flag, so the key never lands in `ps`/argv).
672
672
  */
673
673
  upstreamApiKey?: string;
674
+ /**
675
+ * The key behind a `credits:<model>` entry of `--pool-fallback`
676
+ * (credits-rung.ts): a Console API key of the organization a Max plan is
677
+ * linked to, spending the monthly API credits that plan includes. Used
678
+ * only when every seat is drained or cooling and no subscription rung can
679
+ * serve; the pool stays the first choice. Sourced from
680
+ * DARIO_CREDITS_API_KEY (env-only, like `upstreamApiKey`, so it never lands
681
+ * in argv). A chain entry without the key is inert and said so at startup.
682
+ */
683
+ creditsApiKey?: string;
674
684
  /**
675
685
  * Overage-guard — halt the proxy on the first response that bills to
676
686
  * anything other than the subscription pool (`overage`, `api`, or a new