@askalf/dario 6.4.0 → 6.6.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 +17 -3
- package/dist/analytics.d.ts +38 -3
- package/dist/analytics.js +57 -8
- package/dist/cli.js +61 -4
- package/dist/codex-accounts.d.ts +80 -6
- package/dist/codex-accounts.js +177 -2
- package/dist/codex-backend.d.ts +13 -1
- package/dist/codex-backend.js +41 -6
- package/dist/ledger.d.ts +175 -0
- package/dist/ledger.js +411 -0
- package/dist/proxy.d.ts +8 -0
- package/dist/proxy.js +282 -61
- package/dist/tui/tabs/analytics.d.ts +8 -0
- package/dist/tui/tabs/analytics.js +11 -2
- package/docs/api-equivalent-spend.md +116 -0
- package/package.json +1 -1
package/dist/codex-backend.d.ts
CHANGED
|
@@ -48,6 +48,9 @@ export interface CodexForwardOutcome {
|
|
|
48
48
|
export interface CodexDecline {
|
|
49
49
|
status: number;
|
|
50
50
|
retryAfterMs: number | null;
|
|
51
|
+
/** The seat that declined. Without it a caller can cool the provider but
|
|
52
|
+
* not the account, which is the whole point of a pool. */
|
|
53
|
+
alias: string;
|
|
51
54
|
}
|
|
52
55
|
/** The cached slug list for an alias WITHOUT fetching. For the admin surface:
|
|
53
56
|
* a status read must never cost an upstream call or a token refresh. */
|
|
@@ -275,7 +278,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
|
|
|
275
278
|
* into a buffered response object is not built yet. A non-streaming client
|
|
276
279
|
* gets a 400 saying so.
|
|
277
280
|
*/
|
|
278
|
-
export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void
|
|
281
|
+
export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void,
|
|
282
|
+
/** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
|
|
283
|
+
* caller needs to know which seat and for how long — without it the pool
|
|
284
|
+
* cannot cool a limited seat on this path, so selection hands the same
|
|
285
|
+
* rate-limited account back on every following request. */
|
|
286
|
+
onDecline?: (info: CodexDecline) => void,
|
|
287
|
+
/** When true a decline returns false WITHOUT writing, so the caller can
|
|
288
|
+
* retry the request on a healthy peer. False keeps the old behaviour: the
|
|
289
|
+
* upstream error is written through as the backend sent it. */
|
|
290
|
+
deferOnUnavailable?: boolean): Promise<boolean>;
|
|
279
291
|
/**
|
|
280
292
|
* Serve a request from a stored Codex account, in either client wire shape.
|
|
281
293
|
*
|
package/dist/codex-backend.js
CHANGED
|
@@ -759,7 +759,16 @@ export function buildCodexHeaders(creds) {
|
|
|
759
759
|
* into a buffered response object is not built yet. A non-streaming client
|
|
760
760
|
* gets a 400 saying so.
|
|
761
761
|
*/
|
|
762
|
-
export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone
|
|
762
|
+
export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone,
|
|
763
|
+
/** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
|
|
764
|
+
* caller needs to know which seat and for how long — without it the pool
|
|
765
|
+
* cannot cool a limited seat on this path, so selection hands the same
|
|
766
|
+
* rate-limited account back on every following request. */
|
|
767
|
+
onDecline,
|
|
768
|
+
/** When true a decline returns false WITHOUT writing, so the caller can
|
|
769
|
+
* retry the request on a healthy peer. False keeps the old behaviour: the
|
|
770
|
+
* upstream error is written through as the backend sent it. */
|
|
771
|
+
deferOnUnavailable = false) {
|
|
763
772
|
const startedAt = Date.now();
|
|
764
773
|
const model = String(body.model ?? '');
|
|
765
774
|
let reported = false;
|
|
@@ -804,6 +813,23 @@ export async function forwardResponsesToCodex(res, body, creds, corsOrigin, secu
|
|
|
804
813
|
const detail = await upstream.text().catch(() => '');
|
|
805
814
|
if (verbose)
|
|
806
815
|
console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
|
|
816
|
+
// Same rule as the Messages path: a 429 or a 5xx is the seat declining,
|
|
817
|
+
// and that is true whether or not anything is waiting to take over.
|
|
818
|
+
const unavailable = upstream.status === 429 || upstream.status >= 500;
|
|
819
|
+
if (unavailable) {
|
|
820
|
+
try {
|
|
821
|
+
onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
|
|
822
|
+
}
|
|
823
|
+
catch { /* a reporting failure must never break a request */ }
|
|
824
|
+
}
|
|
825
|
+
if (deferOnUnavailable && unavailable) {
|
|
826
|
+
if (verbose)
|
|
827
|
+
console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring`);
|
|
828
|
+
// Nothing written, so the caller is free to retry this same request
|
|
829
|
+
// on a peer. Reporting nothing here matches forwardToCodex: a
|
|
830
|
+
// declined attempt is not a served request.
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
807
833
|
if (!clientGone) {
|
|
808
834
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
809
835
|
// The backend's own error body, already in the client's shape.
|
|
@@ -1048,16 +1074,25 @@ midstream) {
|
|
|
1048
1074
|
// own fault (a bad body, an unsupported parameter) is NOT: failing over
|
|
1049
1075
|
// would just reproduce it somewhere else and hide the real error.
|
|
1050
1076
|
const unavailable = upstream.status === 429 || upstream.status >= 500;
|
|
1077
|
+
// The seat said no, and that is true whether or not a fallback exists
|
|
1078
|
+
// to defer to. Recording it outside the defer branch is what lets the
|
|
1079
|
+
// POOL rotate on a deployment with no --pool-fallback configured: with
|
|
1080
|
+
// the notice inside the branch, a 429 went straight to the client and
|
|
1081
|
+
// the seat was never cooled, so selection returned the same limited
|
|
1082
|
+
// account forever (found writing the proxy-level test for #1288).
|
|
1083
|
+
if (unavailable) {
|
|
1084
|
+
try {
|
|
1085
|
+
onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
|
|
1086
|
+
}
|
|
1087
|
+
catch { /* a reporting failure must never break a request */ }
|
|
1088
|
+
}
|
|
1051
1089
|
if (deferOnUnavailable && unavailable) {
|
|
1052
1090
|
console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring to the next provider`);
|
|
1053
1091
|
// A decline is the only exit that tells the caller nothing was served,
|
|
1054
1092
|
// and until now it carried no WHY: a 429 and a 503 were the same false.
|
|
1055
1093
|
// The chain needs the status (to cool a rate limit but not an outage)
|
|
1056
1094
|
// and the upstream's own `retry-after` (to cool it for the right long).
|
|
1057
|
-
|
|
1058
|
-
onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')) });
|
|
1059
|
-
}
|
|
1060
|
-
catch { /* a reporting failure must never break a declined request */ }
|
|
1095
|
+
// (the decline was already recorded above, for both exits)
|
|
1061
1096
|
return false;
|
|
1062
1097
|
}
|
|
1063
1098
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
@@ -1251,7 +1286,7 @@ midstream) {
|
|
|
1251
1286
|
// status 0: no HTTP status ever arrived. Reported so the caller can tell
|
|
1252
1287
|
// an outage from a rate limit — an unreachable backend is not quota.
|
|
1253
1288
|
try {
|
|
1254
|
-
onDecline?.({ status: 0, retryAfterMs: null });
|
|
1289
|
+
onDecline?.({ status: 0, retryAfterMs: null, alias: creds.alias });
|
|
1255
1290
|
}
|
|
1256
1291
|
catch { /* as above */ }
|
|
1257
1292
|
return false;
|
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger — what the traffic dario has served would have cost on the
|
|
3
|
+
* metered API, kept across restarts.
|
|
4
|
+
*
|
|
5
|
+
* /analytics is a rolling in-memory window: it forgets on every restart and
|
|
6
|
+
* caps at 10k records, so the one number a subscription user actually wants
|
|
7
|
+
* — "what has this saved me" — was never answerable past the last few hours.
|
|
8
|
+
* The ledger keeps one small row per (UTC day, model, bucket): a request
|
|
9
|
+
* count and the four token buckets. It never stores a price. Rows are priced
|
|
10
|
+
* at read time through `costOfTokens` at the day's own timestamp, so a
|
|
11
|
+
* pricing correction (#1047, #1048 — both happened) reprices history instead
|
|
12
|
+
* of freezing the wrong number in.
|
|
13
|
+
*
|
|
14
|
+
* Two buckets per row. `covered` is traffic a subscription paid for — the
|
|
15
|
+
* API-equivalent cost of that is the headline, the invoice that never
|
|
16
|
+
* arrived. `metered` is traffic billed per token anyway (an API key, or
|
|
17
|
+
* Anthropic's paid `extra_usage` overage) — that money was spent, and it is
|
|
18
|
+
* reported separately rather than counted as saved. Only 2xx responses
|
|
19
|
+
* count: a 429 carries no tokens and a 5xx bills nothing.
|
|
20
|
+
*
|
|
21
|
+
* On disk: `~/.dario/ledger.json` for the default port, `ledger-<port>.json`
|
|
22
|
+
* for any other, so two instances sharing a home (the box's live-test rig
|
|
23
|
+
* runs one on :3999 next to production) do not overwrite each other's file.
|
|
24
|
+
* Writes are debounced and durable (`durableWriteFile`); the shutdown hook
|
|
25
|
+
* flushes what the debounce still holds, so at most the last few seconds
|
|
26
|
+
* before a SIGKILL are lost.
|
|
27
|
+
*/
|
|
28
|
+
import { type PricingProvider, type RequestRecord } from './analytics.js';
|
|
29
|
+
export declare const LEDGER_VERSION = 1;
|
|
30
|
+
/** Days kept before the oldest roll off — two years at one row per model per day. */
|
|
31
|
+
export declare const LEDGER_MAX_DAYS = 730;
|
|
32
|
+
/** How long after the last record the file is rewritten. */
|
|
33
|
+
export declare const LEDGER_FLUSH_DELAY_MS = 3000;
|
|
34
|
+
export type LedgerBucket = 'covered' | 'metered';
|
|
35
|
+
export interface LedgerCell {
|
|
36
|
+
requests: number;
|
|
37
|
+
inputTokens: number;
|
|
38
|
+
outputTokens: number;
|
|
39
|
+
cacheReadTokens: number;
|
|
40
|
+
cacheCreateTokens: number;
|
|
41
|
+
}
|
|
42
|
+
export type LedgerRow = Partial<Record<LedgerBucket, LedgerCell>>;
|
|
43
|
+
export interface LedgerFile {
|
|
44
|
+
version: number;
|
|
45
|
+
/** ISO timestamp of the first record the ledger ever saw. */
|
|
46
|
+
since: string;
|
|
47
|
+
/** ISO timestamp of the last write. */
|
|
48
|
+
updated: string;
|
|
49
|
+
/** `YYYY-MM-DD` (UTC) → model id → per-bucket totals. */
|
|
50
|
+
days: Record<string, Record<string, LedgerRow>>;
|
|
51
|
+
}
|
|
52
|
+
export interface LedgerModelSummary {
|
|
53
|
+
provider: PricingProvider;
|
|
54
|
+
requests: number;
|
|
55
|
+
inputTokens: number;
|
|
56
|
+
outputTokens: number;
|
|
57
|
+
cacheReadTokens: number;
|
|
58
|
+
cacheCreateTokens: number;
|
|
59
|
+
/** API-equivalent cost of this model's covered traffic, USD. */
|
|
60
|
+
apiEquivalentCost: number;
|
|
61
|
+
/** What this model's metered traffic cost at list price, USD. */
|
|
62
|
+
meteredCost: number;
|
|
63
|
+
}
|
|
64
|
+
export interface LedgerSummary {
|
|
65
|
+
/** Where the file lives — so `dario usage` can say what it read. */
|
|
66
|
+
path: string;
|
|
67
|
+
since: string;
|
|
68
|
+
/** Distinct UTC days with traffic. */
|
|
69
|
+
days: number;
|
|
70
|
+
/** Covered + metered, 2xx only. */
|
|
71
|
+
requests: number;
|
|
72
|
+
/**
|
|
73
|
+
* The headline: what subscription-covered traffic would have been billed
|
|
74
|
+
* on the metered API at today's list prices, USD.
|
|
75
|
+
*/
|
|
76
|
+
apiEquivalentCost: number;
|
|
77
|
+
/** What metered traffic (API key, paid overage) actually cost at list price, USD. */
|
|
78
|
+
meteredCost: number;
|
|
79
|
+
/** Covered token totals. */
|
|
80
|
+
tokens: {
|
|
81
|
+
input: number;
|
|
82
|
+
output: number;
|
|
83
|
+
cacheRead: number;
|
|
84
|
+
cacheCreate: number;
|
|
85
|
+
};
|
|
86
|
+
perProvider: Record<PricingProvider, {
|
|
87
|
+
requests: number;
|
|
88
|
+
apiEquivalentCost: number;
|
|
89
|
+
}>;
|
|
90
|
+
perModel: Record<string, LedgerModelSummary>;
|
|
91
|
+
/** apiEquivalentCost over the trailing windows, UTC days. */
|
|
92
|
+
recent: {
|
|
93
|
+
today: number;
|
|
94
|
+
last7d: number;
|
|
95
|
+
last30d: number;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export declare function ledgerPathFor(port: number, home?: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* `DARIO_LEDGER_PATH` names the file; `DARIO_LEDGER=0` (or `--no-ledger`)
|
|
101
|
+
* turns the ledger off. Off, /analytics reports `lifetime: null` and the
|
|
102
|
+
* usage command says so.
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveLedgerPath(port: number, env?: NodeJS.ProcessEnv): string;
|
|
105
|
+
export declare function ledgerDisabledByEnv(env?: NodeJS.ProcessEnv): boolean;
|
|
106
|
+
export declare function emptyLedger(now?: number): LedgerFile;
|
|
107
|
+
/** UTC calendar day of an epoch-ms timestamp. */
|
|
108
|
+
export declare function dayKey(atMs: number): string;
|
|
109
|
+
/**
|
|
110
|
+
* Which bucket a record lands in, or null when it should not be counted.
|
|
111
|
+
* `api` and `extra_usage` are metered; every subscription claim, the codex
|
|
112
|
+
* claim, and an absent claim on a 2xx (stream aborts, api-key mode without
|
|
113
|
+
* the header) are covered — the request was served, and nothing says it was
|
|
114
|
+
* billed per token.
|
|
115
|
+
*/
|
|
116
|
+
export declare function ledgerBucketFor(record: Pick<RequestRecord, 'status' | 'claim'>): LedgerBucket | null;
|
|
117
|
+
/**
|
|
118
|
+
* Parse a ledger file's text, keeping only well-formed rows. A file that is
|
|
119
|
+
* not a ledger at all throws; the caller moves it aside and starts fresh.
|
|
120
|
+
*/
|
|
121
|
+
export declare function parseLedger(text: string): LedgerFile;
|
|
122
|
+
/** Add one record's tokens to the file in place. Returns false when it was not counted. */
|
|
123
|
+
export declare function addToLedger(file: LedgerFile, record: RequestRecord): boolean;
|
|
124
|
+
/** Drop the oldest days past LEDGER_MAX_DAYS. */
|
|
125
|
+
export declare function pruneLedger(file: LedgerFile, maxDays?: number): void;
|
|
126
|
+
export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
|
|
127
|
+
/**
|
|
128
|
+
* Read a ledger file for display without a running proxy (`dario usage`
|
|
129
|
+
* when the proxy is down). Missing file → null; unreadable → null with the
|
|
130
|
+
* reason, never a throw.
|
|
131
|
+
*/
|
|
132
|
+
export declare function readLedgerFile(path: string): Promise<{
|
|
133
|
+
file: LedgerFile | null;
|
|
134
|
+
error?: string;
|
|
135
|
+
}>;
|
|
136
|
+
export declare class Ledger {
|
|
137
|
+
readonly path: string;
|
|
138
|
+
private readonly log;
|
|
139
|
+
private file;
|
|
140
|
+
private dirty;
|
|
141
|
+
private timer;
|
|
142
|
+
private writing;
|
|
143
|
+
private closed;
|
|
144
|
+
private constructor();
|
|
145
|
+
/**
|
|
146
|
+
* Load the ledger at `path`, or start one. A file that cannot be parsed is
|
|
147
|
+
* moved aside (`<path>.corrupt-<ts>`) rather than overwritten, so a bad
|
|
148
|
+
* write never silently zeroes two years of history.
|
|
149
|
+
*/
|
|
150
|
+
static open(path: string, log?: (line: string) => void): Promise<Ledger>;
|
|
151
|
+
/** Count a request. Returns false when it was not ledger material. */
|
|
152
|
+
add(record: RequestRecord): boolean;
|
|
153
|
+
summary(now?: number): LedgerSummary;
|
|
154
|
+
/** The raw per-day table, for /analytics/ledger. */
|
|
155
|
+
snapshot(): LedgerFile;
|
|
156
|
+
private scheduleFlush;
|
|
157
|
+
/** Write now if anything changed. Serialized; a failure is logged, not thrown. */
|
|
158
|
+
flush(): Promise<void>;
|
|
159
|
+
/** Final flush for the shutdown hook. */
|
|
160
|
+
close(): Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
export declare function formatUsd(usd: number): string;
|
|
163
|
+
/** `claude-opus-5` → `Opus 5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5.6-terra` → `gpt-5.6-terra`. */
|
|
164
|
+
export declare function shortModelName(model: string): string;
|
|
165
|
+
/**
|
|
166
|
+
* The block `dario usage` prints above the rolling window. Two-space indent
|
|
167
|
+
* to match the rest of that command's output.
|
|
168
|
+
*/
|
|
169
|
+
export declare function formatLedgerSummary(s: LedgerSummary): string[];
|
|
170
|
+
/**
|
|
171
|
+
* A share card: one SVG, 640×320, dark, the number in the middle. Plain
|
|
172
|
+
* system monospace so it renders the same in a README, a tweet screenshot
|
|
173
|
+
* and an <img> tag with nothing to fetch.
|
|
174
|
+
*/
|
|
175
|
+
export declare function renderLedgerCard(s: LedgerSummary): string;
|