@askalf/dario 6.8.7 → 6.8.9

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.
@@ -14,6 +14,13 @@
14
14
  * GET /admin/accounts -> { accounts: [...], count }
15
15
  * DELETE /admin/accounts/<alias> -> { alias, removed }
16
16
  *
17
+ * The same four for a ChatGPT (altman) seat (dario#1009) — a headless proxy
18
+ * could add a Claude seat over HTTP but a ChatGPT one only from a terminal:
19
+ * POST /admin/codex/login/start { alias? } -> { alias, authorize_url, expires_at, instructions }
20
+ * POST /admin/codex/login/complete { alias, code } -> { alias, status, expires_at } (code = the redirect URL or the bare code)
21
+ * GET /admin/codex/accounts -> { accounts: [{ alias, expiresAt, needsRefresh }], count }
22
+ * DELETE /admin/codex/accounts/<alias> -> { alias, removed }
23
+ *
17
24
  * The login flow mirrors `dario accounts add --manual` (PKCE + manual paste):
18
25
  * `/start` returns the authorize URL the operator opens in a browser; they POST
19
26
  * the code Anthropic displays back to `/complete`. The PKCE verifier + state
@@ -142,8 +149,16 @@ export interface AdminAccountLive {
142
149
  consecutiveAuthFailures: number;
143
150
  }
144
151
  /** An audited admin action — see `AdminDeps.audit`. Never carries secrets. */
152
+ /** One stored ChatGPT seat as `GET /admin/codex/accounts` reports it. */
153
+ export interface AdminCodexAccountRecord {
154
+ alias: string;
155
+ expiresAt: number;
156
+ needsRefresh: boolean;
157
+ }
145
158
  export interface AdminAuditEvent {
146
159
  action: 'login_start' | 'login_complete' | 'account_remove' | 'auth_reject' | 'rate_limited' | 'key_create' | 'key_revoke' | 'key_rotate';
160
+ /** Which engine's credentials the event touched; absent means Claude, the only engine before codex joined (dario#1009). */
161
+ engine?: 'codex';
147
162
  ok: boolean;
148
163
  status: number;
149
164
  /** Account alias, when the action targets one. */
@@ -164,6 +179,8 @@ export interface AdminDeps {
164
179
  * the change routable by the time the client sees its 200.
165
180
  */
166
181
  onAccountsChanged?: () => void | Promise<void>;
182
+ /** A ChatGPT seat was added or removed over HTTP — the proxy drops its "no codex account" cache so the next request routes. */
183
+ onCodexAccountsChanged?: () => void | Promise<void>;
167
184
  /**
168
185
  * Persisted-account inventory (alias, scopes, token expiry). Defaults to the
169
186
  * on-disk store at `~/.dario/accounts`; injectable for tests.
package/dist/admin-api.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { maskEmail } from './pool.js';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
4
+ import { startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, loadAllCodexAccounts, listCodexAccountAliases, codexAccountNeedsRefresh, parseCodexManualPaste, } from './codex-accounts.js';
4
5
  import { parseManualPaste } from './oauth.js';
5
6
  import { grantAge } from './refresh-grant.js';
6
7
  import { createKey, revokeKey, rotateKey, parseExpiry, publicKey, KEY_NAME_RE } from './keys.js';
7
8
  const PENDING_TTL_MS = 10 * 60_000;
8
9
  const MAX_PENDING = 64; // backstop against unbounded growth (distinct aliases)
9
10
  const ACCOUNTS_PREFIX = '/admin/accounts/';
11
+ const CODEX_ACCOUNTS_PREFIX = '/admin/codex/accounts/';
10
12
  const KEYS_PREFIX = '/admin/keys/';
11
13
  /**
12
14
  * `consecutiveAuthFailures` floor for `/admin/login/start-needed` to treat an
@@ -23,16 +25,21 @@ const NEEDS_LOGIN_THRESHOLD = 3;
23
25
  const MAX_BATCH_ITEMS = 64;
24
26
  // Keyed by account alias — one pending login per alias (#599).
25
27
  const pendingLogins = new Map();
28
+ // The ChatGPT seats' pending logins live apart: an alias may name a Claude
29
+ // seat and a ChatGPT seat at once (the stores are separate directories).
30
+ const pendingCodexLogins = new Map();
26
31
  function prunePending(now) {
27
- for (const [id, p] of pendingLogins) {
28
- if (p.expiresAt <= now)
29
- pendingLogins.delete(id);
32
+ for (const map of [pendingLogins, pendingCodexLogins]) {
33
+ for (const [id, p] of map) {
34
+ if (p.expiresAt <= now)
35
+ map.delete(id);
36
+ }
30
37
  }
31
38
  }
32
39
  /** First `account-<n>` not already taken by an existing account or pending login. */
33
- function nextDefaultAlias(taken) {
40
+ function nextDefaultAlias(taken, prefix = 'account') {
34
41
  for (let n = 1;; n++) {
35
- const candidate = `account-${n}`;
42
+ const candidate = `${prefix}-${n}`;
36
43
  if (!taken.has(candidate))
37
44
  return candidate; // taken is finite → always terminates
38
45
  }
@@ -97,6 +104,58 @@ async function doCompleteLogin(alias, rawCode, now, deps, remote) {
97
104
  return { ok: false, status: 400, error: message };
98
105
  }
99
106
  }
107
+ async function doStartCodexLogin(alias, now, deps, remote) {
108
+ if (!pendingCodexLogins.has(alias) && pendingCodexLogins.size >= MAX_PENDING) {
109
+ return { ok: false, status: 429, error: 'too many pending logins; complete or wait for one to expire' };
110
+ }
111
+ if ((await listCodexAccountAliases()).includes(alias)) {
112
+ return { ok: false, status: 409, error: `codex account "${alias}" already exists — DELETE /admin/codex/accounts/${alias} first` };
113
+ }
114
+ try {
115
+ const { authorizeUrl, codeVerifier, state } = await startAddCodexAccount(alias);
116
+ const expiresAt = now + PENDING_TTL_MS;
117
+ pendingCodexLogins.set(alias, { codeVerifier, state, expiresAt });
118
+ deps.audit?.({ action: 'login_start', ok: true, status: 200, alias, remote, engine: 'codex' });
119
+ return { ok: true, alias, authorizeUrl, expiresAt };
120
+ }
121
+ catch (err) {
122
+ return { ok: false, status: 400, error: err.message };
123
+ }
124
+ }
125
+ async function doCompleteCodexLogin(alias, rawCode, now, deps, remote) {
126
+ if (!alias || !rawCode)
127
+ return { ok: false, status: 400, error: 'missing "alias" or "code"' };
128
+ const p = pendingCodexLogins.get(alias);
129
+ if (!p || p.expiresAt <= now) {
130
+ pendingCodexLogins.delete(alias);
131
+ return { ok: false, status: 410, error: 'no pending codex login for that alias (unknown or expired) — start a new login' };
132
+ }
133
+ // The whole redirect URL (what the CLI asks the user to paste) or a bare code; the
134
+ // state in a URL is checked against the login it was printed for, as the CLI does.
135
+ const { code, state: pastedState } = parseCodexManualPaste(rawCode);
136
+ if (!code)
137
+ return { ok: false, status: 400, error: 'no authorization code found in "code" (paste the whole redirect URL)' };
138
+ if (pastedState !== null && pastedState !== p.state) {
139
+ return { ok: false, status: 400, error: 'state mismatch — the redirect is from a different login attempt' };
140
+ }
141
+ pendingCodexLogins.delete(alias); // single-use, regardless of exchange outcome
142
+ try {
143
+ const creds = await completeAddCodexAccount(alias, code, p.codeVerifier);
144
+ await deps.onCodexAccountsChanged?.();
145
+ deps.audit?.({ action: 'login_complete', ok: true, status: 200, alias: creds.alias, remote, engine: 'codex' });
146
+ return { ok: true, alias: creds.alias, expiresAt: creds.expiresAt };
147
+ }
148
+ catch (err) {
149
+ deps.audit?.({ action: 'login_complete', ok: false, status: 400, alias, remote, engine: 'codex' });
150
+ return { ok: false, status: 400, error: err.message };
151
+ }
152
+ }
153
+ async function listCodexAccountRecords() {
154
+ const all = await loadAllCodexAccounts();
155
+ return all
156
+ .map((a) => ({ alias: a.alias, expiresAt: a.expiresAt, needsRefresh: codexAccountNeedsRefresh(a) }))
157
+ .sort((x, y) => x.alias.localeCompare(y.alias));
158
+ }
100
159
  /** On-disk account inventory — the default `AdminDeps.listAccounts`. */
101
160
  async function defaultListAccounts() {
102
161
  const aliases = await listAccountAliases();
@@ -177,6 +236,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
177
236
  const method = req.method ?? 'GET';
178
237
  const remote = req.socket?.remoteAddress;
179
238
  const isAccountDelete = method === 'DELETE' && urlPath.startsWith(ACCOUNTS_PREFIX) && urlPath.length > ACCOUNTS_PREFIX.length;
239
+ const isCodexAccountDelete = method === 'DELETE' && urlPath.startsWith(CODEX_ACCOUNTS_PREFIX) && urlPath.length > CODEX_ACCOUNTS_PREFIX.length;
180
240
  // Named keys (dario#1318): `/admin/keys`, `/admin/keys/<name>`,
181
241
  // `/admin/keys/<name>/rotate`. The name is validated after auth so a
182
242
  // malformed one is a 400 to a caller who holds the token, not a route miss.
@@ -191,6 +251,10 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
191
251
  urlPath === '/admin/login/complete' ||
192
252
  urlPath === '/admin/accounts' ||
193
253
  urlPath === '/admin/keys' ||
254
+ urlPath === '/admin/codex/login/start' ||
255
+ urlPath === '/admin/codex/login/complete' ||
256
+ urlPath === '/admin/codex/accounts' ||
257
+ isCodexAccountDelete ||
194
258
  isAccountDelete ||
195
259
  isKeyRotate ||
196
260
  isKeyRevoke;
@@ -224,6 +288,7 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
224
288
  // blanket pre-parse token here would let a single HTTP request move N
225
289
  // accounts' credentials for the price of one throttle token.
226
290
  const isMutation = urlPath === '/admin/login/start' || isAccountDelete
291
+ || urlPath === '/admin/codex/login/start' || isCodexAccountDelete
227
292
  || (urlPath === '/admin/keys' && method === 'POST') || isKeyRotate || isKeyRevoke;
228
293
  if (isMutation) {
229
294
  const wait = deps.rateLimit?.('mutation') ?? 0;
@@ -264,6 +329,80 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
264
329
  });
265
330
  return true;
266
331
  }
332
+ // POST /admin/codex/login/start { alias? } — a ChatGPT (altman) seat, headless (dario#1009)
333
+ if (urlPath === '/admin/codex/login/start') {
334
+ if (method !== 'POST') {
335
+ send(res, 405, { error: 'Method not allowed (use POST)' });
336
+ return true;
337
+ }
338
+ const body = await readJsonBody(req);
339
+ let alias = typeof body.alias === 'string' ? body.alias.trim() : '';
340
+ if (!alias) {
341
+ const taken = new Set([...(await listCodexAccountAliases()), ...pendingCodexLogins.keys()]);
342
+ alias = nextDefaultAlias(taken, 'altman');
343
+ }
344
+ const result = await doStartCodexLogin(alias, now, deps, remote);
345
+ if (!result.ok) {
346
+ send(res, result.status, { error: result.error });
347
+ return true;
348
+ }
349
+ send(res, 200, {
350
+ alias: result.alias,
351
+ authorize_url: result.authorizeUrl,
352
+ expires_at: new Date(result.expiresAt).toISOString(),
353
+ instructions: `Open authorize_url and log in with the ChatGPT account. The browser lands on a localhost page that does not load — that is expected. POST { "alias": "${result.alias}", "code": "<the whole address bar of that page>" } to /admin/codex/login/complete.`,
354
+ });
355
+ return true;
356
+ }
357
+ // POST /admin/codex/login/complete { alias, code }
358
+ if (urlPath === '/admin/codex/login/complete') {
359
+ if (method !== 'POST') {
360
+ send(res, 405, { error: 'Method not allowed (use POST)' });
361
+ return true;
362
+ }
363
+ const body = await readJsonBody(req);
364
+ const alias = typeof body.alias === 'string' ? body.alias.trim() : '';
365
+ const rawCode = typeof body.code === 'string' ? body.code : '';
366
+ const wait = deps.rateLimit?.('mutation') ?? 0;
367
+ if (wait > 0) {
368
+ sendThrottled(res, wait, 'mutation', deps.audit, remote, alias || undefined);
369
+ return true;
370
+ }
371
+ const result = await doCompleteCodexLogin(alias, rawCode, now, deps, remote);
372
+ if (!result.ok) {
373
+ send(res, result.status, { error: result.error });
374
+ return true;
375
+ }
376
+ send(res, 200, { alias: result.alias, status: 'added', expires_at: new Date(result.expiresAt).toISOString() });
377
+ return true;
378
+ }
379
+ // GET /admin/codex/accounts
380
+ if (urlPath === '/admin/codex/accounts') {
381
+ if (method !== 'GET') {
382
+ send(res, 405, { error: 'Method not allowed (use GET)' });
383
+ return true;
384
+ }
385
+ const accounts = await listCodexAccountRecords();
386
+ send(res, 200, { accounts, count: accounts.length });
387
+ return true;
388
+ }
389
+ // DELETE /admin/codex/accounts/<alias>
390
+ if (isCodexAccountDelete) {
391
+ let alias;
392
+ try {
393
+ alias = decodeURIComponent(urlPath.slice(CODEX_ACCOUNTS_PREFIX.length));
394
+ }
395
+ catch {
396
+ send(res, 400, { error: 'malformed alias' });
397
+ return true;
398
+ }
399
+ const removed = await removeCodexAccount(alias);
400
+ if (removed)
401
+ await deps.onCodexAccountsChanged?.();
402
+ deps.audit?.({ action: 'account_remove', ok: removed, status: removed ? 200 : 404, alias, remote, engine: 'codex' });
403
+ send(res, removed ? 200 : 404, removed ? { alias, removed: true } : { error: `no codex account "${alias}"` });
404
+ return true;
405
+ }
267
406
  // POST /admin/login/start-needed { threshold? } (#913)
268
407
  // Bulk-starts a login for every live pool account whose consecutive auth
269
408
  // failures have crossed `threshold` (default NEEDS_LOGIN_THRESHOLD) — the
@@ -529,4 +668,5 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
529
668
  /** Test-only: clear the pending-login map between cases. */
530
669
  export function _resetAdminStateForTest() {
531
670
  pendingLogins.clear();
671
+ pendingCodexLogins.clear();
532
672
  }
@@ -7,7 +7,13 @@ export interface CodexAccountCredentials {
7
7
  }
8
8
  export declare function listCodexAccountAliases(): Promise<string[]>;
9
9
  export declare function hasAnyCodexAccount(nowMs?: number): Promise<boolean>;
10
- /** Drop the negative cache so a test doesn't have to sleep out its TTL. */
10
+ /**
11
+ * Drop the negative cache: a seat was just added or removed by something other
12
+ * than the CLI (the admin API, dario#1009), so the next request must ask the
13
+ * directory again instead of trusting a 30 s old "none".
14
+ */
15
+ export declare function resetCodexPresenceCache(): void;
16
+ /** Test alias — kept so existing tests need not change. */
11
17
  export declare function _resetCodexPresenceCacheForTest(): void;
12
18
  export declare function loadCodexAccount(alias: string): Promise<CodexAccountCredentials | null>;
13
19
  export declare function loadAllCodexAccounts(): Promise<CodexAccountCredentials[]>;
@@ -61,6 +67,15 @@ export declare function _resetCodexRefreshFailuresForTest(): void;
61
67
  * a misleading "run `dario login`" answer to the client.
62
68
  */
63
69
  export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
70
+ /**
71
+ * Refresh REGARDLESS of the clock, for the one caller that knows better than
72
+ * the clock does: the backend answered 401 on a token dario still believes in
73
+ * (dario#1338 shape, 2026-09-17 — a stored token valid for another day, every
74
+ * request on the seat rejected for six hours because nothing ever asked for a
75
+ * new one). Same single-flight and same failure cool-down as the clock path:
76
+ * a dead refresh token must not become a token-endpoint storm, one per request.
77
+ */
78
+ export declare function forceRefreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
64
79
  /** Record that `alias` declined, for as long as the upstream asked. */
65
80
  export declare function noteCodexDecline(alias: string, retryAfterMs?: number | null): number;
66
81
  /** A seat that just served is not rate-limited — clear it. */
@@ -69,10 +69,18 @@ export async function hasAnyCodexAccount(nowMs = Date.now()) {
69
69
  codexAbsentUntil = present ? 0 : nowMs + CODEX_PRESENCE_NEGATIVE_TTL_MS;
70
70
  return present;
71
71
  }
72
- /** Drop the negative cache so a test doesn't have to sleep out its TTL. */
73
- export function _resetCodexPresenceCacheForTest() {
72
+ /**
73
+ * Drop the negative cache: a seat was just added or removed by something other
74
+ * than the CLI (the admin API, dario#1009), so the next request must ask the
75
+ * directory again instead of trusting a 30 s old "none".
76
+ */
77
+ export function resetCodexPresenceCache() {
74
78
  codexAbsentUntil = 0;
75
79
  }
80
+ /** Test alias — kept so existing tests need not change. */
81
+ export function _resetCodexPresenceCacheForTest() {
82
+ resetCodexPresenceCache();
83
+ }
76
84
  export async function loadCodexAccount(alias) {
77
85
  const path = safeAliasPath(alias);
78
86
  if (!path)
@@ -271,6 +279,21 @@ export async function getFreshCodexAccount(creds) {
271
279
  noteRefreshRecovered(creds.alias);
272
280
  return creds;
273
281
  }
282
+ return refreshNow(creds);
283
+ }
284
+ /**
285
+ * Refresh REGARDLESS of the clock, for the one caller that knows better than
286
+ * the clock does: the backend answered 401 on a token dario still believes in
287
+ * (dario#1338 shape, 2026-09-17 — a stored token valid for another day, every
288
+ * request on the seat rejected for six hours because nothing ever asked for a
289
+ * new one). Same single-flight and same failure cool-down as the clock path:
290
+ * a dead refresh token must not become a token-endpoint storm, one per request.
291
+ */
292
+ export async function forceRefreshCodexAccount(creds) {
293
+ return refreshNow(creds);
294
+ }
295
+ /** The refresh itself: one in flight per alias, and a remembered failure short-circuits. */
296
+ async function refreshNow(creds) {
274
297
  const existing = inflightRefresh.get(creds.alias);
275
298
  if (existing)
276
299
  return existing;
@@ -278,6 +278,30 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
278
278
  * into a buffered response object is not built yet. A non-streaming client
279
279
  * gets a 400 saying so.
280
280
  */
281
+ /**
282
+ * Is this status the backend saying the CREDENTIAL is no good, rather than the
283
+ * request or the quota? 401 and 403 both arrive that way from the Responses
284
+ * API — a revoked session, a token rotated by another client, an account whose
285
+ * plan changed underneath us.
286
+ */
287
+ export declare function isCodexAuthFailure(status: number): boolean;
288
+ /**
289
+ * One forced refresh after an auth failure, then the caller retries once.
290
+ *
291
+ * `getFreshCodexAccount` refreshes on the CLOCK, so a token dario believes in
292
+ * is never re-fetched no matter how many times upstream rejects it. On
293
+ * 2026-09-17 that took the fleet's only fallback seat down for six hours: the
294
+ * stored token was valid until the next day, the backend answered 401 to every
295
+ * request, and each one was handed to the client unchanged — no refresh, no
296
+ * cool-down, no failover.
297
+ *
298
+ * Returns the fresh credentials when a retry is worth making, or null when it
299
+ * is not: no refresh token to spend, the refresh failed (its own cool-down then
300
+ * governs — the seat is reported unavailable and the chain moves on), or the
301
+ * token came back byte-identical, in which case retrying only reproduces the
302
+ * same 401.
303
+ */
304
+ export declare function refreshAfterCodexAuthFailure(creds: CodexAccountCredentials, verbose: boolean): Promise<CodexAccountCredentials | null>;
281
305
  export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void,
282
306
  /** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
283
307
  * caller needs to know which seat and for how long — without it the pool
@@ -22,6 +22,7 @@
22
22
  * translation in both directions, including SSE.
23
23
  */
24
24
  import { createHash } from 'node:crypto';
25
+ import { forceRefreshCodexAccount } from './codex-accounts.js';
25
26
  import { anthropicToResponsesRequest, anthropicUsageFromResponses, createResponsesSSEParser, formatResponsesAnthropicSSE, createAnthropicMessageAssembler, responsesStreamToAnthropicSSE, } from './anthropic-responses-translate.js';
26
27
  import { resolveClaudeTarget } from './claude-model.js';
27
28
  import { parseEffortSuffix } from './effort.js';
@@ -759,6 +760,48 @@ export function buildCodexHeaders(creds) {
759
760
  * into a buffered response object is not built yet. A non-streaming client
760
761
  * gets a 400 saying so.
761
762
  */
763
+ /**
764
+ * Is this status the backend saying the CREDENTIAL is no good, rather than the
765
+ * request or the quota? 401 and 403 both arrive that way from the Responses
766
+ * API — a revoked session, a token rotated by another client, an account whose
767
+ * plan changed underneath us.
768
+ */
769
+ export function isCodexAuthFailure(status) {
770
+ return status === 401 || status === 403;
771
+ }
772
+ /**
773
+ * One forced refresh after an auth failure, then the caller retries once.
774
+ *
775
+ * `getFreshCodexAccount` refreshes on the CLOCK, so a token dario believes in
776
+ * is never re-fetched no matter how many times upstream rejects it. On
777
+ * 2026-09-17 that took the fleet's only fallback seat down for six hours: the
778
+ * stored token was valid until the next day, the backend answered 401 to every
779
+ * request, and each one was handed to the client unchanged — no refresh, no
780
+ * cool-down, no failover.
781
+ *
782
+ * Returns the fresh credentials when a retry is worth making, or null when it
783
+ * is not: no refresh token to spend, the refresh failed (its own cool-down then
784
+ * governs — the seat is reported unavailable and the chain moves on), or the
785
+ * token came back byte-identical, in which case retrying only reproduces the
786
+ * same 401.
787
+ */
788
+ export async function refreshAfterCodexAuthFailure(creds, verbose) {
789
+ if (!creds.refreshToken)
790
+ return null;
791
+ try {
792
+ const fresh = await forceRefreshCodexAccount(creds);
793
+ if (fresh.accessToken === creds.accessToken)
794
+ return null;
795
+ if (verbose)
796
+ console.log(`[dario] codex account ${creds.alias}: upstream rejected a stored token — refreshed, retrying once`);
797
+ return fresh;
798
+ }
799
+ catch (err) {
800
+ console.warn(`[dario] codex account ${creds.alias}: upstream rejected its token and the refresh failed `
801
+ + `(${err instanceof Error ? err.message : String(err)}) — re-add the seat with \`dario add altman ${creds.alias}\``);
802
+ return null;
803
+ }
804
+ }
762
805
  export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone,
763
806
  /** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
764
807
  * caller needs to know which seat and for how long — without it the pool
@@ -808,14 +851,26 @@ deferOnUnavailable = false) {
808
851
  try {
809
852
  if (verbose)
810
853
  console.log(`[dario] → codex backend (responses passthrough): ${target} (model: ${model})`);
811
- const upstream = await fetchImpl(target, { method: 'POST', headers: buildCodexHeaders(creds), body: JSON.stringify(upstreamBody), signal: abort.signal });
854
+ let activeCreds = creds;
855
+ let upstream = await fetchImpl(target, { method: 'POST', headers: buildCodexHeaders(activeCreds), body: JSON.stringify(upstreamBody), signal: abort.signal });
856
+ if (isCodexAuthFailure(upstream.status)) {
857
+ await upstream.text().catch(() => ''); // release the rejected response before retrying
858
+ const fresh = await refreshAfterCodexAuthFailure(activeCreds, verbose);
859
+ if (fresh) {
860
+ activeCreds = fresh;
861
+ upstream = await fetchImpl(target, { method: 'POST', headers: buildCodexHeaders(activeCreds), body: JSON.stringify(upstreamBody), signal: abort.signal });
862
+ }
863
+ }
812
864
  if (!upstream.ok || !upstream.body) {
813
865
  const detail = await upstream.text().catch(() => '');
814
866
  if (verbose)
815
867
  console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
816
868
  // Same rule as the Messages path: a 429 or a 5xx is the seat declining,
817
- // and that is true whether or not anything is waiting to take over.
818
- const unavailable = upstream.status === 429 || upstream.status >= 500;
869
+ // and that is true whether or not anything is waiting to take over. An
870
+ // auth failure that survived the forced refresh above joins them: the
871
+ // seat cannot serve until someone re-adds it, so cool it and let the
872
+ // chain move on instead of handing the client a 401 it cannot act on.
873
+ const unavailable = upstream.status === 429 || upstream.status >= 500 || isCodexAuthFailure(upstream.status);
819
874
  if (unavailable) {
820
875
  try {
821
876
  onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
@@ -1050,12 +1105,28 @@ midstream) {
1050
1105
  try {
1051
1106
  if (verbose)
1052
1107
  console.log(`[dario] → codex backend: ${target} (model: ${model})`);
1053
- const upstream = await fetchImpl(target, {
1108
+ let activeCreds = creds;
1109
+ let upstream = await fetchImpl(target, {
1054
1110
  method: 'POST',
1055
- headers: buildCodexHeaders(creds),
1111
+ headers: buildCodexHeaders(activeCreds),
1056
1112
  body: JSON.stringify(scrubbed),
1057
1113
  signal: abort.signal,
1058
1114
  });
1115
+ // An auth failure on a token the clock still trusts: refresh it once and
1116
+ // ask again, before any of the decline/report machinery below runs.
1117
+ if (isCodexAuthFailure(upstream.status)) {
1118
+ await upstream.text().catch(() => ''); // release the rejected response before retrying
1119
+ const fresh = await refreshAfterCodexAuthFailure(activeCreds, verbose);
1120
+ if (fresh) {
1121
+ activeCreds = fresh;
1122
+ upstream = await fetchImpl(target, {
1123
+ method: 'POST',
1124
+ headers: buildCodexHeaders(activeCreds),
1125
+ body: JSON.stringify(scrubbed),
1126
+ signal: abort.signal,
1127
+ });
1128
+ }
1129
+ }
1059
1130
  if (!upstream.ok) {
1060
1131
  const detail = await upstream.text().catch(() => '');
1061
1132
  if (verbose)
@@ -1073,7 +1144,12 @@ midstream) {
1073
1144
  // cue to fail over, not something to hand the client. A 4xx that is our
1074
1145
  // own fault (a bad body, an unsupported parameter) is NOT: failing over
1075
1146
  // would just reproduce it somewhere else and hide the real error.
1076
- const unavailable = upstream.status === 429 || upstream.status >= 500;
1147
+ // An auth failure that survived the forced refresh above counts as the
1148
+ // seat declining, not as the client's error: nothing the caller sends
1149
+ // will fix a revoked token, and a 401 relayed to Claude Code reads as an
1150
+ // outage (2026-09-17, six hours of it). Cooling it also stops selection
1151
+ // from handing the same dead seat the next request.
1152
+ const unavailable = upstream.status === 429 || upstream.status >= 500 || isCodexAuthFailure(upstream.status);
1077
1153
  // The seat said no, and that is true whether or not a fallback exists
1078
1154
  // to defer to. Recording it outside the defer branch is what lets the
1079
1155
  // POOL rotate on a deployment with no --pool-fallback configured: with
package/dist/ledger.d.ts CHANGED
@@ -62,6 +62,11 @@ export interface LedgerConsumerSummary {
62
62
  requests: number;
63
63
  apiEquivalentCost: number;
64
64
  meteredCost: number;
65
+ /** Lifetime tokens, both buckets — what the cost is made of (dario#1318: "100k output can't be $24"; it was the input and cache-write side). */
66
+ inputTokens: number;
67
+ outputTokens: number;
68
+ cacheReadTokens: number;
69
+ cacheCreateTokens: number;
65
70
  recent: {
66
71
  today: number;
67
72
  last7d: number;
@@ -150,6 +155,8 @@ export declare function addToLedger(file: LedgerFile, record: RequestRecord): bo
150
155
  export declare function pruneLedger(file: LedgerFile, maxDays?: number): void;
151
156
  /** The per-consumer split of a file, priced the same way as the headline. */
152
157
  export declare function summarizeLedgerConsumers(file: LedgerFile, now?: number): Record<string, LedgerConsumerSummary>;
158
+ /** `1234` → `1.2k`, `1234567` → `1.2M`; below a thousand, the number itself. */
159
+ export declare function formatTokenCount(n: number): string;
153
160
  export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
154
161
  /**
155
162
  * Read a ledger file for display without a running proxy (`dario usage`
package/dist/ledger.js CHANGED
@@ -198,7 +198,7 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
198
198
  for (const [day, byConsumer] of Object.entries(file.consumers ?? {})) {
199
199
  const at = dayMs(day);
200
200
  for (const [consumer, models] of Object.entries(byConsumer)) {
201
- const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
201
+ const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
202
202
  if (day > c.lastDay)
203
203
  c.lastDay = day;
204
204
  for (const [model, row] of Object.entries(models)) {
@@ -206,6 +206,7 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
206
206
  const cost = costOfTokens(model, at, row.covered);
207
207
  c.apiEquivalentCost += cost;
208
208
  c.requests += row.covered.requests;
209
+ addTokens(c, row.covered);
209
210
  c._models[model] = (c._models[model] ?? 0) + row.covered.requests;
210
211
  if (day === today)
211
212
  c.recent.today += cost;
@@ -217,6 +218,7 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
217
218
  if (row.metered) {
218
219
  c.meteredCost += costOfTokens(model, at, row.metered);
219
220
  c.requests += row.metered.requests;
221
+ addTokens(c, row.metered);
220
222
  c._models[model] = (c._models[model] ?? 0) + row.metered.requests;
221
223
  }
222
224
  }
@@ -228,6 +230,10 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
228
230
  requests: c.requests,
229
231
  apiEquivalentCost: round(c.apiEquivalentCost),
230
232
  meteredCost: round(c.meteredCost),
233
+ inputTokens: c.inputTokens,
234
+ outputTokens: c.outputTokens,
235
+ cacheReadTokens: c.cacheReadTokens,
236
+ cacheCreateTokens: c.cacheCreateTokens,
231
237
  recent: { today: round(c.recent.today), last7d: round(c.recent.last7d), last30d: round(c.recent.last30d) },
232
238
  lastDay: c.lastDay,
233
239
  models: Object.entries(c._models).sort((a, b) => b[1] - a[1]).map(([m]) => m),
@@ -235,6 +241,28 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
235
241
  }
236
242
  return result;
237
243
  }
244
+ function addTokens(into, cell) {
245
+ into.inputTokens += cell.inputTokens;
246
+ into.outputTokens += cell.outputTokens;
247
+ into.cacheReadTokens += cell.cacheReadTokens;
248
+ into.cacheCreateTokens += cell.cacheCreateTokens;
249
+ }
250
+ // Round BEFORE choosing the unit. Picking the unit on the raw value and
251
+ // rounding afterwards let 999_600 print as "1000k": once the rounded
252
+ // thousands reach the next unit, the number belongs in that unit.
253
+ function scaleTokenCount(value, unit) {
254
+ const oneDecimal = Math.round(value * 10) / 10;
255
+ return oneDecimal >= 10 ? `${Math.round(value)}${unit}` : `${oneDecimal.toFixed(1)}${unit}`;
256
+ }
257
+ /** `1234` → `1.2k`, `1234567` → `1.2M`; below a thousand, the number itself. */
258
+ export function formatTokenCount(n) {
259
+ if (n < 1_000)
260
+ return String(n);
261
+ const thousands = n / 1_000;
262
+ if (thousands >= 1_000 || Math.round(thousands) >= 1_000)
263
+ return scaleTokenCount(n / 1_000_000, 'M');
264
+ return scaleTokenCount(thousands, 'k');
265
+ }
238
266
  // Six places, not the window's four: a handful of gpt-5.6-luna requests is
239
267
  // real money in the millionths and "$0 for 2 requests" reads as free.
240
268
  const round = (usd) => Math.round(usd * 1_000_000) / 1_000_000;
@@ -475,11 +503,12 @@ export function formatLedgerConsumers(s, limit = 20) {
475
503
  if (entries.length === 0)
476
504
  return [' By key: no request named a consumer yet (create keys with `dario keys create <name>`).'];
477
505
  const lines = [];
478
- lines.push(` By key (${entries.length} consumer${entries.length === 1 ? '' : 's'}; API-equivalent, lifetime · today · 7d · 30d):`);
506
+ lines.push(` By key (${entries.length} consumer${entries.length === 1 ? '' : 's'}; API-equivalent, lifetime · today · 7d · 30d; then the tokens behind the lifetime number):`);
479
507
  const width = Math.min(24, Math.max(...entries.map(([c]) => c.length)));
480
508
  for (const [consumer, c] of entries.slice(0, limit)) {
481
509
  const models = c.models.slice(0, 2).map(shortModelName).join(', ');
482
510
  lines.push(` ${consumer.slice(0, width).padEnd(width)} ${formatUsd(c.apiEquivalentCost).padStart(9)} · ${formatUsd(c.recent.today).padStart(8)} · ${formatUsd(c.recent.last7d).padStart(8)} · ${formatUsd(c.recent.last30d).padStart(8)} ${c.requests.toLocaleString('en-US')} req${c.requests === 1 ? '' : 's'}${models ? `, ${models}` : ''}${c.meteredCost > 0 ? `, ${formatUsd(c.meteredCost)} metered` : ''}`);
511
+ lines.push(` ${' '.repeat(width)} in ${formatTokenCount(c.inputTokens)} · out ${formatTokenCount(c.outputTokens)} · cache read ${formatTokenCount(c.cacheReadTokens)} · cache write ${formatTokenCount(c.cacheCreateTokens)}`);
483
512
  }
484
513
  if (entries.length > limit)
485
514
  lines.push(` … and ${entries.length - limit} more`);
@@ -496,7 +496,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
496
496
  */
497
497
  export declare const SUPPORTED_CC_RANGE: {
498
498
  readonly min: "1.0.0";
499
- readonly maxTested: "2.1.273";
499
+ readonly maxTested: "2.1.275";
500
500
  };
501
501
  /**
502
502
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -1194,7 +1194,7 @@ export function detectDrift(t, installedOverride) {
1194
1194
  */
1195
1195
  export const SUPPORTED_CC_RANGE = {
1196
1196
  min: '1.0.0',
1197
- maxTested: '2.1.273',
1197
+ maxTested: '2.1.275',
1198
1198
  };
1199
1199
  /**
1200
1200
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/dist/proxy.js CHANGED
@@ -45,7 +45,7 @@ import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequest
45
45
  import { isClaudeServableModel } from './claude-model.js';
46
46
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
47
47
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
48
- import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
48
+ import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, getCodexRefreshFailure, CodexCredentialsUnavailableError, resetCodexPresenceCache } from './codex-accounts.js';
49
49
  import { route as routeProvider } from './provider-adapter.js';
50
50
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
51
51
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
@@ -2394,6 +2394,13 @@ export async function startProxy(opts = {}) {
2394
2394
  // through the store the live proxy authenticates from, so a key made
2395
2395
  // here works on the next request.
2396
2396
  keys: keyStore,
2397
+ onCodexAccountsChanged: async () => {
2398
+ // A ChatGPT seat came or went over HTTP (dario#1009): forget the
2399
+ // "no codex account" answer so the next request routes to it.
2400
+ resetCodexPresenceCache();
2401
+ if (verbose)
2402
+ console.log('[dario] admin: codex accounts changed — re-read on the next request');
2403
+ },
2397
2404
  onAccountsChanged: async () => {
2398
2405
  // Hot-reload the live pool from disk so accounts added / removed via
2399
2406
  // the admin API take effect immediately — no proxy restart (#599).
package/docs/admin-api.md CHANGED
@@ -258,3 +258,16 @@ one-liner for interactive setups and is never required on the admin path. See
258
258
  [`docs/multi-account-pool.md`](./multi-account-pool.md) for how the pool
259
259
  routes, and [`docs/docker.md`](./docker.md) for the container deployment this
260
260
  API was built for.
261
+
262
+ ## A ChatGPT (altman) seat, headless
263
+
264
+ The same flow for a ChatGPT Plus/Pro seat, for a proxy that never sees a terminal — a k8s pod, a CI runner (dario#1009). `dario add altman` needs someone at a prompt; these four do not. The browser lands on a `localhost` page that does not load, which is expected: the whole address bar of that page is the code.
265
+
266
+ | Method + path | Body | Returns |
267
+ |---|---|---|
268
+ | `POST /admin/codex/login/start` | `{ "alias"?: string }` | `{ alias, authorize_url, expires_at, instructions }` — default alias `altman-1`, `altman-2`, … ; `409` if the alias already holds a seat |
269
+ | `POST /admin/codex/login/complete` | `{ "alias": string, "code": string }` — the redirect URL, or the bare code | `{ alias, status: "added", expires_at }` |
270
+ | `GET /admin/codex/accounts` | — | `{ accounts: [{ alias, expiresAt, needsRefresh }], count }` |
271
+ | `DELETE /admin/codex/accounts/<alias>` | — | `{ alias, removed }` (`404` if no such alias) |
272
+
273
+ A running proxy serves the new seat on its next request; nothing restarts. Same token, same rate limits, same audit log — codex events carry `engine: "codex"`. The seat is stored where the CLI stores it (`~/.dario/codex-accounts/<alias>.json`), so `dario codex list` and `dario codex remove` see it too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.8.7",
3
+ "version": "6.8.9",
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": {