@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/README.md +19 -21
- package/dist/analytics-ui.html +835 -0
- package/dist/analytics.d.ts +26 -1
- package/dist/analytics.js +51 -3
- package/dist/cli.js +9 -0
- package/dist/credits-rung.d.ts +72 -0
- package/dist/credits-rung.js +133 -0
- package/dist/doctor-core.d.ts +2 -0
- package/dist/doctor-core.js +47 -10
- package/dist/donuts.d.ts +18 -17
- package/dist/donuts.js +46 -54
- package/dist/ledger.d.ts +20 -0
- package/dist/ledger.js +40 -1
- package/dist/metrics.js +2 -1
- package/dist/proxy.d.ts +10 -0
- package/dist/proxy.js +292 -74
- package/docs/analytics.md +10 -8
- package/docs/api-equivalent-spend.md +9 -2
- package/docs/faq.md +5 -0
- package/docs/multi-account-pool.md +1 -1
- package/docs/pool-keys-and-analytics.md +2 -0
- package/docs/two-plans.md +2 -0
- package/docs/who-its-for.md +2 -2
- package/package.json +8 -4
package/dist/analytics.d.ts
CHANGED
|
@@ -92,11 +92,17 @@ export declare function continuationStats(records: readonly RequestRecord[]): Co
|
|
|
92
92
|
* - `api` — pure API key billing, no subscription involved
|
|
93
93
|
* - `unknown` — header absent or unparseable (non-200 responses, stream aborts)
|
|
94
94
|
*
|
|
95
|
+
* A sixth, `credits`, is stamped by the proxy rather than read from a header:
|
|
96
|
+
* a request the credits rung of the failover chain served on the Console key
|
|
97
|
+
* in DARIO_CREDITS_API_KEY (credits-rung.ts). Anthropic sends no claim header
|
|
98
|
+
* on an API key request, and "the operator's own credits rung" is a different
|
|
99
|
+
* fact from "an unexpected API bill", so it gets its own bucket.
|
|
100
|
+
*
|
|
95
101
|
* Exposed in `/analytics` summaries and in verbose per-request logs so
|
|
96
102
|
* users can see at a glance which bucket their traffic is actually hitting.
|
|
97
103
|
* See #34 for background.
|
|
98
104
|
*/
|
|
99
|
-
export type BillingBucket = 'subscription' | 'subscription_fallback' | 'extra_usage' | 'api' | 'unknown';
|
|
105
|
+
export type BillingBucket = 'subscription' | 'subscription_fallback' | 'extra_usage' | 'api' | 'credits' | 'unknown';
|
|
100
106
|
/**
|
|
101
107
|
* Map the raw `representative-claim` header value to a human-friendly
|
|
102
108
|
* billing bucket. Pure function; no state; safe to call from any context.
|
|
@@ -112,6 +118,14 @@ export declare function billingBucketFromClaim(claim: string | null | undefined)
|
|
|
112
118
|
export declare const SUBSCRIPTION_CLAIMS: ReadonlySet<string>;
|
|
113
119
|
/** The claim the proxy stamps on codex-engine requests (see above). */
|
|
114
120
|
export declare const CODEX_CLAIM = "chatgpt_subscription";
|
|
121
|
+
/**
|
|
122
|
+
* The claim the proxy stamps on a request the credits rung served
|
|
123
|
+
* (credits-rung.ts). Not a subscription claim: the request was billed at API
|
|
124
|
+
* rates against the monthly credit balance, so it lands in its own bucket and
|
|
125
|
+
* the ledger files it as metered. The overage guard leaves it alone, because
|
|
126
|
+
* the rung is a chain entry the operator wrote, not a billing surprise.
|
|
127
|
+
*/
|
|
128
|
+
export declare const CREDITS_CLAIM = "anthropic_credits";
|
|
115
129
|
/**
|
|
116
130
|
* One-line per-request usage summary for verbose (-v / -vv) logs.
|
|
117
131
|
*
|
|
@@ -282,6 +296,12 @@ export declare function costOfTokens(model: string, atMs: number, t: {
|
|
|
282
296
|
cacheReadTokens: number;
|
|
283
297
|
cacheCreateTokens: number;
|
|
284
298
|
}): number;
|
|
299
|
+
/**
|
|
300
|
+
* Request counts per five-minute bin over the window, oldest bin first; the
|
|
301
|
+
* newest bin is the one `now` falls in. Records outside the window are left
|
|
302
|
+
* out, so the bins sum to the window's request count.
|
|
303
|
+
*/
|
|
304
|
+
export declare function binByFiveMinutes(records: RequestRecord[], now: number, windowMinutes: number): number[];
|
|
285
305
|
export declare class Analytics extends EventEmitter {
|
|
286
306
|
private records;
|
|
287
307
|
private maxRecords;
|
|
@@ -408,6 +428,8 @@ export interface AnalyticsSummary {
|
|
|
408
428
|
window: WindowStats & {
|
|
409
429
|
minutes: number;
|
|
410
430
|
requests: number;
|
|
431
|
+
/** Requests per five-minute bin across the window, oldest first (twelve for the default hour). */
|
|
432
|
+
perFiveMinutes: number[];
|
|
411
433
|
};
|
|
412
434
|
allTime: WindowStats & {
|
|
413
435
|
requests: number;
|
|
@@ -423,7 +445,10 @@ export interface AnalyticsSummary {
|
|
|
423
445
|
};
|
|
424
446
|
predictions: {
|
|
425
447
|
estimatedExhaustionMinutes: number | null;
|
|
448
|
+
/** Input and output tokens per minute across the window; cache reads and writes are not in it. */
|
|
426
449
|
tokenBurnRate: number;
|
|
450
|
+
/** Every kind per minute: input, output, cache reads and cache writes. */
|
|
451
|
+
totalTokenBurnRate: number;
|
|
427
452
|
costBurnRate: number;
|
|
428
453
|
};
|
|
429
454
|
}
|
package/dist/analytics.js
CHANGED
|
@@ -69,6 +69,8 @@ export function billingBucketFromClaim(claim) {
|
|
|
69
69
|
return 'extra_usage';
|
|
70
70
|
case 'api':
|
|
71
71
|
return 'api';
|
|
72
|
+
case CREDITS_CLAIM:
|
|
73
|
+
return 'credits';
|
|
72
74
|
default:
|
|
73
75
|
return 'unknown';
|
|
74
76
|
}
|
|
@@ -96,6 +98,14 @@ export const SUBSCRIPTION_CLAIMS = new Set([
|
|
|
96
98
|
]);
|
|
97
99
|
/** The claim the proxy stamps on codex-engine requests (see above). */
|
|
98
100
|
export const CODEX_CLAIM = 'chatgpt_subscription';
|
|
101
|
+
/**
|
|
102
|
+
* The claim the proxy stamps on a request the credits rung served
|
|
103
|
+
* (credits-rung.ts). Not a subscription claim: the request was billed at API
|
|
104
|
+
* rates against the monthly credit balance, so it lands in its own bucket and
|
|
105
|
+
* the ledger files it as metered. The overage guard leaves it alone, because
|
|
106
|
+
* the rung is a chain entry the operator wrote, not a billing surprise.
|
|
107
|
+
*/
|
|
108
|
+
export const CREDITS_CLAIM = 'anthropic_credits';
|
|
99
109
|
/**
|
|
100
110
|
* One-line per-request usage summary for verbose (-v / -vv) logs.
|
|
101
111
|
*
|
|
@@ -189,6 +199,12 @@ export function consumerFromBody(body) {
|
|
|
189
199
|
export function isNonSubscriptionBilling(claim) {
|
|
190
200
|
if (!claim || claim === NO_BILLING_CLAIM)
|
|
191
201
|
return false;
|
|
202
|
+
// The credits rung is metered billing the operator configured by name in
|
|
203
|
+
// the failover chain and armed with a key; the guard exists for billing
|
|
204
|
+
// nobody asked for. The claim is still not a subscription claim, so the
|
|
205
|
+
// bucket math and the ledger keep treating it as paid.
|
|
206
|
+
if (claim === CREDITS_CLAIM)
|
|
207
|
+
return false;
|
|
192
208
|
return !SUBSCRIPTION_CLAIMS.has(claim);
|
|
193
209
|
}
|
|
194
210
|
/**
|
|
@@ -363,6 +379,22 @@ function estimateCost(record) {
|
|
|
363
379
|
// estimates each side correctly rather than repricing history at today's rate.
|
|
364
380
|
return costOfTokens(record.model, record.timestamp, record);
|
|
365
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Request counts per five-minute bin over the window, oldest bin first; the
|
|
384
|
+
* newest bin is the one `now` falls in. Records outside the window are left
|
|
385
|
+
* out, so the bins sum to the window's request count.
|
|
386
|
+
*/
|
|
387
|
+
export function binByFiveMinutes(records, now, windowMinutes) {
|
|
388
|
+
const n = Math.max(1, Math.ceil(windowMinutes / 5));
|
|
389
|
+
const bins = new Array(n).fill(0);
|
|
390
|
+
for (const r of records) {
|
|
391
|
+
const age = now - r.timestamp;
|
|
392
|
+
if (age < 0 || age >= n * 300_000)
|
|
393
|
+
continue;
|
|
394
|
+
bins[n - 1 - Math.floor(age / 300_000)]++;
|
|
395
|
+
}
|
|
396
|
+
return bins;
|
|
397
|
+
}
|
|
366
398
|
export class Analytics extends EventEmitter {
|
|
367
399
|
records = [];
|
|
368
400
|
maxRecords;
|
|
@@ -426,13 +458,18 @@ export class Analytics extends EventEmitter {
|
|
|
426
458
|
};
|
|
427
459
|
}
|
|
428
460
|
summary(windowMinutes = 60) {
|
|
429
|
-
const
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
const cutoff = now - windowMinutes * 60_000;
|
|
430
463
|
const recent = this.records.filter(r => r.timestamp >= cutoff);
|
|
431
464
|
const allTime = this.records;
|
|
432
465
|
return {
|
|
433
466
|
window: {
|
|
434
467
|
minutes: windowMinutes,
|
|
435
468
|
requests: recent.length,
|
|
469
|
+
// The dashboard's sparkline and its "vs prior 30 min" figure need the
|
|
470
|
+
// whole window; /analytics/stream replays only the last 50 records,
|
|
471
|
+
// which would show an hour of steady traffic as a late surge.
|
|
472
|
+
perFiveMinutes: binByFiveMinutes(recent, now, windowMinutes),
|
|
436
473
|
...this.computeStats(recent),
|
|
437
474
|
},
|
|
438
475
|
allTime: {
|
|
@@ -459,6 +496,7 @@ export class Analytics extends EventEmitter {
|
|
|
459
496
|
subscription_fallback: 0,
|
|
460
497
|
extra_usage: 0,
|
|
461
498
|
api: 0,
|
|
499
|
+
credits: 0,
|
|
462
500
|
unknown: 0,
|
|
463
501
|
},
|
|
464
502
|
subscriptionPercent: 0,
|
|
@@ -478,6 +516,7 @@ export class Analytics extends EventEmitter {
|
|
|
478
516
|
subscription_fallback: 0,
|
|
479
517
|
extra_usage: 0,
|
|
480
518
|
api: 0,
|
|
519
|
+
credits: 0,
|
|
481
520
|
unknown: 0,
|
|
482
521
|
};
|
|
483
522
|
for (const r of records) {
|
|
@@ -601,24 +640,31 @@ export class Analytics extends EventEmitter {
|
|
|
601
640
|
}
|
|
602
641
|
predict(records) {
|
|
603
642
|
if (records.length < 3) {
|
|
604
|
-
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
|
|
643
|
+
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, totalTokenBurnRate: 0, costBurnRate: 0 };
|
|
605
644
|
}
|
|
606
645
|
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
|
|
607
646
|
const first = sorted[0];
|
|
608
647
|
const last = sorted[sorted.length - 1];
|
|
609
648
|
const durationMin = (last.timestamp - first.timestamp) / 60_000;
|
|
610
649
|
if (durationMin < 1) {
|
|
611
|
-
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
|
|
650
|
+
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, totalTokenBurnRate: 0, costBurnRate: 0 };
|
|
612
651
|
}
|
|
613
652
|
const totalTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
|
653
|
+
// Every kind the provider counts, for a reader asking "how many tokens a
|
|
654
|
+
// minute": on a cached prompt the cache reads dwarf input and output.
|
|
655
|
+
// thinkingTokens is an estimate carved out of outputTokens, not a kind of
|
|
656
|
+
// its own, so adding it would count that output twice.
|
|
657
|
+
const allTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheCreateTokens, 0);
|
|
614
658
|
const totalCost = sorted.reduce((s, r) => s + estimateCost(r), 0);
|
|
615
659
|
const tokenBurnRate = totalTokens / durationMin;
|
|
660
|
+
const totalTokenBurnRate = allTokens / durationMin;
|
|
616
661
|
const costBurnRate = (totalCost / durationMin) * 60;
|
|
617
662
|
const currentUtil = last.util5h;
|
|
618
663
|
if (currentUtil >= 0.95) {
|
|
619
664
|
return {
|
|
620
665
|
estimatedExhaustionMinutes: 0,
|
|
621
666
|
tokenBurnRate: Math.round(tokenBurnRate),
|
|
667
|
+
totalTokenBurnRate: Math.round(totalTokenBurnRate),
|
|
622
668
|
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
623
669
|
};
|
|
624
670
|
}
|
|
@@ -627,6 +673,7 @@ export class Analytics extends EventEmitter {
|
|
|
627
673
|
return {
|
|
628
674
|
estimatedExhaustionMinutes: null,
|
|
629
675
|
tokenBurnRate: Math.round(tokenBurnRate),
|
|
676
|
+
totalTokenBurnRate: Math.round(totalTokenBurnRate),
|
|
630
677
|
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
631
678
|
};
|
|
632
679
|
}
|
|
@@ -634,6 +681,7 @@ export class Analytics extends EventEmitter {
|
|
|
634
681
|
return {
|
|
635
682
|
estimatedExhaustionMinutes: Math.round(minutesToExhaustion),
|
|
636
683
|
tokenBurnRate: Math.round(tokenBurnRate),
|
|
684
|
+
totalTokenBurnRate: Math.round(totalTokenBurnRate),
|
|
637
685
|
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
638
686
|
};
|
|
639
687
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2294,6 +2294,15 @@ async function help() {
|
|
|
2294
2294
|
Needs one of those configured. Empty value
|
|
2295
2295
|
disables. Env: DARIO_POOL_FALLBACK. Config:
|
|
2296
2296
|
poolFallback.model.
|
|
2297
|
+
An entry written credits:<model> is a rung on
|
|
2298
|
+
the API credits a Max plan includes: with
|
|
2299
|
+
DARIO_CREDITS_API_KEY set (env only, never a
|
|
2300
|
+
flag) the request goes to the Claude API on
|
|
2301
|
+
that key as <model>, both wire shapes, after
|
|
2302
|
+
every seat and subscription rung; /analytics
|
|
2303
|
+
and the dashboard file it under credits, the
|
|
2304
|
+
ledger counts it as metered. Without the key
|
|
2305
|
+
the entry is inert and dario doctor says so.
|
|
2297
2306
|
--family-fallback=<family=model,...>
|
|
2298
2307
|
When every pool seat is parked for a model
|
|
2299
2308
|
family, serve its requests as <model> on the
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credits rung of the failover chain.
|
|
3
|
+
*
|
|
4
|
+
* Since October 2026 a Max plan includes monthly Claude API credits ($100 on
|
|
5
|
+
* Max 5x, $200 on Max 20x). They are spent through a Console API key of the
|
|
6
|
+
* organization the plan is linked to, not through the seat's own five-hour
|
|
7
|
+
* and weekly windows, so until now the only ways to spend them through dario
|
|
8
|
+
* were ANTHROPIC_UPSTREAM_API_KEY (the whole Claude backend on the key, pool
|
|
9
|
+
* bypassed) or an OpenAI-compat backend pointed at Anthropic's compatibility
|
|
10
|
+
* layer, which Anthropic describes as a layer for testing models.
|
|
11
|
+
*
|
|
12
|
+
* A chain entry written `credits:<model>` names the key as a rung. When every
|
|
13
|
+
* seat is drained or cooling and no subscription rung can serve, the request
|
|
14
|
+
* goes to api.anthropic.com on the Messages path as a plain API request: the
|
|
15
|
+
* client's own body with the model swapped, `x-api-key`, no Claude Code
|
|
16
|
+
* template and no OAuth beta. Both wire shapes work, because the Claude path
|
|
17
|
+
* already translates OpenAI-shape requests and responses. The response says
|
|
18
|
+
* `x-dario-pool-fallback: credits:<model>`; the rolling window, `/metrics`
|
|
19
|
+
* and the dashboard file the request under its own billing bucket, `credits`,
|
|
20
|
+
* next to extra usage and API key; the persistent ledger counts it in its
|
|
21
|
+
* metered total, as spent; and the overage guard does not halt on it: a rung
|
|
22
|
+
* the operator configured is not a surprise bill.
|
|
23
|
+
*
|
|
24
|
+
* The key comes from DARIO_CREDITS_API_KEY only, never a flag, so it never
|
|
25
|
+
* lands in argv. The rung is opt-in twice: the entry must be in the chain AND
|
|
26
|
+
* the key must be set. Without the key the entry is inert, the proxy says so
|
|
27
|
+
* at startup and `dario doctor` says so in its Failover line.
|
|
28
|
+
*
|
|
29
|
+
* Where it sits: a subscription rung (a Codex account that lists an entry)
|
|
30
|
+
* always goes first, because the plan is the thing dario exists to spend.
|
|
31
|
+
* Among the metered rungs, this one and the openai-compat backend, the
|
|
32
|
+
* chain's own left-to-right order decides.
|
|
33
|
+
*/
|
|
34
|
+
import { type ClaudeTarget, type ModelResolver } from './claude-model.js';
|
|
35
|
+
export declare const CREDITS_PREFIX = "credits:";
|
|
36
|
+
/** The analytics `account` of a request the credits rung served. */
|
|
37
|
+
export declare const ACCOUNT_KEY_CREDITS = "__credits__";
|
|
38
|
+
/** The value of `x-dario-pool-fallback` on a response the rung served. */
|
|
39
|
+
export declare function creditsFallbackMarker(model: string): string;
|
|
40
|
+
export declare function isCreditsEntry(entry: string): boolean;
|
|
41
|
+
/** The model half of a `credits:<model>` entry as written; null for any other entry. */
|
|
42
|
+
export declare function creditsEntryModel(entry: string): string | null;
|
|
43
|
+
/** Every credits entry's model as written, in chain order. */
|
|
44
|
+
export declare function creditsEntries(chain: readonly string[]): string[];
|
|
45
|
+
/** The chain without its credits entries: what the subscription and openai-compat rungs read. */
|
|
46
|
+
export declare function withoutCreditsEntries(chain: readonly string[]): string[];
|
|
47
|
+
/**
|
|
48
|
+
* True when the credits rung is asked before the openai-compat backend: the
|
|
49
|
+
* first credits entry precedes every plain entry, or there is no plain entry.
|
|
50
|
+
* False when the chain has no credits entry at all.
|
|
51
|
+
*/
|
|
52
|
+
export declare function creditsBeforePlain(chain: readonly string[]): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The first credits entry the Claude catalog can serve, resolved exactly like
|
|
55
|
+
* a Claude chain entry: aliases (`credits:sonnet`), a provider prefix
|
|
56
|
+
* (`credits:anthropic:claude-opus-5`) and an effort suffix spelled the way a
|
|
57
|
+
* Claude entry spells it (`credits:claude:opus:high`, `credits:claude-opus-5-high`)
|
|
58
|
+
* all work, and a name the catalog does not list is skipped rather than sent, so
|
|
59
|
+
* a typo cannot trade a recoverable 429 for a 404 billed to the key.
|
|
60
|
+
*/
|
|
61
|
+
export declare function pickCreditsTarget(chain: readonly string[], bases?: readonly string[], resolve?: ModelResolver): ClaudeTarget | null;
|
|
62
|
+
/**
|
|
63
|
+
* Outbound headers for a credits request: the headers a plain API client
|
|
64
|
+
* sends and nothing of Claude Code's identity. The client's own betas go
|
|
65
|
+
* through minus the OAuth flag, which an API key request must not carry.
|
|
66
|
+
*/
|
|
67
|
+
export declare function creditsHeaders(key: string, clientBeta: string | undefined, anthropicVersion?: string): Record<string, string>;
|
|
68
|
+
/**
|
|
69
|
+
* One phrase for the startup line and `dario doctor`, or null when the chain
|
|
70
|
+
* names no credits entry.
|
|
71
|
+
*/
|
|
72
|
+
export declare function describeCreditsRung(chain: readonly string[], hasKey: boolean): string | null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credits rung of the failover chain.
|
|
3
|
+
*
|
|
4
|
+
* Since October 2026 a Max plan includes monthly Claude API credits ($100 on
|
|
5
|
+
* Max 5x, $200 on Max 20x). They are spent through a Console API key of the
|
|
6
|
+
* organization the plan is linked to, not through the seat's own five-hour
|
|
7
|
+
* and weekly windows, so until now the only ways to spend them through dario
|
|
8
|
+
* were ANTHROPIC_UPSTREAM_API_KEY (the whole Claude backend on the key, pool
|
|
9
|
+
* bypassed) or an OpenAI-compat backend pointed at Anthropic's compatibility
|
|
10
|
+
* layer, which Anthropic describes as a layer for testing models.
|
|
11
|
+
*
|
|
12
|
+
* A chain entry written `credits:<model>` names the key as a rung. When every
|
|
13
|
+
* seat is drained or cooling and no subscription rung can serve, the request
|
|
14
|
+
* goes to api.anthropic.com on the Messages path as a plain API request: the
|
|
15
|
+
* client's own body with the model swapped, `x-api-key`, no Claude Code
|
|
16
|
+
* template and no OAuth beta. Both wire shapes work, because the Claude path
|
|
17
|
+
* already translates OpenAI-shape requests and responses. The response says
|
|
18
|
+
* `x-dario-pool-fallback: credits:<model>`; the rolling window, `/metrics`
|
|
19
|
+
* and the dashboard file the request under its own billing bucket, `credits`,
|
|
20
|
+
* next to extra usage and API key; the persistent ledger counts it in its
|
|
21
|
+
* metered total, as spent; and the overage guard does not halt on it: a rung
|
|
22
|
+
* the operator configured is not a surprise bill.
|
|
23
|
+
*
|
|
24
|
+
* The key comes from DARIO_CREDITS_API_KEY only, never a flag, so it never
|
|
25
|
+
* lands in argv. The rung is opt-in twice: the entry must be in the chain AND
|
|
26
|
+
* the key must be set. Without the key the entry is inert, the proxy says so
|
|
27
|
+
* at startup and `dario doctor` says so in its Failover line.
|
|
28
|
+
*
|
|
29
|
+
* Where it sits: a subscription rung (a Codex account that lists an entry)
|
|
30
|
+
* always goes first, because the plan is the thing dario exists to spend.
|
|
31
|
+
* Among the metered rungs, this one and the openai-compat backend, the
|
|
32
|
+
* chain's own left-to-right order decides.
|
|
33
|
+
*/
|
|
34
|
+
import { resolveClaudeTarget } from './claude-model.js';
|
|
35
|
+
export const CREDITS_PREFIX = 'credits:';
|
|
36
|
+
/** The analytics `account` of a request the credits rung served. */
|
|
37
|
+
export const ACCOUNT_KEY_CREDITS = '__credits__';
|
|
38
|
+
/** The OAuth-enablement beta a plain API request must not carry. */
|
|
39
|
+
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
40
|
+
/** The value of `x-dario-pool-fallback` on a response the rung served. */
|
|
41
|
+
export function creditsFallbackMarker(model) {
|
|
42
|
+
return `${CREDITS_PREFIX}${model}`;
|
|
43
|
+
}
|
|
44
|
+
export function isCreditsEntry(entry) {
|
|
45
|
+
return entry.trim().toLowerCase().startsWith(CREDITS_PREFIX);
|
|
46
|
+
}
|
|
47
|
+
/** The model half of a `credits:<model>` entry as written; null for any other entry. */
|
|
48
|
+
export function creditsEntryModel(entry) {
|
|
49
|
+
const e = entry.trim();
|
|
50
|
+
if (!e.toLowerCase().startsWith(CREDITS_PREFIX))
|
|
51
|
+
return null;
|
|
52
|
+
const model = e.slice(CREDITS_PREFIX.length).trim();
|
|
53
|
+
return model || null;
|
|
54
|
+
}
|
|
55
|
+
/** Every credits entry's model as written, in chain order. */
|
|
56
|
+
export function creditsEntries(chain) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const entry of chain) {
|
|
59
|
+
const model = creditsEntryModel(entry);
|
|
60
|
+
if (model)
|
|
61
|
+
out.push(model);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** The chain without its credits entries: what the subscription and openai-compat rungs read. */
|
|
66
|
+
export function withoutCreditsEntries(chain) {
|
|
67
|
+
return chain.filter((entry) => !isCreditsEntry(entry));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* True when the credits rung is asked before the openai-compat backend: the
|
|
71
|
+
* first credits entry precedes every plain entry, or there is no plain entry.
|
|
72
|
+
* False when the chain has no credits entry at all.
|
|
73
|
+
*/
|
|
74
|
+
export function creditsBeforePlain(chain) {
|
|
75
|
+
const credits = chain.findIndex((entry) => isCreditsEntry(entry));
|
|
76
|
+
if (credits < 0)
|
|
77
|
+
return false;
|
|
78
|
+
const plain = chain.findIndex((entry) => !isCreditsEntry(entry));
|
|
79
|
+
return plain < 0 || credits < plain;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The first credits entry the Claude catalog can serve, resolved exactly like
|
|
83
|
+
* a Claude chain entry: aliases (`credits:sonnet`), a provider prefix
|
|
84
|
+
* (`credits:anthropic:claude-opus-5`) and an effort suffix spelled the way a
|
|
85
|
+
* Claude entry spells it (`credits:claude:opus:high`, `credits:claude-opus-5-high`)
|
|
86
|
+
* all work, and a name the catalog does not list is skipped rather than sent, so
|
|
87
|
+
* a typo cannot trade a recoverable 429 for a 404 billed to the key.
|
|
88
|
+
*/
|
|
89
|
+
export function pickCreditsTarget(chain, bases, resolve) {
|
|
90
|
+
for (const entry of chain) {
|
|
91
|
+
const model = creditsEntryModel(entry);
|
|
92
|
+
if (!model)
|
|
93
|
+
continue;
|
|
94
|
+
const target = resolveClaudeTarget(model, bases, resolve);
|
|
95
|
+
if (target)
|
|
96
|
+
return target;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Outbound headers for a credits request: the headers a plain API client
|
|
102
|
+
* sends and nothing of Claude Code's identity. The client's own betas go
|
|
103
|
+
* through minus the OAuth flag, which an API key request must not carry.
|
|
104
|
+
*/
|
|
105
|
+
export function creditsHeaders(key, clientBeta, anthropicVersion = '2023-06-01') {
|
|
106
|
+
const headers = {
|
|
107
|
+
'accept': 'application/json',
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
'x-api-key': key,
|
|
110
|
+
'anthropic-version': anthropicVersion,
|
|
111
|
+
};
|
|
112
|
+
const beta = (clientBeta ?? '')
|
|
113
|
+
.split(',')
|
|
114
|
+
.map((b) => b.trim())
|
|
115
|
+
.filter((b) => b.length > 0 && b !== OAUTH_BETA)
|
|
116
|
+
.join(',');
|
|
117
|
+
if (beta)
|
|
118
|
+
headers['anthropic-beta'] = beta;
|
|
119
|
+
return headers;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* One phrase for the startup line and `dario doctor`, or null when the chain
|
|
123
|
+
* names no credits entry.
|
|
124
|
+
*/
|
|
125
|
+
export function describeCreditsRung(chain, hasKey) {
|
|
126
|
+
const credits = creditsEntries(chain);
|
|
127
|
+
if (credits.length === 0)
|
|
128
|
+
return null;
|
|
129
|
+
const model = credits[0];
|
|
130
|
+
return hasKey
|
|
131
|
+
? `API credits as ${model} (x-api-key on the Messages path, both wire shapes, billing bucket credits, metered in the ledger)`
|
|
132
|
+
: `credits:${model} named but DARIO_CREDITS_API_KEY is not set, so that rung is inert`;
|
|
133
|
+
}
|
package/dist/doctor-core.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export declare function failoverReadiness(input: {
|
|
|
83
83
|
chain: readonly string[];
|
|
84
84
|
codexAccounts: number;
|
|
85
85
|
backends: readonly string[];
|
|
86
|
+
/** DARIO_CREDITS_API_KEY is set (the credits rung, credits-rung.ts). */
|
|
87
|
+
creditsKey?: boolean;
|
|
86
88
|
}): {
|
|
87
89
|
status: CheckStatus;
|
|
88
90
|
detail: string;
|
package/dist/doctor-core.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
|
|
14
14
|
import { maskEmail } from './pool.js';
|
|
15
|
+
import { creditsEntries, withoutCreditsEntries } from './credits-rung.js';
|
|
15
16
|
import { readFileSync } from 'node:fs';
|
|
16
17
|
import { join, dirname } from 'node:path';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
@@ -111,36 +112,71 @@ function describeAge(ms) {
|
|
|
111
112
|
}
|
|
112
113
|
export function failoverReadiness(input) {
|
|
113
114
|
const { chain, codexAccounts, backends } = input;
|
|
115
|
+
const creditsKey = input.creditsKey === true;
|
|
114
116
|
const hasCodex = codexAccounts > 0;
|
|
115
117
|
const hasBackend = backends.length > 0;
|
|
118
|
+
// The subscription and openai-compat rungs read the chain without its
|
|
119
|
+
// credits entries; the credits rung is reported on the end of whichever
|
|
120
|
+
// verdict those earn.
|
|
121
|
+
const plain = withoutCreditsEntries(chain);
|
|
122
|
+
const credits = creditsEntries(chain);
|
|
123
|
+
const creditsArmed = credits.length > 0 && creditsKey;
|
|
124
|
+
const creditsNote = credits.length > 0
|
|
125
|
+
? (creditsKey
|
|
126
|
+
? ` Then API credits as ${credits[0]} on DARIO_CREDITS_API_KEY (billing bucket credits, metered in the ledger).`
|
|
127
|
+
: ` credits:${credits[0]} is in the chain but DARIO_CREDITS_API_KEY is not set, so that rung is inert.`)
|
|
128
|
+
: (creditsKey ? ' DARIO_CREDITS_API_KEY is set but no chain entry names credits:<model>, so nothing spends it.' : '');
|
|
116
129
|
if (chain.length === 0) {
|
|
117
|
-
return { status: 'info', detail: 'off
|
|
130
|
+
return { status: 'info', detail: 'off: a drained Claude pool returns 429/503 (--pool-fallback to arm)' + creditsNote };
|
|
131
|
+
}
|
|
132
|
+
if (plain.length === 0) {
|
|
133
|
+
// Nothing but credits entries: the rung is the whole chain.
|
|
134
|
+
if (creditsArmed) {
|
|
135
|
+
return {
|
|
136
|
+
status: 'ok',
|
|
137
|
+
detail: `claude → API credits as ${credits[0]} (x-api-key on the Messages path, both wire shapes, billing bucket credits, metered in the ledger). `
|
|
138
|
+
+ 'Metered: add a Codex account (`dario add altman`) to the chain to spend a subscription first.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
status: 'warn',
|
|
143
|
+
detail: `armed (${chain.join(' → ')}) but INERT: DARIO_CREDITS_API_KEY is not set. Set it (env only, never a flag) `
|
|
144
|
+
+ 'to arm the credits rung, or add a subscription rung: `dario add altman`.',
|
|
145
|
+
};
|
|
118
146
|
}
|
|
119
147
|
if (!hasCodex && !hasBackend) {
|
|
148
|
+
if (creditsArmed) {
|
|
149
|
+
return {
|
|
150
|
+
status: 'ok',
|
|
151
|
+
detail: `claude → API credits as ${credits[0]} (both wire shapes, billing bucket credits, metered in the ledger). `
|
|
152
|
+
+ `${plain.join(' → ')} has no Codex account and no backend to serve it; add one `
|
|
153
|
+
+ '(`dario add altman` for a subscription, `dario backend add …` for an api key) to spend it before the key.',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
120
156
|
return {
|
|
121
157
|
status: 'warn',
|
|
122
|
-
detail: `armed (${chain.join(' → ')}) but INERT
|
|
123
|
-
+ 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).',
|
|
158
|
+
detail: `armed (${chain.join(' → ')}) but INERT: no Codex account and no backend to fall back to. `
|
|
159
|
+
+ 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).' + creditsNote,
|
|
124
160
|
};
|
|
125
161
|
}
|
|
126
|
-
if (hasCodex &&
|
|
162
|
+
if (hasCodex && plain.length > 1) {
|
|
127
163
|
return {
|
|
128
164
|
status: 'ok',
|
|
129
|
-
detail: `symmetric: ${
|
|
130
|
-
+ `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}
|
|
165
|
+
detail: `symmetric: ${plain.join(' → ')}, across ${codexAccounts} Codex account`
|
|
166
|
+
+ `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}.` + creditsNote,
|
|
131
167
|
};
|
|
132
168
|
}
|
|
133
169
|
if (hasCodex) {
|
|
134
170
|
return {
|
|
135
171
|
status: 'ok',
|
|
136
|
-
detail: `claude → codex as ${
|
|
137
|
-
+ 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.',
|
|
172
|
+
detail: `claude → codex as ${plain[0]} (both wire shapes). One-way: add a Claude model to `
|
|
173
|
+
+ 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.' + creditsNote,
|
|
138
174
|
};
|
|
139
175
|
}
|
|
140
176
|
return {
|
|
141
177
|
status: 'ok',
|
|
142
|
-
detail: `claude → ${backends[0]} as ${
|
|
143
|
-
+ 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).',
|
|
178
|
+
detail: `claude → ${backends[0]} as ${plain[0]}, OpenAI path only. A Codex account would `
|
|
179
|
+
+ 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).' + creditsNote,
|
|
144
180
|
};
|
|
145
181
|
}
|
|
146
182
|
/**
|
|
@@ -1252,6 +1288,7 @@ export async function runChecks(opts = {}) {
|
|
|
1252
1288
|
chain,
|
|
1253
1289
|
codexAccounts: codexAliases.length,
|
|
1254
1290
|
backends: backends.map((b) => b.name),
|
|
1291
|
+
creditsKey: (process.env.DARIO_CREDITS_API_KEY ?? '').trim().length > 0,
|
|
1255
1292
|
});
|
|
1256
1293
|
checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
|
|
1257
1294
|
const midstreamEnabled = !['0', 'false', 'no', 'off'].includes((process.env.DARIO_MIDSTREAM_CONTINUE ?? '').toLowerCase());
|
package/dist/donuts.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
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
1
|
import type { LedgerSummary } from './ledger.js';
|
|
14
2
|
import type { AnalyticsSummary } from './analytics.js';
|
|
15
3
|
import type { QueueSnapshot } from './request-queue.js';
|
|
@@ -35,12 +23,25 @@ export declare function renderSpendDonuts(s: LedgerSummary): string;
|
|
|
35
23
|
/**
|
|
36
24
|
* The server-rendered fragment behind `/analytics/ui`: headline, the three
|
|
37
25
|
* rings, the rolling window, and a per-model table. Same gate as
|
|
38
|
-
* `/analytics`; the
|
|
26
|
+
* `/analytics`; the page before 6.17 fetched it with the token the viewer typed; it is still served.
|
|
39
27
|
*/
|
|
40
28
|
export declare function renderAnalyticsView(summary: AnalyticsView, lifetime: LedgerSummary | null, version: string): string;
|
|
41
29
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
30
|
+
* A reader of the dashboard page that reads it once: the first read that succeeds is kept for
|
|
31
|
+
* every call after it. A failed read answers with the missing-file page and keeps nothing, so
|
|
32
|
+
* copying the file in takes effect on the next request without a restart. The reader is a
|
|
33
|
+
* parameter so the test can count the reads.
|
|
34
|
+
*/
|
|
35
|
+
export declare function analyticsUiLoader(read: () => string): () => string;
|
|
36
|
+
/**
|
|
37
|
+
* The dashboard behind `/analytics/ui`: `dist/analytics-ui.html`, copied from `src/` by the
|
|
38
|
+
* build and read once, on the first request. It carries no data and no credential. It asks for
|
|
39
|
+
* the analytics token or an API key once (kept in sessionStorage) and reads `/analytics`,
|
|
40
|
+
* `/accounts`, `/codex`, `/health`, `/analytics/ledger` and `/admin/keys` with it, and tails
|
|
41
|
+
* `/analytics/stream`; it loads nothing from outside the proxy. The
|
|
42
|
+
* `window.DARIO_COCKPIT = "served"` line in its head tells the page a proxy is serving it, so it
|
|
43
|
+
* goes live at once; the same file opened from disk runs on sample data, because the page checks
|
|
44
|
+
* the protocol before the marker. `/analytics/view` and the rings above are unchanged for the CLI
|
|
45
|
+
* and for anything that embedded them.
|
|
45
46
|
*/
|
|
46
|
-
export declare const
|
|
47
|
+
export declare const analyticsUiPage: () => string;
|