@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.
- package/dist/admin-api.d.ts +17 -0
- package/dist/admin-api.js +145 -5
- package/dist/codex-accounts.d.ts +16 -1
- package/dist/codex-accounts.js +25 -2
- package/dist/codex-backend.d.ts +24 -0
- package/dist/codex-backend.js +82 -6
- package/dist/ledger.d.ts +7 -0
- package/dist/ledger.js +31 -2
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/proxy.js +8 -1
- package/docs/admin-api.md +13 -0
- package/package.json +1 -1
package/dist/admin-api.d.ts
CHANGED
|
@@ -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 [
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/codex-accounts.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|
package/dist/codex-accounts.js
CHANGED
|
@@ -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
|
-
/**
|
|
73
|
-
|
|
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;
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -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
|
package/dist/codex-backend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1108
|
+
let activeCreds = creds;
|
|
1109
|
+
let upstream = await fetchImpl(target, {
|
|
1054
1110
|
method: 'POST',
|
|
1055
|
-
headers: buildCodexHeaders(
|
|
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
|
-
|
|
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.
|
|
499
|
+
readonly maxTested: "2.1.275";
|
|
500
500
|
};
|
|
501
501
|
/**
|
|
502
502
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/live-fingerprint.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|