@askalf/dario 6.15.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/body-identity.d.ts +25 -0
- package/dist/body-identity.js +68 -0
- package/dist/cc-template.js +3 -14
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +44 -7
- package/dist/codex-backend.js +12 -4
- package/dist/config-file.d.ts +1 -1
- package/dist/config-file.js +2 -2
- 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/midstream.js +6 -2
- package/dist/openai-backend.js +8 -3
- package/dist/pool.d.ts +18 -1
- package/dist/pool.js +31 -10
- package/dist/proxy.d.ts +14 -1
- package/dist/proxy.js +351 -105
- package/dist/relaunch-signals.d.ts +35 -0
- package/dist/relaunch-signals.js +41 -0
- package/dist/stream-drain.d.ts +4 -3
- package/dist/stream-drain.js +4 -3
- package/dist/tui/tabs/config.js +2 -2
- package/dist/upstream-deadline.d.ts +25 -0
- package/dist/upstream-deadline.js +76 -0
- package/docs/analytics.md +10 -8
- package/docs/api-equivalent-spend.md +9 -2
- package/docs/commands.md +1 -1
- package/docs/configuration.md +1 -0
- package/docs/faq.md +5 -0
- package/docs/multi-account-pool.md +3 -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
|
@@ -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;
|
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
|
|
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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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', '
|
|
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/midstream.js
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* the existing pre-byte failover covers them), and the api-key OpenAI backend.
|
|
48
48
|
* A stream that cannot be continued ends exactly as it did before this module.
|
|
49
49
|
*/
|
|
50
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
50
51
|
/**
|
|
51
52
|
* Marks a loopback request as a continuation and carries its DEPTH: `1` for
|
|
52
53
|
* the resume of a client request, `2` for the resume of that resume. A
|
|
@@ -836,7 +837,8 @@ export class MidstreamGuard {
|
|
|
836
837
|
const body = buildResumeBody(this.o.shape, clientBody, target.model, partial);
|
|
837
838
|
const splicer = new Splicer(this.o.shape, s, partial);
|
|
838
839
|
const abort = new AbortController();
|
|
839
|
-
|
|
840
|
+
// Caps the wait until headers, then the gap between chunks of the resume.
|
|
841
|
+
const timer = upstreamDeadline(r.timeoutMs, () => abort.abort());
|
|
840
842
|
const startedAt = Date.now();
|
|
841
843
|
try {
|
|
842
844
|
r.onBeforeResume?.();
|
|
@@ -854,6 +856,7 @@ export class MidstreamGuard {
|
|
|
854
856
|
// An SSE comment, ignored by every parser, so a raw capture shows where
|
|
855
857
|
// the second provider took over.
|
|
856
858
|
this.o.write(`${SEAM_COMMENT_PREFIX} ${target.label} after ${partial.length} chars\n\n`);
|
|
859
|
+
timer.streaming();
|
|
857
860
|
const reader = res.body.getReader();
|
|
858
861
|
const split = new SseFrameSplitter();
|
|
859
862
|
let sawContent = false;
|
|
@@ -861,6 +864,7 @@ export class MidstreamGuard {
|
|
|
861
864
|
try {
|
|
862
865
|
outer: while (true) {
|
|
863
866
|
const { done, value } = await reader.read();
|
|
867
|
+
timer.touch();
|
|
864
868
|
if (done)
|
|
865
869
|
break;
|
|
866
870
|
if (this.o.isClientGone()) {
|
|
@@ -919,7 +923,7 @@ export class MidstreamGuard {
|
|
|
919
923
|
return 'failed';
|
|
920
924
|
}
|
|
921
925
|
finally {
|
|
922
|
-
|
|
926
|
+
timer.clear();
|
|
923
927
|
}
|
|
924
928
|
}
|
|
925
929
|
log(line) {
|
package/dist/openai-backend.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { readFile, writeFile, mkdir, unlink, readdir } from 'node:fs/promises';
|
|
16
16
|
import { join, basename } from 'node:path';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
|
+
import { upstreamDeadline } from './upstream-deadline.js';
|
|
18
19
|
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
20
|
const BACKENDS_DIR = join(DARIO_DIR, 'backends');
|
|
20
21
|
export const MINIMAX_MODELS = ['MiniMax-M3.1-Flash', 'MiniMax-M3'];
|
|
@@ -138,7 +139,8 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
138
139
|
// Drop Anthropic-specific headers entirely
|
|
139
140
|
void clientBeta;
|
|
140
141
|
const abort = new AbortController();
|
|
141
|
-
|
|
142
|
+
// Caps the wait until headers, then the gap between chunks of a 2xx body.
|
|
143
|
+
const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
|
|
142
144
|
try {
|
|
143
145
|
if (verbose) {
|
|
144
146
|
console.log(`[dario] → openai backend: ${target}`);
|
|
@@ -165,10 +167,13 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
165
167
|
}
|
|
166
168
|
res.writeHead(upstream.status, respHeaders);
|
|
167
169
|
if (upstream.body) {
|
|
170
|
+
if (upstream.ok)
|
|
171
|
+
timeout.streaming();
|
|
168
172
|
const reader = upstream.body.getReader();
|
|
169
173
|
try {
|
|
170
174
|
while (true) {
|
|
171
175
|
const { done, value } = await reader.read();
|
|
176
|
+
timeout.touch();
|
|
172
177
|
if (done)
|
|
173
178
|
break;
|
|
174
179
|
if (value)
|
|
@@ -182,7 +187,7 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
182
187
|
res.end();
|
|
183
188
|
}
|
|
184
189
|
catch (err) {
|
|
185
|
-
|
|
190
|
+
timeout.clear();
|
|
186
191
|
// Log error details server-side only. Responding with err.message
|
|
187
192
|
// exposes internal stack / path / module information (CodeQL
|
|
188
193
|
// js/stack-trace-exposure). The client gets a generic 502.
|
|
@@ -205,6 +210,6 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
|
|
|
205
210
|
return;
|
|
206
211
|
}
|
|
207
212
|
finally {
|
|
208
|
-
|
|
213
|
+
timeout.clear();
|
|
209
214
|
}
|
|
210
215
|
}
|