@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/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
<p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
|
|
29
29
|
|
|
30
|
-
<sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~
|
|
30
|
+
<sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~36k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
|
|
31
31
|
|
|
32
32
|
<sub><a href="#start-in-60-seconds">Start</a> · <a href="#point-your-tools-at-it">Your tools</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#two-plans-one-endpoint">Two plans</a> · <a href="#many-seats-one-endpoint">Pool</a> · <a href="#it-tracks-a-moving-target">Drift</a> · <a href="#trust--transparency">Trust</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#commands">Commands</a> · <a href="#faq">FAQ</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
|
|
33
33
|
|
|
@@ -373,6 +373,20 @@ Type `dario` with no arguments for a full-screen control panel: live request str
|
|
|
373
373
|
|
|
374
374
|
<sub>Both screenshots are rendered from the real TUI against a fixture proxy by <a href="scripts/readme/tui.mjs"><code>scripts/readme/tui.mjs</code></a>, so a layout change shows up here instead of rotting a mock-up. The numbers are illustrative; the pixels are not.</sub>
|
|
375
375
|
|
|
376
|
+
### What it would have cost
|
|
377
|
+
|
|
378
|
+
The rolling window forgets on every restart; the **ledger** does not. Since 6.6 dario keeps one small row per day, per model, per billing bucket in `~/.dario/ledger.json` — request counts and the four token buckets, never a price — and prices them at read time from the published API rate cards (Anthropic's, and OpenAI's for the ChatGPT leg), so a pricing correction reprices history instead of freezing the old number in. `dario usage` opens with it, `/analytics` carries it as `lifetime`, the TUI shows it as **API-equivalent**, and it reads from the file when the proxy is down:
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
API-equivalent spend (since 2026-09-11, 3 days, 1,515 requests):
|
|
382
|
+
$413 would have been billed on the metered API — covered by subscriptions
|
|
383
|
+
Claude $388 1,204 reqs (Opus 5 $301 · Sonnet 5 $86.68)
|
|
384
|
+
ChatGPT $24.75 311 reqs (gpt-5.6-terra $24.75)
|
|
385
|
+
Today $48.20 · Last 7d $413 · Last 30d $413
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Only served requests count. Traffic that was metered anyway — an API key upstream, or Anthropic's paid `extra_usage` overage — is kept in its own column and reported as spent, not saved. `dario usage --card` writes the headline as a 640×320 SVG you can drop in a README or a post; `--no-ledger` / `DARIO_LEDGER=0` turns the file off, `DARIO_LEDGER_PATH` moves it, and `GET /analytics/ledger` is the per-day table behind the number. Details: [api-equivalent-spend.md](./docs/api-equivalent-spend.md).
|
|
389
|
+
|
|
376
390
|
## It tracks a moving target
|
|
377
391
|
|
|
378
392
|
Claude Code's request shape changes between releases — new betas, tool renames, per-model thinking configs — usually with no subscriber-facing note. dario doesn't *guess* that shape: it captures it live from your own installed `claude` binary on every startup, diffs it against each upstream release, and replays it faithfully. That's why your subscription routes the same through dario as it does through Claude Code itself: the request that leaves your machine *is* the shape your plan expects. Details: [wire-fidelity.md](./docs/wire-fidelity.md) · [#13](https://github.com/askalf/dario/discussions/13) · [#14](https://github.com/askalf/dario/discussions/14).
|
|
@@ -492,7 +506,7 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
|
|
|
492
506
|
| `dario accounts list` / `add` / `remove` / `check <alias>` | Pool management; `check` sends one pinned request per model through the running proxy (admin API on) |
|
|
493
507
|
| `dario backend list` / `add` / `remove` | OpenAI-compatible API-key backends |
|
|
494
508
|
| `dario codex list` / `add` / `remove` | ChatGPT accounts (the long form of `dario add altman`) |
|
|
495
|
-
| `dario usage` · `dario config` · `dario status` |
|
|
509
|
+
| `dario usage` · `dario config` · `dario status` | Lifetime API-equivalent spend + burn rate for the last hour (`--card` writes the share card) · effective config, redacted · token health |
|
|
496
510
|
| `dario resume` · `dario refresh` · `dario logout` · `dario upgrade` | Clear an overage halt · force a token refresh · delete credentials · safe self-update |
|
|
497
511
|
| `dario mcp` · `dario subagent install` / `remove` / `status` | Reach dario from inside any MCP client, or from inside a Claude Code session, read-only |
|
|
498
512
|
|
|
@@ -503,7 +517,7 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
|
|
|
503
517
|
| `GET /health` · `GET /livez` | Serviceability (503 when not) · liveness. `/health?probe=1` sends one real request |
|
|
504
518
|
| `GET /status` · `GET /accounts` · `GET /analytics` | OAuth detail · per-seat utilization and grant age · per-account / per-model stats and burn rate |
|
|
505
519
|
| `POST /v1/messages/count_tokens` · `POST /v1/complete` | Token counting and the legacy Text Completions shape |
|
|
506
|
-
| `GET /analytics/stream` · `GET /codex` | Live analytics over SSE · ChatGPT-seat status, read without spending or exposing a token |
|
|
520
|
+
| `GET /analytics/stream` · `GET /analytics/ledger` · `GET /codex` | Live analytics over SSE · the ledger's per-day table · ChatGPT-seat status, read without spending or exposing a token |
|
|
507
521
|
| `/admin/*` | Provisioning, `GET /admin/accounts`, `POST /admin/resume`; only with `DARIO_ADMIN=1` ([admin API](./docs/admin-api.md)) |
|
|
508
522
|
|
|
509
523
|
Flags: [commands.md](./docs/commands.md), plus `dario --help` for the ones it doesn't list yet (`--effort`, `--max-tokens`, `--model-alias`, `--fast-model`, session rotation, concurrency caps, the pacing knobs behind `--stealth`) · env vars grouped by task, for Docker / k8s / systemd: [configuration.md](./docs/configuration.md) · SDK examples: [usage.md](./docs/usage.md).
|
package/dist/analytics.d.ts
CHANGED
|
@@ -139,7 +139,7 @@ export declare function consumerFromBody(body: Record<string, unknown> | null |
|
|
|
139
139
|
* the proxy on every transient non-200/stream-abort.
|
|
140
140
|
*/
|
|
141
141
|
export declare function isNonSubscriptionBilling(claim: string | null | undefined): boolean;
|
|
142
|
-
interface Rate {
|
|
142
|
+
export interface Rate {
|
|
143
143
|
input: number;
|
|
144
144
|
output: number;
|
|
145
145
|
cacheRead: number;
|
|
@@ -168,15 +168,50 @@ interface PricingEntry extends Rate {
|
|
|
168
168
|
* is why the watcher exists (#1048).
|
|
169
169
|
*/
|
|
170
170
|
export declare const PRICING: Record<string, PricingEntry>;
|
|
171
|
+
/**
|
|
172
|
+
* OpenAI's published per-1M-token rates for the models the codex backend
|
|
173
|
+
* serves, standard tier, read off developers.openai.com/api/docs/pricing on
|
|
174
|
+
* 2026-09-11. Kept apart from PRICING because scripts/check-pricing-drift.mjs
|
|
175
|
+
* diffs that table against Anthropic's page and would report every row here
|
|
176
|
+
* as "absent upstream". OpenAI charges nothing to write a cache entry, so
|
|
177
|
+
* cacheCreate is the input rate (the codex path reports no cache writes
|
|
178
|
+
* anyway — `cached_tokens` lands in cacheReadTokens, the rest in inputTokens).
|
|
179
|
+
*
|
|
180
|
+
* Before this table every `gpt-*` row was priced at the sonnet-4-6 fallback:
|
|
181
|
+
* a ChatGPT-plan request showed up in "would-be API cost" at Anthropic's
|
|
182
|
+
* rate for a model Anthropic does not sell. Nothing watches this table yet.
|
|
183
|
+
*/
|
|
184
|
+
export declare const OPENAI_PRICING: Record<string, Rate>;
|
|
185
|
+
export type PricingProvider = 'anthropic' | 'openai';
|
|
186
|
+
/**
|
|
187
|
+
* Which price list a model id belongs to. Every id the codex backend serves
|
|
188
|
+
* starts `gpt-`; the rest of the pattern covers the older OpenAI families a
|
|
189
|
+
* `--model-alias` might name. Anything else is priced as Claude.
|
|
190
|
+
*/
|
|
191
|
+
export declare function providerOfModel(model: string): PricingProvider;
|
|
171
192
|
/**
|
|
172
193
|
* The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
|
|
173
194
|
* rate while within its window, otherwise the standard rate. A trailing context
|
|
174
195
|
* tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
|
|
175
196
|
* the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
|
|
176
|
-
* family's rate
|
|
177
|
-
*
|
|
197
|
+
* family's rate — and so are an effort suffix (`gpt-5.6-terra:high`) and the
|
|
198
|
+
* dated form the response echoes (`claude-haiku-4-5-20251001`, which priced
|
|
199
|
+
* at the sonnet fallback until the ledger's first live run caught it).
|
|
200
|
+
* Unknown Claude models fall back to the sonnet-4-6 rate, unknown OpenAI
|
|
201
|
+
* models to gpt-5.6-terra's. Exported for tests.
|
|
178
202
|
*/
|
|
179
203
|
export declare function pricingRateFor(model: string, atMs: number): Rate;
|
|
204
|
+
/**
|
|
205
|
+
* USD the four token buckets would bill at `model`'s rate in effect at `atMs`.
|
|
206
|
+
* The ledger prices its per-day rows through this too, so a pricing
|
|
207
|
+
* correction reprices history instead of freezing the old number in.
|
|
208
|
+
*/
|
|
209
|
+
export declare function costOfTokens(model: string, atMs: number, t: {
|
|
210
|
+
inputTokens: number;
|
|
211
|
+
outputTokens: number;
|
|
212
|
+
cacheReadTokens: number;
|
|
213
|
+
cacheCreateTokens: number;
|
|
214
|
+
}): number;
|
|
180
215
|
export declare class Analytics extends EventEmitter {
|
|
181
216
|
private records;
|
|
182
217
|
private maxRecords;
|
package/dist/analytics.js
CHANGED
|
@@ -206,16 +206,57 @@ export const PRICING = {
|
|
|
206
206
|
// LOW. Found by check-pricing-drift.mjs on its very first run (#1048).
|
|
207
207
|
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },
|
|
208
208
|
};
|
|
209
|
+
/**
|
|
210
|
+
* OpenAI's published per-1M-token rates for the models the codex backend
|
|
211
|
+
* serves, standard tier, read off developers.openai.com/api/docs/pricing on
|
|
212
|
+
* 2026-09-11. Kept apart from PRICING because scripts/check-pricing-drift.mjs
|
|
213
|
+
* diffs that table against Anthropic's page and would report every row here
|
|
214
|
+
* as "absent upstream". OpenAI charges nothing to write a cache entry, so
|
|
215
|
+
* cacheCreate is the input rate (the codex path reports no cache writes
|
|
216
|
+
* anyway — `cached_tokens` lands in cacheReadTokens, the rest in inputTokens).
|
|
217
|
+
*
|
|
218
|
+
* Before this table every `gpt-*` row was priced at the sonnet-4-6 fallback:
|
|
219
|
+
* a ChatGPT-plan request showed up in "would-be API cost" at Anthropic's
|
|
220
|
+
* rate for a model Anthropic does not sell. Nothing watches this table yet.
|
|
221
|
+
*/
|
|
222
|
+
export const OPENAI_PRICING = {
|
|
223
|
+
'gpt-6-astra': { input: 10, output: 50, cacheRead: 1, cacheCreate: 10 },
|
|
224
|
+
'gpt-5.6-sol': { input: 4, output: 20, cacheRead: 0.4, cacheCreate: 4 },
|
|
225
|
+
'gpt-5.6-terra': { input: 2, output: 12, cacheRead: 0.2, cacheCreate: 2 },
|
|
226
|
+
'gpt-5.6-luna': { input: 0.2, output: 1.2, cacheRead: 0.02, cacheCreate: 0.2 },
|
|
227
|
+
'gpt-5.5': { input: 5, output: 30, cacheRead: 0.5, cacheCreate: 5 },
|
|
228
|
+
'gpt-5.4': { input: 2.5, output: 15, cacheRead: 0.25, cacheCreate: 2.5 },
|
|
229
|
+
'gpt-5.4-mini': { input: 0.75, output: 4.5, cacheRead: 0.075, cacheCreate: 0.75 },
|
|
230
|
+
'gpt-5.4-nano': { input: 0.2, output: 1.25, cacheRead: 0.02, cacheCreate: 0.2 },
|
|
231
|
+
'gpt-5.3-codex': { input: 1.75, output: 14, cacheRead: 0.175, cacheCreate: 1.75 },
|
|
232
|
+
};
|
|
233
|
+
/** The unknown-model rate on the OpenAI side: dario's default codex model. */
|
|
234
|
+
const OPENAI_FALLBACK_MODEL = 'gpt-5.6-terra';
|
|
235
|
+
/**
|
|
236
|
+
* Which price list a model id belongs to. Every id the codex backend serves
|
|
237
|
+
* starts `gpt-`; the rest of the pattern covers the older OpenAI families a
|
|
238
|
+
* `--model-alias` might name. Anything else is priced as Claude.
|
|
239
|
+
*/
|
|
240
|
+
export function providerOfModel(model) {
|
|
241
|
+
return /^(gpt-|o\d|codex|chatgpt)/i.test(model) ? 'openai' : 'anthropic';
|
|
242
|
+
}
|
|
209
243
|
/**
|
|
210
244
|
* The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
|
|
211
245
|
* rate while within its window, otherwise the standard rate. A trailing context
|
|
212
246
|
* tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
|
|
213
247
|
* the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
|
|
214
|
-
* family's rate
|
|
215
|
-
*
|
|
248
|
+
* family's rate — and so are an effort suffix (`gpt-5.6-terra:high`) and the
|
|
249
|
+
* dated form the response echoes (`claude-haiku-4-5-20251001`, which priced
|
|
250
|
+
* at the sonnet fallback until the ledger's first live run caught it).
|
|
251
|
+
* Unknown Claude models fall back to the sonnet-4-6 rate, unknown OpenAI
|
|
252
|
+
* models to gpt-5.6-terra's. Exported for tests.
|
|
216
253
|
*/
|
|
217
254
|
export function pricingRateFor(model, atMs) {
|
|
218
|
-
const baseModel = model.replace(/\[[^\]]*\]$/, '');
|
|
255
|
+
const baseModel = model.replace(/\[[^\]]*\]$/, '').replace(/:[a-z]+$/i, '').replace(/-\d{8}$/, '');
|
|
256
|
+
if (providerOfModel(baseModel) === 'openai') {
|
|
257
|
+
const rate = OPENAI_PRICING[baseModel] ?? OPENAI_PRICING[OPENAI_FALLBACK_MODEL];
|
|
258
|
+
return { ...rate };
|
|
259
|
+
}
|
|
219
260
|
const entry = PRICING[baseModel] ?? PRICING['claude-sonnet-4-6'];
|
|
220
261
|
if (entry.intro && atMs <= Date.parse(`${entry.intro.until}T23:59:59.999Z`)) {
|
|
221
262
|
const { until: _until, ...introRate } = entry.intro;
|
|
@@ -223,16 +264,24 @@ export function pricingRateFor(model, atMs) {
|
|
|
223
264
|
}
|
|
224
265
|
return { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheCreate: entry.cacheCreate };
|
|
225
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* USD the four token buckets would bill at `model`'s rate in effect at `atMs`.
|
|
269
|
+
* The ledger prices its per-day rows through this too, so a pricing
|
|
270
|
+
* correction reprices history instead of freezing the old number in.
|
|
271
|
+
*/
|
|
272
|
+
export function costOfTokens(model, atMs, t) {
|
|
273
|
+
const p = pricingRateFor(model, atMs);
|
|
274
|
+
return ((t.inputTokens * p.input) +
|
|
275
|
+
(t.outputTokens * p.output) +
|
|
276
|
+
(t.cacheReadTokens * p.cacheRead) +
|
|
277
|
+
(t.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
|
|
278
|
+
}
|
|
226
279
|
function estimateCost(record) {
|
|
227
280
|
// Price each record at the rate effective at ITS OWN timestamp, so a window
|
|
228
281
|
// that spans a pricing cutover (no model currently has one — Sonnet 5's
|
|
229
282
|
// scheduled increase was cancelled and its $2/$10 made permanent)
|
|
230
283
|
// estimates each side correctly rather than repricing history at today's rate.
|
|
231
|
-
|
|
232
|
-
return ((record.inputTokens * p.input) +
|
|
233
|
-
(record.outputTokens * p.output) +
|
|
234
|
-
(record.cacheReadTokens * p.cacheRead) +
|
|
235
|
-
(record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
|
|
284
|
+
return costOfTokens(record.model, record.timestamp, record);
|
|
236
285
|
}
|
|
237
286
|
export class Analytics extends EventEmitter {
|
|
238
287
|
records = [];
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// at the bottom of the file so importing this module (e.g. from tests that
|
|
17
17
|
// just want `parsePositiveIntEnv`) doesn't trigger a Bun relaunch or any
|
|
18
18
|
// other startup side effect.
|
|
19
|
-
import { unlink } from 'node:fs/promises';
|
|
19
|
+
import { unlink, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { formatLedgerSummary, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
|
|
20
21
|
import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
|
|
21
22
|
import { maskEmail } from './pool.js';
|
|
22
23
|
import { realpathSync, readFileSync } from 'node:fs';
|
|
@@ -630,6 +631,10 @@ async function proxy() {
|
|
|
630
631
|
// On by default; see ProxyOptions.midstreamContinue.
|
|
631
632
|
const midstreamContinue = !(args.includes('--no-midstream-continue')
|
|
632
633
|
|| ['0', 'false', 'no', 'off'].includes((process.env['DARIO_MIDSTREAM_CONTINUE'] ?? '').toLowerCase()));
|
|
634
|
+
// --no-ledger / DARIO_LEDGER=0 — do not keep the lifetime ledger (v6.6).
|
|
635
|
+
// On by default; see ProxyOptions.ledger.
|
|
636
|
+
const ledger = !(args.includes('--no-ledger')
|
|
637
|
+
|| ['0', 'false', 'no', 'off'].includes((process.env['DARIO_LEDGER'] ?? '').toLowerCase()));
|
|
633
638
|
// --preserve-output-format — carry the client body's `output_config.format`
|
|
634
639
|
// (structured-output JSON schema) through to upstream instead of dropping it
|
|
635
640
|
// during the CC rebuild. See ProxyOptions.preserveOutputFormat for rationale.
|
|
@@ -654,7 +659,7 @@ async function proxy() {
|
|
|
654
659
|
console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
|
|
655
660
|
process.exit(1);
|
|
656
661
|
}
|
|
657
|
-
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue });
|
|
662
|
+
await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, poolStrategy, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger });
|
|
658
663
|
}
|
|
659
664
|
/**
|
|
660
665
|
* Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
|
|
@@ -1583,7 +1588,12 @@ async function help() {
|
|
|
1583
1588
|
rate-limit snapshot from Anthropic, see
|
|
1584
1589
|
\`dario doctor --usage\`. --port=N to target
|
|
1585
1590
|
a non-default port; --json for the raw
|
|
1586
|
-
/analytics payload.
|
|
1591
|
+
/analytics payload. Above the window: the
|
|
1592
|
+
lifetime API-equivalent spend from the
|
|
1593
|
+
ledger (read from disk when the proxy is
|
|
1594
|
+
down). --card[=file.svg] writes a share
|
|
1595
|
+
card of that number (default
|
|
1596
|
+
dario-api-equivalent.svg). (v6.6)
|
|
1587
1597
|
dario upgrade npm install -g @askalf/dario@latest with a
|
|
1588
1598
|
pre-flight current-vs-latest check.
|
|
1589
1599
|
|
|
@@ -1704,6 +1714,14 @@ async function help() {
|
|
|
1704
1714
|
fallback entry for the other provider,
|
|
1705
1715
|
the stream ends truncated as before.
|
|
1706
1716
|
Env: DARIO_MIDSTREAM_CONTINUE=0. (v6.1)
|
|
1717
|
+
--no-ledger Do not keep the lifetime ledger
|
|
1718
|
+
(~/.dario/ledger.json): per-day, per-model
|
|
1719
|
+
token totals that let /analytics and
|
|
1720
|
+
\`dario usage\` say what the traffic would
|
|
1721
|
+
have cost on the metered API since the
|
|
1722
|
+
first request, across restarts. Env:
|
|
1723
|
+
DARIO_LEDGER=0; DARIO_LEDGER_PATH=<file>
|
|
1724
|
+
moves it. (v6.6)
|
|
1707
1725
|
--session-idle-rotate=MS Idle ms before an account's session id
|
|
1708
1726
|
rotates (default: 900000 = 15 min).
|
|
1709
1727
|
Real CC rotates once per conversation, not
|
|
@@ -2322,6 +2340,9 @@ async function usage() {
|
|
|
2322
2340
|
? parseInt(process.env['DARIO_USAGE_PORT'], 10)
|
|
2323
2341
|
: 3456;
|
|
2324
2342
|
const asJson = args.includes('--json');
|
|
2343
|
+
// --card / --card=<file>: write the share card (SVG) of the lifetime number.
|
|
2344
|
+
const cardArg = args.find(a => a === '--card' || a.startsWith('--card='));
|
|
2345
|
+
const cardPath = cardArg ? (cardArg.includes('=') ? cardArg.slice('--card='.length) : 'dario-api-equivalent.svg') : null;
|
|
2325
2346
|
const url = `http://127.0.0.1:${port}/analytics`;
|
|
2326
2347
|
let payload = null;
|
|
2327
2348
|
let connectError = null;
|
|
@@ -2337,18 +2358,54 @@ async function usage() {
|
|
|
2337
2358
|
catch (err) {
|
|
2338
2359
|
connectError = err instanceof Error ? err.message : String(err);
|
|
2339
2360
|
}
|
|
2361
|
+
// The lifetime number does not need a running proxy: the ledger is a file.
|
|
2362
|
+
// Prefer the proxy's view (it holds records not yet flushed); fall back to
|
|
2363
|
+
// reading the file this port's proxy would write.
|
|
2364
|
+
let lifetime = payload?.lifetime ?? null;
|
|
2365
|
+
let lifetimeNote = null;
|
|
2366
|
+
if (!payload) {
|
|
2367
|
+
const ledgerPath = resolveLedgerPath(port);
|
|
2368
|
+
const { file, error } = await readLedgerFile(ledgerPath);
|
|
2369
|
+
if (file)
|
|
2370
|
+
lifetime = summarizeLedger(file, ledgerPath);
|
|
2371
|
+
else if (error)
|
|
2372
|
+
lifetimeNote = `ledger at ${ledgerPath} unreadable: ${error}`;
|
|
2373
|
+
else
|
|
2374
|
+
lifetimeNote = `no ledger at ${ledgerPath} yet — it appears after the first request through a proxy on this port`;
|
|
2375
|
+
}
|
|
2376
|
+
else if (payload.lifetime === null) {
|
|
2377
|
+
lifetimeNote = 'ledger disabled on this proxy (--no-ledger)';
|
|
2378
|
+
}
|
|
2379
|
+
if (cardPath) {
|
|
2380
|
+
if (!lifetime) {
|
|
2381
|
+
console.error(` No lifetime numbers to draw${lifetimeNote ? ` (${lifetimeNote})` : ''}.`);
|
|
2382
|
+
process.exit(1);
|
|
2383
|
+
}
|
|
2384
|
+
await writeFile(cardPath, renderLedgerCard(lifetime), 'utf8');
|
|
2385
|
+
if (!asJson)
|
|
2386
|
+
console.log(` Wrote ${cardPath} — ${formatUsd(lifetime.apiEquivalentCost)} API-equivalent since ${lifetime.since.slice(0, 10)}.`);
|
|
2387
|
+
}
|
|
2340
2388
|
if (asJson) {
|
|
2341
2389
|
if (payload) {
|
|
2342
2390
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
2343
2391
|
return;
|
|
2344
2392
|
}
|
|
2345
|
-
process.stdout.write(JSON.stringify({ error: 'proxy not reachable', port, detail: connectError }, null, 2) + '\n');
|
|
2393
|
+
process.stdout.write(JSON.stringify({ error: 'proxy not reachable', port, detail: connectError, lifetime }, null, 2) + '\n');
|
|
2346
2394
|
process.exit(1);
|
|
2347
2395
|
}
|
|
2348
2396
|
console.log('');
|
|
2349
2397
|
console.log(' dario — Usage');
|
|
2350
2398
|
console.log(' ─────────────');
|
|
2351
2399
|
console.log('');
|
|
2400
|
+
if (lifetime) {
|
|
2401
|
+
for (const line of formatLedgerSummary(lifetime))
|
|
2402
|
+
console.log(line);
|
|
2403
|
+
console.log('');
|
|
2404
|
+
}
|
|
2405
|
+
else if (lifetimeNote) {
|
|
2406
|
+
console.log(` API-equivalent spend: ${lifetimeNote}.`);
|
|
2407
|
+
console.log('');
|
|
2408
|
+
}
|
|
2352
2409
|
if (!payload) {
|
|
2353
2410
|
console.log(` Proxy not reachable on http://127.0.0.1:${port} (${connectError ?? 'no response'}).`);
|
|
2354
2411
|
console.log(' `dario usage` summarizes traffic from a running proxy (live history).');
|
package/dist/codex-accounts.d.ts
CHANGED
|
@@ -61,14 +61,88 @@ export declare function _resetCodexRefreshFailuresForTest(): void;
|
|
|
61
61
|
* a misleading "run `dario login`" answer to the client.
|
|
62
62
|
*/
|
|
63
63
|
export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
|
|
64
|
+
/** Record that `alias` declined, for as long as the upstream asked. */
|
|
65
|
+
export declare function noteCodexDecline(alias: string, retryAfterMs?: number | null): number;
|
|
66
|
+
/** A seat that just served is not rate-limited — clear it. */
|
|
67
|
+
export declare function clearCodexDecline(alias: string): void;
|
|
68
|
+
/** Ms until `alias` is askable again; 0 when it is askable now. */
|
|
69
|
+
export declare function codexCooldownRemainingMs(alias: string): number;
|
|
70
|
+
/** Test seam — forget every cool-down and binding. */
|
|
71
|
+
export declare function _resetCodexPoolForTest(): void;
|
|
72
|
+
/** The alias currently bound to a conversation, or null. */
|
|
73
|
+
export declare function codexStickyAliasFor(key: string | null | undefined): string | null;
|
|
64
74
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
75
|
+
* Move a conversation onto `alias`, the codex mirror of pool.rebindSticky.
|
|
76
|
+
*
|
|
77
|
+
* Selection binds a conversation to the seat it picked; mid-request failover
|
|
78
|
+
* then moves it, and without this the binding still names the seat that just
|
|
79
|
+
* declined — the next turn would read a stale binding, find it cooling, and
|
|
80
|
+
* re-pick from scratch. A null key is accepted so the caller does not have to
|
|
81
|
+
* guard: a request with no hashable first user message has no conversation to
|
|
82
|
+
* bind.
|
|
83
|
+
*/
|
|
84
|
+
export declare function rebindCodexSticky(key: string | null | undefined, alias: string): void;
|
|
85
|
+
/**
|
|
86
|
+
* Choose a ChatGPT seat for this request.
|
|
87
|
+
*
|
|
88
|
+
* Order, most specific first:
|
|
89
|
+
* 1. an explicitly named alias (`x-dario-account`, DARIO_CODEX_ACCOUNT) — a
|
|
90
|
+
* pin is an instruction, so it is honoured even while cooling; the caller
|
|
91
|
+
* asked for that seat and gets its answer, 429 included.
|
|
92
|
+
* 2. the seat this conversation is already bound to, unless it is cooling.
|
|
93
|
+
* 3. the first seat alphabetically that is not cooling — deterministic, so a
|
|
94
|
+
* given conversation lands on the same seat across a restart and keeps its
|
|
95
|
+
* prompt cache.
|
|
96
|
+
* 4. null when every seat is cooling. The caller answers from that rather
|
|
97
|
+
* than spending a request that can only 429 again.
|
|
98
|
+
*/
|
|
99
|
+
export declare function selectCodexAccount(preferredAlias?: string, opts?: {
|
|
100
|
+
stickyKey?: string | null;
|
|
101
|
+
}): Promise<CodexAccountCredentials | null>;
|
|
102
|
+
/**
|
|
103
|
+
* The next askable seat that this request has NOT already tried.
|
|
104
|
+
*
|
|
105
|
+
* Mid-flight failover: a seat that 429s during a request hands the SAME
|
|
106
|
+
* request to a peer rather than failing it. Without this the pool only helps
|
|
107
|
+
* the request AFTER the one that discovered the limit — the discovering
|
|
108
|
+
* request still failed, every time a window rolled over.
|
|
109
|
+
*
|
|
110
|
+
* `tried` is per-request, so a seat already attempted here is never revisited
|
|
111
|
+
* inside the same request even if its cool-down has not landed yet. That is
|
|
112
|
+
* the codex mirror of the Claude pool's selectExcluding, and it is what makes
|
|
113
|
+
* the loop terminate: every pass adds a seat, so it is bounded by pool size.
|
|
114
|
+
*
|
|
115
|
+
* Stickiness is deliberately NOT consulted. The bound seat is the one that
|
|
116
|
+
* just declined; re-offering it would loop, and a conversation whose seat has
|
|
117
|
+
* gone away is better served elsewhere than not at all.
|
|
118
|
+
*/
|
|
119
|
+
export declare function selectCodexAccountExcluding(tried: ReadonlySet<string>): Promise<CodexAccountCredentials | null>;
|
|
120
|
+
/** Every seat is cooling — the fail-fast condition, for the caller's message. */
|
|
121
|
+
/**
|
|
122
|
+
* Are ALL of these aliases cooling, right now?
|
|
123
|
+
*
|
|
124
|
+
* Synchronous on purpose. The provider-wide cool-down is written from this
|
|
125
|
+
* answer, and an `await` between deciding and writing is a window another
|
|
126
|
+
* in-flight request can use: the last-limited-seat request observes every seat
|
|
127
|
+
* cooling, a peer then succeeds on a just-recovered seat and calls
|
|
128
|
+
* clearCodexDecline, and the delayed continuation re-cools the whole provider
|
|
129
|
+
* against a pool that is healthy again. canAttempt('codex') then short-circuits
|
|
130
|
+
* and the healthy seat is skipped until the stale window expires — the exact
|
|
131
|
+
* single-seat outage this pool exists to prevent, reintroduced by the
|
|
132
|
+
* bookkeeping meant to prevent it.
|
|
133
|
+
*
|
|
134
|
+
* Re-checking inside the continuation narrows that window; it does not close
|
|
135
|
+
* it, because the re-check is itself another await. Taking the alias list first
|
|
136
|
+
* and then deciding-and-writing with no suspension point between them closes it
|
|
137
|
+
* outright: JS runs that callback as one unit, so nothing can interleave.
|
|
138
|
+
*
|
|
139
|
+
* The alias list may be a tick stale, which is harmless — a seat added in that
|
|
140
|
+
* window is not cooling, so "all cooled" is false on the next decline anyway.
|
|
70
141
|
*/
|
|
71
|
-
export declare function
|
|
142
|
+
export declare function allAliasesCooled(aliases: readonly string[]): boolean;
|
|
143
|
+
export declare function allCodexAccountsCooled(): Promise<boolean>;
|
|
144
|
+
/** Longest remaining cool-down across every seat, for a `retry-after`. */
|
|
145
|
+
export declare function codexPoolRetryAfterMs(): Promise<number>;
|
|
72
146
|
/**
|
|
73
147
|
* Parse whatever the user pastes back after authorizing.
|
|
74
148
|
*
|
package/dist/codex-accounts.js
CHANGED
|
@@ -15,6 +15,7 @@ import { homedir } from 'node:os';
|
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
16
|
import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, CodexRefreshError, } from './codex-oauth.js';
|
|
17
17
|
import { durableWriteFile } from './durable-write.js';
|
|
18
|
+
import { ProviderCooldowns } from './provider-cooldown.js';
|
|
18
19
|
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
20
|
const CODEX_ACCOUNTS_DIR = join(DARIO_DIR, 'codex-accounts');
|
|
20
21
|
/** Same alias charset/traversal guard as accounts.ts's safeAliasPath. */
|
|
@@ -298,7 +299,105 @@ export async function getFreshCodexAccount(creds) {
|
|
|
298
299
|
* balancing — a subscription is per-seat, so spreading load across seats is the
|
|
299
300
|
* user's decision to make explicitly, not something to do implicitly.
|
|
300
301
|
*/
|
|
301
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Per-seat cool-downs and conversation stickiness for the ChatGPT pool
|
|
304
|
+
* (dario#1244 follow-up).
|
|
305
|
+
*
|
|
306
|
+
* Until now selectCodexAccount returned `sort()[0]` — the alphabetically FIRST
|
|
307
|
+
* account, every time. `dario add altman` will happily store a dozen seats and
|
|
308
|
+
* dario would use exactly one of them. That is why the account-wide 429 on
|
|
309
|
+
* 2026-09-07 took the whole GPT lane down: a second seat sat there, healthy and
|
|
310
|
+
* unreachable, while every request failed over to Claude.
|
|
311
|
+
*
|
|
312
|
+
* Reuses ProviderCooldowns keyed by ALIAS rather than by provider name. It is
|
|
313
|
+
* already the right shape — arbitrary string key, injectable clock, entries
|
|
314
|
+
* dropped on read — so the pool needs no second cool-down implementation.
|
|
315
|
+
*
|
|
316
|
+
* ROTATION IS PER-CONVERSATION, NOT PER-REQUEST, and that is the whole design.
|
|
317
|
+
* The Codex prompt cache is scoped to the serving account: a conversation that
|
|
318
|
+
* builds a prefix on seat A reads nothing from it on seat B, and measured cache
|
|
319
|
+
* share on this lane is 59% in production against a 73% controlled ceiling.
|
|
320
|
+
* Rotating per request would trade a rate-limit problem for a cache problem and
|
|
321
|
+
* come out behind. So a conversation binds to a seat and stays there until that
|
|
322
|
+
* seat actually declines.
|
|
323
|
+
*
|
|
324
|
+
* Deliberately NOT headroom routing like the Claude pool. Claude responds with
|
|
325
|
+
* `anthropic-ratelimit-*` headers on every response, so that pool can read
|
|
326
|
+
* utilisation before it picks. The Codex backend states nothing until it 429s —
|
|
327
|
+
* the only signal is the decline itself plus its `retry-after` — so this is
|
|
328
|
+
* fill-first with cool-down eviction, which is what the available signal
|
|
329
|
+
* supports. If the backend ever starts reporting utilisation, this is where
|
|
330
|
+
* headroom would go.
|
|
331
|
+
*/
|
|
332
|
+
let codexCooldowns = new ProviderCooldowns();
|
|
333
|
+
/** conversation sticky key -> alias. Bounded; swept when it exceeds the cap. */
|
|
334
|
+
const codexSticky = new Map();
|
|
335
|
+
const CODEX_STICKY_MAX = 500;
|
|
336
|
+
/** Record that `alias` declined, for as long as the upstream asked. */
|
|
337
|
+
export function noteCodexDecline(alias, retryAfterMs) {
|
|
338
|
+
return codexCooldowns.note(alias, retryAfterMs);
|
|
339
|
+
}
|
|
340
|
+
/** A seat that just served is not rate-limited — clear it. */
|
|
341
|
+
export function clearCodexDecline(alias) {
|
|
342
|
+
codexCooldowns.clear(alias);
|
|
343
|
+
}
|
|
344
|
+
/** Ms until `alias` is askable again; 0 when it is askable now. */
|
|
345
|
+
export function codexCooldownRemainingMs(alias) {
|
|
346
|
+
return codexCooldowns.remainingMs(alias);
|
|
347
|
+
}
|
|
348
|
+
/** Test seam — forget every cool-down and binding. */
|
|
349
|
+
export function _resetCodexPoolForTest() {
|
|
350
|
+
codexSticky.clear();
|
|
351
|
+
// A fresh instance rather than clearing per alias: the previous version
|
|
352
|
+
// emptied the sticky map first and then iterated it, so it cleared nothing
|
|
353
|
+
// and cool-downs leaked between test cases.
|
|
354
|
+
codexCooldowns = new ProviderCooldowns();
|
|
355
|
+
}
|
|
356
|
+
/** The alias currently bound to a conversation, or null. */
|
|
357
|
+
export function codexStickyAliasFor(key) {
|
|
358
|
+
return key ? codexSticky.get(key) ?? null : null;
|
|
359
|
+
}
|
|
360
|
+
function bindCodexSticky(key, alias) {
|
|
361
|
+
if (codexSticky.size >= CODEX_STICKY_MAX && !codexSticky.has(key)) {
|
|
362
|
+
// Oldest-first eviction: Map preserves insertion order, so the first key is
|
|
363
|
+
// the least recently bound. Losing a binding costs one cache miss, never
|
|
364
|
+
// correctness, so a cheap sweep beats an LRU.
|
|
365
|
+
const oldest = codexSticky.keys().next().value;
|
|
366
|
+
if (oldest !== undefined)
|
|
367
|
+
codexSticky.delete(oldest);
|
|
368
|
+
}
|
|
369
|
+
codexSticky.set(key, alias);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Move a conversation onto `alias`, the codex mirror of pool.rebindSticky.
|
|
373
|
+
*
|
|
374
|
+
* Selection binds a conversation to the seat it picked; mid-request failover
|
|
375
|
+
* then moves it, and without this the binding still names the seat that just
|
|
376
|
+
* declined — the next turn would read a stale binding, find it cooling, and
|
|
377
|
+
* re-pick from scratch. A null key is accepted so the caller does not have to
|
|
378
|
+
* guard: a request with no hashable first user message has no conversation to
|
|
379
|
+
* bind.
|
|
380
|
+
*/
|
|
381
|
+
export function rebindCodexSticky(key, alias) {
|
|
382
|
+
if (!key)
|
|
383
|
+
return;
|
|
384
|
+
bindCodexSticky(key, alias);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Choose a ChatGPT seat for this request.
|
|
388
|
+
*
|
|
389
|
+
* Order, most specific first:
|
|
390
|
+
* 1. an explicitly named alias (`x-dario-account`, DARIO_CODEX_ACCOUNT) — a
|
|
391
|
+
* pin is an instruction, so it is honoured even while cooling; the caller
|
|
392
|
+
* asked for that seat and gets its answer, 429 included.
|
|
393
|
+
* 2. the seat this conversation is already bound to, unless it is cooling.
|
|
394
|
+
* 3. the first seat alphabetically that is not cooling — deterministic, so a
|
|
395
|
+
* given conversation lands on the same seat across a restart and keeps its
|
|
396
|
+
* prompt cache.
|
|
397
|
+
* 4. null when every seat is cooling. The caller answers from that rather
|
|
398
|
+
* than spending a request that can only 429 again.
|
|
399
|
+
*/
|
|
400
|
+
export async function selectCodexAccount(preferredAlias, opts) {
|
|
302
401
|
const alias = preferredAlias || process.env.DARIO_CODEX_ACCOUNT;
|
|
303
402
|
if (alias) {
|
|
304
403
|
const one = await loadCodexAccount(alias);
|
|
@@ -308,7 +407,83 @@ export async function selectCodexAccount(preferredAlias) {
|
|
|
308
407
|
const all = await loadAllCodexAccounts();
|
|
309
408
|
if (all.length === 0)
|
|
310
409
|
return null;
|
|
311
|
-
|
|
410
|
+
const byAlias = [...all].sort((a, b) => a.alias.localeCompare(b.alias));
|
|
411
|
+
const key = opts?.stickyKey ?? null;
|
|
412
|
+
if (key) {
|
|
413
|
+
const bound = codexSticky.get(key);
|
|
414
|
+
if (bound && !codexCooldowns.isCooled(bound)) {
|
|
415
|
+
const hit = byAlias.find((c) => c.alias === bound);
|
|
416
|
+
// A binding to a seat that has since been removed falls through to a
|
|
417
|
+
// fresh pick rather than failing the request.
|
|
418
|
+
if (hit)
|
|
419
|
+
return hit;
|
|
420
|
+
codexSticky.delete(key);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const free = byAlias.find((c) => !codexCooldowns.isCooled(c.alias));
|
|
424
|
+
if (!free)
|
|
425
|
+
return null;
|
|
426
|
+
if (key)
|
|
427
|
+
bindCodexSticky(key, free.alias);
|
|
428
|
+
return free;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The next askable seat that this request has NOT already tried.
|
|
432
|
+
*
|
|
433
|
+
* Mid-flight failover: a seat that 429s during a request hands the SAME
|
|
434
|
+
* request to a peer rather than failing it. Without this the pool only helps
|
|
435
|
+
* the request AFTER the one that discovered the limit — the discovering
|
|
436
|
+
* request still failed, every time a window rolled over.
|
|
437
|
+
*
|
|
438
|
+
* `tried` is per-request, so a seat already attempted here is never revisited
|
|
439
|
+
* inside the same request even if its cool-down has not landed yet. That is
|
|
440
|
+
* the codex mirror of the Claude pool's selectExcluding, and it is what makes
|
|
441
|
+
* the loop terminate: every pass adds a seat, so it is bounded by pool size.
|
|
442
|
+
*
|
|
443
|
+
* Stickiness is deliberately NOT consulted. The bound seat is the one that
|
|
444
|
+
* just declined; re-offering it would loop, and a conversation whose seat has
|
|
445
|
+
* gone away is better served elsewhere than not at all.
|
|
446
|
+
*/
|
|
447
|
+
export async function selectCodexAccountExcluding(tried) {
|
|
448
|
+
const all = await loadAllCodexAccounts();
|
|
449
|
+
if (all.length === 0)
|
|
450
|
+
return null;
|
|
451
|
+
return [...all]
|
|
452
|
+
.sort((a, b) => a.alias.localeCompare(b.alias))
|
|
453
|
+
.find((c) => !tried.has(c.alias) && !codexCooldowns.isCooled(c.alias)) ?? null;
|
|
454
|
+
}
|
|
455
|
+
/** Every seat is cooling — the fail-fast condition, for the caller's message. */
|
|
456
|
+
/**
|
|
457
|
+
* Are ALL of these aliases cooling, right now?
|
|
458
|
+
*
|
|
459
|
+
* Synchronous on purpose. The provider-wide cool-down is written from this
|
|
460
|
+
* answer, and an `await` between deciding and writing is a window another
|
|
461
|
+
* in-flight request can use: the last-limited-seat request observes every seat
|
|
462
|
+
* cooling, a peer then succeeds on a just-recovered seat and calls
|
|
463
|
+
* clearCodexDecline, and the delayed continuation re-cools the whole provider
|
|
464
|
+
* against a pool that is healthy again. canAttempt('codex') then short-circuits
|
|
465
|
+
* and the healthy seat is skipped until the stale window expires — the exact
|
|
466
|
+
* single-seat outage this pool exists to prevent, reintroduced by the
|
|
467
|
+
* bookkeeping meant to prevent it.
|
|
468
|
+
*
|
|
469
|
+
* Re-checking inside the continuation narrows that window; it does not close
|
|
470
|
+
* it, because the re-check is itself another await. Taking the alias list first
|
|
471
|
+
* and then deciding-and-writing with no suspension point between them closes it
|
|
472
|
+
* outright: JS runs that callback as one unit, so nothing can interleave.
|
|
473
|
+
*
|
|
474
|
+
* The alias list may be a tick stale, which is harmless — a seat added in that
|
|
475
|
+
* window is not cooling, so "all cooled" is false on the next decline anyway.
|
|
476
|
+
*/
|
|
477
|
+
export function allAliasesCooled(aliases) {
|
|
478
|
+
return aliases.length > 0 && aliases.every((a) => codexCooldowns.isCooled(a));
|
|
479
|
+
}
|
|
480
|
+
export async function allCodexAccountsCooled() {
|
|
481
|
+
return allAliasesCooled(await listCodexAccountAliases());
|
|
482
|
+
}
|
|
483
|
+
/** Longest remaining cool-down across every seat, for a `retry-after`. */
|
|
484
|
+
export async function codexPoolRetryAfterMs() {
|
|
485
|
+
const aliases = await listCodexAccountAliases();
|
|
486
|
+
return aliases.reduce((max, a) => Math.max(max, codexCooldowns.remainingMs(a)), 0);
|
|
312
487
|
}
|
|
313
488
|
/**
|
|
314
489
|
* Parse whatever the user pastes back after authorizing.
|