@askalf/dario 6.8.7 → 6.8.8

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[]>;
@@ -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)
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.274";
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.274',
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.8",
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": {