@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/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
|
-
|
|
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
|
|
2114
|
+
let seat;
|
|
2032
2115
|
try {
|
|
2033
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
//
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3470
|
-
//
|
|
3471
|
-
//
|
|
3472
|
-
//
|
|
3473
|
-
//
|
|
3474
|
-
//
|
|
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
|
|
3506
|
-
//
|
|
3507
|
-
//
|
|
3508
|
-
//
|
|
3509
|
-
//
|
|
3510
|
-
//
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
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
|
-
//
|
|
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.
|
|
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": {
|