@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/dist/proxy.js CHANGED
@@ -16,6 +16,7 @@ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCo
16
16
  import { backfillIdentity } from './accounts.js';
17
17
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
18
18
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
19
+ import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
19
20
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
20
21
  import { notify as osNotify } from './notify.js';
21
22
  import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
@@ -31,7 +32,7 @@ import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequest
31
32
  import { isClaudeServableModel } from './claude-model.js';
32
33
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
33
34
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
34
- import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
35
+ import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
35
36
  import { route as routeProvider } from './provider-adapter.js';
36
37
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
37
38
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
@@ -163,6 +164,30 @@ function extractFirstUserMessage(body) {
163
164
  }
164
165
  return '';
165
166
  }
167
+ /**
168
+ * The conversation key for Codex seat stickiness, from raw request bytes.
169
+ *
170
+ * The same hash the Claude pool binds on (computeStickyKey over the first user
171
+ * message), so a conversation stays on one ChatGPT seat across turns and keeps
172
+ * the prompt-cache prefix it built there — rotating per request would trade a
173
+ * rate-limit problem for a cache problem.
174
+ *
175
+ * Null for a body that is not a JSON object or carries no user message; those
176
+ * requests bypass stickiness rather than sharing one bucket. Used by the two
177
+ * codex entries that hold no parsed body of their own (the pool-exhausted
178
+ * fallback and the mid-stream continuation target).
179
+ */
180
+ function codexStickyKeyForBody(body) {
181
+ try {
182
+ const parsed = JSON.parse(body.toString('utf-8'));
183
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
184
+ return null;
185
+ return computeStickyKey(extractFirstUserMessage(parsed));
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
166
191
  // Session ID behavior:
167
192
  // v3.18 rotated per request — which was itself a fingerprint. Real CC
168
193
  // rotates roughly once per conversation, not per call. A user who has
@@ -1421,6 +1446,19 @@ export async function startProxy(opts = {}) {
1421
1446
  // : null` — that gated the /analytics endpoint, but burn-rate /
1422
1447
  // per-request visibility is useful for a pool of one too.
1423
1448
  const analytics = new Analytics();
1449
+ // The lifetime ledger rides the same record stream analytics emits, so
1450
+ // every site that records a request feeds it without a second call. Off,
1451
+ // /analytics reports `lifetime: null`.
1452
+ const ledgerOn = opts.ledger !== false && !ledgerDisabledByEnv();
1453
+ const ledger = ledgerOn ? await Ledger.open(resolveLedgerPath(port), (line) => console.log(line)) : null;
1454
+ if (ledger) {
1455
+ analytics.on('record', (r) => { ledger.add(r); });
1456
+ if (verbose)
1457
+ console.log(`[dario] ledger: ${ledger.path}`);
1458
+ }
1459
+ else {
1460
+ console.log('[dario] ledger: disabled (--no-ledger)');
1461
+ }
1424
1462
  // Per-alias request counts for GET /codex — the pool has requestCount per
1425
1463
  // account; the codex accounts had nothing until now.
1426
1464
  const codexRequestCounts = new Map();
@@ -1999,6 +2037,47 @@ export async function startProxy(opts = {}) {
1999
2037
  function checkAuth(req) {
2000
2038
  return authenticateRequest(req.headers, apiKeyBuf);
2001
2039
  }
2040
+ /**
2041
+ * A ChatGPT seat declined. Cool the SEAT, and the provider only once every
2042
+ * seat is cooling.
2043
+ *
2044
+ * One handler for every codex forward — both wire shapes and the
2045
+ * Claude-to-Codex fallback. It was two hand-copied copies, and that is
2046
+ * precisely how the native Responses path ended up cooling nothing while
2047
+ * the translated path cooled correctly: a third call site inherits this by
2048
+ * construction rather than by someone remembering to copy it.
2049
+ *
2050
+ * Closes over nothing per-request, which is what makes one copy possible.
2051
+ */
2052
+ const codexOnDecline = (d) => {
2053
+ // Cool the PROVIDER (is the codex lane usable at all) and the SEAT
2054
+ // that actually declined (which ChatGPT account said no, and for how
2055
+ // long). Before the seat half existed, selectCodexAccount returned the
2056
+ // alphabetically-first account every time, so one 429'd seat took the
2057
+ // whole lane down while its healthy peers sat unreachable.
2058
+ if (d.status !== 429)
2059
+ return;
2060
+ // A 429 is a SEAT-level condition, so cool the seat unconditionally.
2061
+ // The provider is only cooled once EVERY seat is cooling.
2062
+ //
2063
+ // Cooling the provider on any single 429 defeats the pool: the routing
2064
+ // gate short-circuits on canAttempt('codex'), so the next request never
2065
+ // reaches selectCodexAccount to find the healthy peer — the exact
2066
+ // single-seat outage this change exists to remove (caught in review of
2067
+ // #1288). Dropping provider cooling altogether is equally wrong the other
2068
+ // way: on a single-seat deployment nothing would fail fast, and every
2069
+ // request would re-hammer a seat already known to be limited instead of
2070
+ // falling through to Claude. All-seats-cooled is the condition that means
2071
+ // what the provider cool-down was always trying to say.
2072
+ noteCodexDecline(d.alias, d.retryAfterMs);
2073
+ void listCodexAccountAliases().then((aliases) => {
2074
+ // Decide and write in the SAME tick — see allAliasesCooled. An await
2075
+ // between the two lets a concurrent success clear a seat in the gap,
2076
+ // and the late write then cools a pool that has recovered.
2077
+ if (allAliasesCooled(aliases))
2078
+ providerCooldowns.note('codex', d.retryAfterMs);
2079
+ }).catch(() => { });
2080
+ };
2002
2081
  /**
2003
2082
  * Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
2004
2083
  *
@@ -2025,50 +2104,101 @@ export async function startProxy(opts = {}) {
2025
2104
  return false;
2026
2105
  if (!(await hasAnyCodexAccount().catch(() => false)))
2027
2106
  return false;
2028
- const stored = await selectCodexAccount().catch(() => null);
2107
+ // Sticky on the CONVERSATION, not on this fallback hop: a conversation
2108
+ // that reaches the subscription twice lands on the same seat both times,
2109
+ // so the second turn reads the prefix the first one paid to create.
2110
+ const stickyKey = codexStickyKeyForBody(body);
2111
+ const stored = await selectCodexAccount(undefined, { stickyKey }).catch(() => null);
2029
2112
  if (!stored)
2030
2113
  return false;
2031
- let creds;
2114
+ let seat;
2032
2115
  try {
2033
- creds = await getFreshCodexAccount(stored);
2116
+ seat = await getFreshCodexAccount(stored);
2034
2117
  }
2035
2118
  catch {
2036
2119
  return false;
2037
2120
  }
2038
- const slugs = await getCodexModelSlugs(creds).catch(() => []);
2039
- const fallbackPick = pickCodexFallback(fallbackModels, slugs);
2040
- if (!fallbackPick)
2041
- return false;
2042
- const fallbackModel = fallbackPick.model;
2043
- const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
2044
- if (!fallbackBody)
2045
- return false;
2046
- console.log(`[dario] #${requestCount} ${why} → codex account ${creds.alias} as ${fallbackModel}`);
2047
- requestCount++;
2048
- attempted.add('codex');
2049
2121
  // If an api-key backend could ALSO serve this request, let the subscription
2050
2122
  // decline a 429/5xx rather than answer with it, and report not-served so the
2051
- // caller falls through to that backend. This helper's contract has always
2052
- // said it declines so the caller can continue; it just never exercised the
2053
- // mechanism it was built on, so a rate-limited subscription ended the chain
2054
- // with a healthy backend sitting unused beside it.
2123
+ // caller falls through to that backend.
2055
2124
  //
2056
- // With NO next option, do not defer: the real upstream error is more useful
2057
- // to the client than replacing it with a generic 503.
2125
+ // With NO next option and no peer, do not defer: the real upstream error is
2126
+ // more useful to the client than replacing it with a generic 503.
2058
2127
  const hasNextOption = openaiBackend !== null && shape === 'openai';
2059
- const served = await forwardToCodex(req, res, fallbackBody, creds, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption, undefined,
2060
- // Only a rate limit cools the provider. A 5xx or a transport failure is
2061
- // an outage, not quota cooling it would park a provider that may be
2062
- // back on the next request, which is the opposite of the fix.
2063
- (d) => { if (d.status === 429)
2064
- providerCooldowns.note('codex', d.retryAfterMs); },
2065
- // The mirror of the Claude side (dario#1161): an operator who writes
2066
- // `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
2067
- // failover runs at, so the entry's own suffix reaches the request rather
2068
- // than the failover quietly running at the backend default.
2069
- effortForCodex(fallbackPick.effort));
2070
- if (served)
2128
+ // The next seat that could serve one of these fallback models, excluding
2129
+ // everything already tried. `peek` is the cached read, so the scan costs no
2130
+ // upstream call; a seat whose model list is unknown is still worth a try.
2131
+ //
2132
+ // Scans rather than testing one candidate: with mixed model availability
2133
+ // across seats, the alphabetically-next peer may be the one that lists none
2134
+ // of the fallback models while a later one lists one.
2135
+ const nextFallbackPeer = async (tried) => {
2136
+ const skipped = new Set(tried);
2137
+ for (;;) {
2138
+ const candidate = await selectCodexAccountExcluding(skipped).catch(() => null);
2139
+ if (!candidate)
2140
+ return null;
2141
+ const peerSlugs = peekCodexModelSlugs(candidate.alias);
2142
+ if (!peerSlugs || pickCodexFallback(fallbackModels, peerSlugs))
2143
+ return candidate;
2144
+ skipped.add(candidate.alias);
2145
+ }
2146
+ };
2147
+ // Mid-flight seat failover on the CLAUDE-TO-CODEX route, the same as the
2148
+ // primary Codex route has. Without it this route selected one seat and
2149
+ // stopped: a 429 from that seat was written to the client while a healthy
2150
+ // peer sat unused, so the pool helped every route except this one (caught
2151
+ // in review of #1288). The fallback model is re-picked per seat because
2152
+ // pickCodexFallback reads that SEAT's slugs — peers need not list the same
2153
+ // model, and the one that answers may answer as a different one.
2154
+ //
2155
+ // Terminates by construction: every pass adds a seat to `tried`, and
2156
+ // nextFallbackPeer never returns one already in it.
2157
+ const tried = new Set();
2158
+ let served = false;
2159
+ while (seat) {
2160
+ tried.add(seat.alias);
2161
+ const slugs = await getCodexModelSlugs(seat).catch(() => []);
2162
+ const fallbackPick = pickCodexFallback(fallbackModels, slugs);
2163
+ // Resolved BEFORE the attempt: it decides whether this attempt may defer,
2164
+ // and becomes the seat to retry on if it declines.
2165
+ const peer = await nextFallbackPeer(tried);
2166
+ if (!fallbackPick) {
2167
+ // This seat lists none of the fallback models. That used to end the
2168
+ // attempt outright; a peer may still list one.
2169
+ if (!peer)
2170
+ return false;
2171
+ seat = await getFreshCodexAccount(peer).catch(() => peer);
2172
+ continue;
2173
+ }
2174
+ const fallbackModel = fallbackPick.model;
2175
+ const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
2176
+ if (!fallbackBody)
2177
+ return false;
2178
+ console.log(`[dario] #${requestCount} ${why} → codex account ${seat.alias} as ${fallbackModel}`);
2179
+ requestCount++;
2180
+ // Marked only once an attempt is actually being made. Marking it before
2181
+ // the guards above would tell the rest of the request that codex had been
2182
+ // tried when it had not, suppressing a later legitimate attempt.
2183
+ attempted.add('codex');
2184
+ served = await forwardToCodex(req, res, fallbackBody, seat, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption || peer !== null, undefined, codexOnDecline,
2185
+ // The mirror of the Claude side (dario#1161): an operator who writes
2186
+ // `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
2187
+ // failover runs at, so the entry's own suffix reaches the request rather
2188
+ // than the failover quietly running at the backend default.
2189
+ effortForCodex(fallbackPick.effort));
2190
+ if (served || !peer)
2191
+ break;
2192
+ console.log(`[dario] codex seat ${seat.alias} declined — retrying this fallback on ${peer.alias}`);
2193
+ // The conversation follows the request. Its binding still names the seat
2194
+ // that just declined; leaving it there would send the next turn back to a
2195
+ // cooling seat and re-pick from scratch.
2196
+ rebindCodexSticky(stickyKey, peer.alias);
2197
+ seat = await getFreshCodexAccount(peer).catch(() => peer);
2198
+ }
2199
+ if (served) {
2071
2200
  providerCooldowns.clear('codex');
2201
+ }
2072
2202
  return served;
2073
2203
  };
2074
2204
  /**
@@ -2453,7 +2583,19 @@ export async function startProxy(opts = {}) {
2453
2583
  // `queue` rides along the summary (dario#905): request-queue.ts always
2454
2584
  // documented snapshot() as "exposed for /analytics", but it was never
2455
2585
  // actually wired in, so slot exhaustion was invisible from outside.
2456
- res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot() }));
2586
+ res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
2587
+ return;
2588
+ }
2589
+ // The ledger's per-day table, for anyone charting it. `lifetime` on
2590
+ // /analytics is the summary; this is the data behind it.
2591
+ if (urlPath === '/analytics/ledger' && req.method === 'GET') {
2592
+ if (!ledger) {
2593
+ res.writeHead(404, JSON_HEADERS);
2594
+ res.end(JSON.stringify({ error: 'ledger disabled', hint: 'start without --no-ledger / DARIO_LEDGER=0' }));
2595
+ return;
2596
+ }
2597
+ res.writeHead(200, JSON_HEADERS);
2598
+ res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot() }));
2457
2599
  return;
2458
2600
  }
2459
2601
  // Analytics live stream — SSE of new RequestRecord JSON, one event
@@ -3027,7 +3169,10 @@ export async function startProxy(opts = {}) {
3027
3169
  return null;
3028
3170
  if (!(await hasAnyCodexAccount().catch(() => false)))
3029
3171
  return null;
3030
- const stored = await selectCodexAccount().catch(() => null);
3172
+ // The CLIENT's bytes rather than the rewritten `body`: a resume is the
3173
+ // same conversation as the request that died mid-stream, so it must
3174
+ // hash to the same key and land on the seat that conversation holds.
3175
+ const stored = await selectCodexAccount(undefined, { stickyKey: codexStickyKeyForBody(clientBodyBytes) }).catch(() => null);
3031
3176
  if (!stored)
3032
3177
  return null;
3033
3178
  let creds;
@@ -3328,8 +3473,17 @@ export async function startProxy(opts = {}) {
3328
3473
  // account, rather than letting the throw escape into the JSON-peek
3329
3474
  // catch below and disappear (DEV-179a412f).
3330
3475
  let codexUnavailable = null;
3476
+ // Conversation -> seat binding for the codex lane, the mirror of the
3477
+ // Claude pool's stickyKey below. It belongs HERE because this is
3478
+ // where the seat is CHOSEN: without a key every turn independently
3479
+ // re-picks "the first seat not cooling", so a lower-alias seat that
3480
+ // frees up mid-conversation silently moves the conversation off the
3481
+ // seat holding its prompt-cache prefix (caught in review of #1288).
3482
+ // `parsedBody` is the object the invalid-body guard already parsed,
3483
+ // so this costs no second JSON.parse.
3484
+ const codexStickyKey = parsedBody ? computeStickyKey(extractFirstUserMessage(parsedBody)) : null;
3331
3485
  if (await hasAnyCodexAccount()) {
3332
- const stored = await selectCodexAccount();
3486
+ const stored = await selectCodexAccount(undefined, { stickyKey: codexStickyKey });
3333
3487
  if (stored) {
3334
3488
  try {
3335
3489
  codexCreds = await getFreshCodexAccount(stored);
@@ -3466,14 +3620,22 @@ export async function startProxy(opts = {}) {
3466
3620
  },
3467
3621
  })
3468
3622
  : null;
3469
- // Before this hook a codex request left no trace: nothing in
3470
- // /analytics, nothing in the request log, no per-account count.
3471
- // The dock (and anyone reading /analytics) saw a proxy that
3472
- // served GPT all day and reported zero of it. A decline (the
3473
- // request handed to the Claude pool) reports nothing here; the
3474
- // Claude path records what it then serves.
3623
+ // Reporting is one function for BOTH codex shapes below: the Responses
3624
+ // passthrough and the translated Messages path record the same row, so a
3625
+ // GPT request looks the same in /analytics whichever shape asked for it.
3626
+ //
3627
+ // Before this hook a codex request left no trace: nothing in /analytics,
3628
+ // nothing in the request log, no per-account count. The dock (and anyone
3629
+ // reading /analytics) saw a proxy that served GPT all day and reported
3630
+ // zero of it. A decline (the request handed to the Claude pool) reports
3631
+ // nothing here; the Claude path records what it then serves.
3475
3632
  const codexOnDone = (o) => {
3476
3633
  codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
3634
+ // A seat that actually SERVED is not rate-limited. Keyed on a 2xx,
3635
+ // never on forwardToCodex returning true — that means "I wrote a
3636
+ // response", which is equally true when it wrote the upstream 429.
3637
+ if (o.status >= 200 && o.status < 300)
3638
+ clearCodexDecline(o.alias);
3477
3639
  analytics.record({
3478
3640
  timestamp: Date.now(),
3479
3641
  consumer,
@@ -3502,24 +3664,83 @@ export async function startProxy(opts = {}) {
3502
3664
  cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
3503
3665
  }, consumer));
3504
3666
  };
3505
- // A Responses client on a ChatGPT-subscription model: the backend
3506
- // speaks that shape natively, so the body goes through as written
3507
- // (model resolved) and the SSE comes back untouched no round
3508
- // trip through the Messages shape, which cannot carry the newest
3509
- // Codex CLI request features. Answers on the raw response: these
3510
- // bytes are already in the client's shape.
3511
- const served = codexAvailable && (isResponses && responsesBodyRaw
3512
- ? await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone)
3513
- : await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer, codexOnDone,
3514
- // Cool codex on a rate limit only — a 5xx or an unreachable backend
3515
- // is an outage, and parking a provider for that would keep it out
3516
- // of the chain while it was already coming back.
3517
- (d) => { if (d.status === 429)
3518
- providerCooldowns.note('codex', d.retryAfterMs); },
3519
- // dario#1260 the effort named by the model-name suffix stripped
3520
- // above. Undefined for every request that did not name one, which
3521
- // leaves the outbound body exactly as it was.
3522
- effortForCodex(requestEffort), codexGuard));
3667
+ // Mid-flight seat failover. A 429 lands BEFORE any body is written
3668
+ // forwardToCodex only returns false on the decline path so the same
3669
+ // request can be handed to a healthy peer instead of failing. Without
3670
+ // this the pool only helps the request AFTER the one that discovered the
3671
+ // limit; the discovering request still failed, every window rollover.
3672
+ //
3673
+ // `deferOnUnavailable` is widened to `canDefer || a peer exists`: without
3674
+ // that, a decline with no Claude fallback configured writes the 429 to the
3675
+ // client and returns true, and there is nothing left to retry onto.
3676
+ //
3677
+ // Terminates by construction: every pass adds a seat to `codexTried`, and
3678
+ // selectCodexAccountExcluding never returns a seat already in it.
3679
+ let served = false;
3680
+ if (codexAvailable) {
3681
+ const codexTried = new Set();
3682
+ let codexSeat = codexCreds;
3683
+ while (codexSeat) {
3684
+ codexTried.add(codexSeat.alias);
3685
+ // Resolved BEFORE the attempt: it decides whether this attempt may
3686
+ // defer, and becomes the seat to retry on if it declines.
3687
+ // A peer that demonstrably does not list this model cannot serve it;
3688
+ // trying it would trade a 429 for a 400. peek is the cached read, so
3689
+ // this never costs an upstream call — an unknown list still gets a try.
3690
+ //
3691
+ // Scanning rather than testing one candidate: with mixed model
3692
+ // availability across seats, the alphabetically-next peer may be the
3693
+ // one that cannot serve this model while a later one can. Stopping at
3694
+ // the first incompatible candidate left `codexPeer` null and abandoned
3695
+ // a usable seat — with no Claude fallback the declining seat's 429 went
3696
+ // straight to the client (caught in review of #1288). `peerTried` is
3697
+ // seeded from `codexTried` and grows every pass, so this terminates.
3698
+ let codexPeer = null;
3699
+ const peerTried = new Set(codexTried);
3700
+ for (;;) {
3701
+ const candidate = await selectCodexAccountExcluding(peerTried).catch(() => null);
3702
+ if (!candidate)
3703
+ break;
3704
+ const peerSlugs = rawModel ? peekCodexModelSlugs(candidate.alias) : null;
3705
+ if (!peerSlugs || isCodexModel(rawModel, peerSlugs)) {
3706
+ codexPeer = candidate;
3707
+ break;
3708
+ }
3709
+ peerTried.add(candidate.alias);
3710
+ }
3711
+ // A Responses client on a ChatGPT-subscription model: the backend speaks
3712
+ // that shape natively, so the body goes through as written (model
3713
+ // resolved) and the SSE comes back untouched — no round trip through the
3714
+ // Messages shape, which cannot carry the newest Codex CLI request
3715
+ // features. Answers on the raw response: these bytes are already in the
3716
+ // client's shape.
3717
+ //
3718
+ // It sits INSIDE the retry loop, on the same seat sequence and the same
3719
+ // defer condition as the translated path. Outside it, a 429 on this shape
3720
+ // cooled nothing: selection handed the same limited seat back on every
3721
+ // following request and a healthy peer was never reached — the single-seat
3722
+ // outage this change exists to remove, surviving on the one shape Codex
3723
+ // CLI actually speaks (caught in review of #1288).
3724
+ if (isResponses && responsesBodyRaw) {
3725
+ served = await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone, codexOnDecline, canDefer || codexPeer !== null);
3726
+ }
3727
+ else {
3728
+ served = await forwardToCodex(req, res, body, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer || codexPeer !== null, codexOnDone, codexOnDecline,
3729
+ // dario#1260 — the effort named by the model-name suffix stripped
3730
+ // above. Undefined for every request that did not name one, which
3731
+ // leaves the outbound body exactly as it was.
3732
+ effortForCodex(requestEffort), codexGuard);
3733
+ }
3734
+ if (served || !codexPeer)
3735
+ break;
3736
+ console.log(`[dario] codex seat ${codexSeat.alias} declined — retrying this request on ${codexPeer.alias}`);
3737
+ // The conversation follows the request. Its binding still names
3738
+ // the seat that just declined; leaving it there would send the
3739
+ // next turn back to a cooling seat and re-pick from scratch.
3740
+ rebindCodexSticky(codexStickyKey, codexPeer.alias);
3741
+ codexSeat = await getFreshCodexAccount(codexPeer).catch(() => codexPeer);
3742
+ }
3743
+ }
3523
3744
  if (served) {
3524
3745
  // A provider that just served is not rate-limited.
3525
3746
  providerCooldowns.clear('codex');
@@ -5337,7 +5558,7 @@ export async function startProxy(opts = {}) {
5337
5558
  // Flush tokens first (best-effort, bounded), then close the server. The
5338
5559
  // flush is fire-and-forget under the same 5s force-exit guard below so a
5339
5560
  // hung fsync can't wedge shutdown.
5340
- void flushPoolTokens().finally(() => {
5561
+ void Promise.all([flushPoolTokens(), ledger?.close()]).finally(() => {
5341
5562
  server.close(() => process.exit(0));
5342
5563
  });
5343
5564
  // Force exit after 5s if connections (or the flush) don't complete.
@@ -43,6 +43,14 @@ interface SummaryShape {
43
43
  currentUtil7d: number;
44
44
  lastClaim: string;
45
45
  }>;
46
+ /** The ledger's lifetime view (v6.6); null when the proxy runs --no-ledger, absent on older proxies. */
47
+ lifetime?: {
48
+ apiEquivalentCost: number;
49
+ since: string;
50
+ recent: {
51
+ today: number;
52
+ };
53
+ } | null;
46
54
  }
47
55
  export interface AnalyticsState {
48
56
  summary: SummaryShape | null;
@@ -14,6 +14,7 @@
14
14
  import { fg, dim, brand, progressBar, pad, truncate } from '../render.js';
15
15
  import { renderKvRow } from '../layout.js';
16
16
  import { fitPanels } from '../panels.js';
17
+ import { formatUsd } from '../../ledger.js';
17
18
  const POLL_INTERVAL_MS = 2000;
18
19
  /**
19
20
  * Label column for the gauge rows (5h / 7d / Overage). Was 6, which is
@@ -97,13 +98,21 @@ export const AnalyticsTab = {
97
98
  counters.push(' ' + renderKvRow('Thinking tokens', formatNumber(s.window.totalThinkingTokens), w - 4));
98
99
  counters.push(' ' + renderKvRow('Avg latency', `${Math.round(s.window.avgLatencyMs)}ms`, w - 4));
99
100
  counters.push(' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4));
100
- // Headline numbers the two that answer "is this costing me money?"
101
+ // The ledger's number: what everything since the first request would
102
+ // have been billed on the metered API. Lifetime, not the window.
103
+ const lifetimeRow = s.lifetime
104
+ ? ' ' + renderKvRow('API-equivalent', `${formatUsd(s.lifetime.apiEquivalentCost)} ${dim(`lifetime, ${formatUsd(s.lifetime.recent.today)} today, since ${s.lifetime.since.slice(0, 10)}`)}`, w - 4)
105
+ : null;
106
+ if (lifetimeRow)
107
+ counters.push(lifetimeRow);
108
+ // Headline numbers — the ones that answer "is this costing me money?"
101
109
  // survive as the collapsed form.
102
110
  panels.push({
103
111
  lines: counters,
104
112
  collapsed: ['',
105
113
  ' ' + renderKvRow('Requests', `${s.window.requests} ${dim(`(${rpm.toFixed(1)}/min)`)}`, w - 4),
106
- ' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4)],
114
+ ' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4),
115
+ ...(lifetimeRow ? [lifetimeRow] : [])],
107
116
  priority: 1,
108
117
  });
109
118
  // ── Per-model bars ─────────────────────────────────────────
@@ -0,0 +1,116 @@
1
+ # API-equivalent spend — the ledger
2
+
3
+ What the traffic dario has served would have cost on the metered API, kept
4
+ across restarts. `dario usage` opens with it; `/analytics` carries it as
5
+ `lifetime`; the TUI's Analytics tab shows it as **API-equivalent**.
6
+
7
+ ## Why a file
8
+
9
+ `/analytics` is a rolling in-memory window: 10k records, gone on restart. It
10
+ answers "what is this costing me right now" and could never answer the
11
+ question a subscription user actually has — what has this saved me since I
12
+ set it up. The ledger is the persistent half. It is deliberately small: one
13
+ row per UTC day, per model, per billing bucket, holding a request count and
14
+ the four token buckets (input, output, cache read, cache write). Nothing else.
15
+
16
+ It never stores a price. Rows are priced when read, at the rate in effect on
17
+ the row's day, from the same tables the rolling window uses (`PRICING` for
18
+ Claude, `OPENAI_PRICING` for the ChatGPT leg, both in `src/analytics.ts`).
19
+ Pricing has been wrong twice in this repo (#1047, #1048); a stored dollar
20
+ figure would have frozen the wrong number in, a stored token count gets
21
+ repriced the moment the table is fixed.
22
+
23
+ ## What counts
24
+
25
+ Only responses with a 2xx status. A 429 carries no tokens and a 5xx bills
26
+ nothing.
27
+
28
+ Two columns per row:
29
+
30
+ - **covered** — served against a subscription: every Anthropic subscription
31
+ claim (`five_hour`, `seven_day`, their `_fallback` and `_overage_included`
32
+ forms), the ChatGPT leg, and a 2xx that carried no claim at all (a stream
33
+ cut before the headers were read, api-key mode without the header). The
34
+ API-equivalent cost of this column is the headline — the invoice that never
35
+ arrived.
36
+ - **metered** — billed per token anyway: an API key upstream (`api`) or
37
+ Anthropic's paid overage (`overage` → `extra_usage`). That money was spent.
38
+ It is reported on its own line ("Paid per token on top") and never counted
39
+ as saved.
40
+
41
+ A mid-stream continuation (6.1) is two upstream requests and records as two
42
+ rows, one per provider, each with the tokens that leg actually consumed.
43
+
44
+ ## Where it lives
45
+
46
+ `~/.dario/ledger.json` for a proxy on the default port; `ledger-<port>.json`
47
+ for any other, so two instances sharing a home (a live-test rig next to
48
+ production) do not overwrite each other. `DARIO_LEDGER_PATH=<file>` moves it;
49
+ `--no-ledger` / `DARIO_LEDGER=0` turns it off, after which `/analytics`
50
+ reports `lifetime: null`, `/analytics/ledger` is a 404 and `dario usage` says
51
+ so.
52
+
53
+ Writes are debounced (3 s after the last record) and durable — temp file,
54
+ fsync, rename, directory fsync, the same path the credential store uses
55
+ (#790). The shutdown hook flushes what the debounce still holds, so a
56
+ `docker rm -f` loses at most the last few seconds. A file that will not parse
57
+ is moved aside as `ledger.json.corrupt-<ts>` and the ledger starts fresh; it
58
+ is never overwritten in place. Days past 730 roll off the front.
59
+
60
+ The test suite pins `DARIO_LEDGER_PATH` to a temp directory, so a suite run
61
+ does not add stub traffic to the operator's file.
62
+
63
+ ## Reading it
64
+
65
+ ```
66
+ $ dario usage
67
+ API-equivalent spend (since 2026-09-11, 3 days, 1,515 requests):
68
+ $413 would have been billed on the metered API — covered by subscriptions
69
+ Claude $388 1,204 reqs (Opus 5 $301 · Sonnet 5 $86.68)
70
+ ChatGPT $24.75 311 reqs (gpt-5.6-terra $24.75)
71
+ Today $48.20 · Last 7d $413 · Last 30d $413
72
+ Paid per token on top (API key / extra usage): $1.10
73
+ ```
74
+
75
+ The proxy's view is preferred (it holds records the debounce has not flushed
76
+ yet); with no proxy on the port, the command reads the file the proxy on that
77
+ port would write. `--card[=file.svg]` renders the headline as a 640×320 SVG
78
+ (default `dario-api-equivalent.svg`) — plain system monospace, nothing to
79
+ fetch, so it looks the same in a README and a screenshot. `--json` is the raw
80
+ `/analytics` payload, `lifetime` included.
81
+
82
+ `GET /analytics` → `lifetime`:
83
+
84
+ ```json
85
+ {
86
+ "path": "/root/.dario/ledger.json",
87
+ "since": "2026-09-11T02:14:09.000Z",
88
+ "days": 3,
89
+ "requests": 1515,
90
+ "apiEquivalentCost": 412.87,
91
+ "meteredCost": 1.1,
92
+ "tokens": { "input": 1204000, "output": 388000, "cacheRead": 91200000, "cacheCreate": 4100000 },
93
+ "perProvider": { "anthropic": { "requests": 1204, "apiEquivalentCost": 388.12 }, "openai": { "requests": 311, "apiEquivalentCost": 24.75 } },
94
+ "perModel": { "claude-opus-5": { "provider": "anthropic", "requests": 900, "apiEquivalentCost": 301.44, "meteredCost": 1.1, "...": "token totals" } },
95
+ "recent": { "today": 48.2, "last7d": 412.87, "last30d": 412.87 }
96
+ }
97
+ ```
98
+
99
+ `GET /analytics/ledger` is the file itself: `{ path, version, since, updated,
100
+ days: { "YYYY-MM-DD": { "<model>": { covered: {…}, metered: {…} } } } }`.
101
+
102
+ ## Pricing on the ChatGPT leg
103
+
104
+ Before 6.6 a `gpt-*` row fell through to the Claude fallback rate and the
105
+ "would-be API cost" of a ChatGPT-plan request was Anthropic's Sonnet 4.6
106
+ price for a model Anthropic does not sell. `OPENAI_PRICING` carries OpenAI's
107
+ published standard-tier rates for the models the codex backend serves, read
108
+ off `developers.openai.com/api/docs/pricing` on 2026-09-11. OpenAI charges
109
+ nothing to write a cache entry, so cache-write tokens (which the codex path
110
+ never reports anyway) are priced at the input rate. Unknown `gpt-*` ids take
111
+ gpt-5.6-terra's rate, dario's default codex model.
112
+
113
+ `scripts/check-pricing-drift.mjs` watches the Claude table against
114
+ Anthropic's page. Nothing watches the OpenAI table yet — an entry that is
115
+ correct today goes wrong the moment OpenAI changes it, and the only signal
116
+ would be this number moving.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {