@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.
@@ -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): Promise<boolean>;
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
  *
@@ -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
- try {
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;
@@ -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;