@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.
- package/dist/admin-api.d.ts +17 -0
- package/dist/admin-api.js +145 -5
- package/dist/codex-accounts.d.ts +7 -1
- package/dist/codex-accounts.js +10 -2
- 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[]>;
|
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)
|
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.274";
|
|
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.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.
|
|
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": {
|