@askalf/dario 6.0.0 → 6.0.1

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.
@@ -63,6 +63,8 @@ export declare function billingBucketFromClaim(claim: string | null | undefined)
63
63
  * non-subscription billing classification or the `unknown` sentinel below.
64
64
  */
65
65
  export declare const SUBSCRIPTION_CLAIMS: ReadonlySet<string>;
66
+ /** The claim the proxy stamps on codex-engine requests (see above). */
67
+ export declare const CODEX_CLAIM = "chatgpt_subscription";
66
68
  /**
67
69
  * One-line per-request usage summary for verbose (-v / -vv) logs.
68
70
  *
package/dist/analytics.js CHANGED
@@ -35,6 +35,7 @@ export function billingBucketFromClaim(claim) {
35
35
  // (30-min cooldown loops) exactly when the weekly window tightens.
36
36
  case 'five_hour_overage_included':
37
37
  case 'seven_day_overage_included':
38
+ case 'chatgpt_subscription':
38
39
  return 'subscription';
39
40
  case 'five_hour_fallback':
40
41
  case 'seven_day_fallback':
@@ -61,7 +62,15 @@ export const SUBSCRIPTION_CLAIMS = new Set([
61
62
  'seven_day_fallback',
62
63
  'five_hour_overage_included',
63
64
  'seven_day_overage_included',
65
+ // The codex engine: a request served from a ChatGPT-subscription account
66
+ // (dario#1009). There is no Anthropic claim header on that path; the proxy
67
+ // stamps this one. It is subscription billing — the user's ChatGPT plan —
68
+ // so it must be recognised here, or the overage guard reads it as
69
+ // pay-as-you-go and halts the proxy after the first GPT request.
70
+ 'chatgpt_subscription',
64
71
  ]);
72
+ /** The claim the proxy stamps on codex-engine requests (see above). */
73
+ export const CODEX_CLAIM = 'chatgpt_subscription';
65
74
  /**
66
75
  * One-line per-request usage summary for verbose (-v / -vv) logs.
67
76
  *
@@ -46,6 +46,22 @@ export declare function fetchCodexModels(creds: CodexAccountCredentials, fetchIm
46
46
  * "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
47
47
  * routes, so discovery being down never makes the engine unusable.
48
48
  */
49
+ /** What the proxy learns from one forwarded codex request — enough for an
50
+ * analytics row and a log line. Reported once per request, on every exit
51
+ * that answered the client; a DECLINE (deferred to the Claude pool) reports
52
+ * nothing, since the Claude path records what it then serves. */
53
+ export interface CodexForwardOutcome {
54
+ status: number;
55
+ latencyMs: number;
56
+ inputTokens: number;
57
+ outputTokens: number;
58
+ stream: boolean;
59
+ model: string;
60
+ alias: string;
61
+ }
62
+ /** The cached slug list for an alias WITHOUT fetching. For the admin surface:
63
+ * a status read must never cost an upstream call or a token refresh. */
64
+ export declare function peekCodexModelSlugs(alias: string): readonly string[] | null;
49
65
  export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetchImpl?: typeof fetch): Promise<readonly string[]>;
50
66
  /**
51
67
  * Whether a request naming `model` should be served from the subscription: the
@@ -125,6 +141,14 @@ export declare function createResponsesTranslator(model: string): {
125
141
  chunk(line: string): string | null;
126
142
  /** True when the upstream stream terminated as a FAILURE. */
127
143
  didFail(): boolean;
144
+ /** Token usage from the terminal event, or null if none arrived. Read by
145
+ * the proxy to record the request in analytics — before this, codex
146
+ * requests were invisible to /analytics and the request log entirely. */
147
+ usage(): {
148
+ prompt_tokens: number;
149
+ completion_tokens: number;
150
+ total_tokens: number;
151
+ } | null;
128
152
  /** Everything seen so far, as one non-streaming chat.completion body. */
129
153
  complete(): Record<string, unknown>;
130
154
  };
@@ -179,4 +203,4 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
179
203
  * testable without network (test/codex-backend.mjs), matching the pattern
180
204
  * test/codex-oauth.mjs already uses.
181
205
  */
182
- export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, shape?: CodexRequestShape, fetchImpl?: typeof fetch, deferOnUnavailable?: boolean): Promise<boolean>;
206
+ export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, shape?: CodexRequestShape, fetchImpl?: typeof fetch, deferOnUnavailable?: boolean, onDone?: (outcome: CodexForwardOutcome) => void): Promise<boolean>;
@@ -59,12 +59,12 @@ export async function fetchCodexModels(creds, fetchImpl = fetch) {
59
59
  }
60
60
  return slugs;
61
61
  }
62
- /**
63
- * Cached {@link fetchCodexModels}, keyed by account alias. Never throws an
64
- * unreachable backend yields the last known set, or an empty one, which means
65
- * "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
66
- * routes, so discovery being down never makes the engine unusable.
67
- */
62
+ /** The cached slug list for an alias WITHOUT fetching. For the admin surface:
63
+ * a status read must never cost an upstream call or a token refresh. */
64
+ export function peekCodexModelSlugs(alias) {
65
+ const hit = modelCache.get(alias);
66
+ return hit ? hit.slugs : null;
67
+ }
68
68
  export async function getCodexModelSlugs(creds, fetchImpl = fetch) {
69
69
  const hit = modelCache.get(creds.alias);
70
70
  if (hit && Date.now() - hit.fetchedAt < hit.ttlMs)
@@ -386,6 +386,12 @@ export function createResponsesTranslator(model) {
386
386
  didFail() {
387
387
  return failed;
388
388
  },
389
+ /** Token usage from the terminal event, or null if none arrived. Read by
390
+ * the proxy to record the request in analytics — before this, codex
391
+ * requests were invisible to /analytics and the request log entirely. */
392
+ usage() {
393
+ return usage;
394
+ },
389
395
  /** Everything seen so far, as one non-streaming chat.completion body. */
390
396
  complete() {
391
397
  const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
@@ -481,9 +487,23 @@ export function buildCodexHeaders(creds) {
481
487
  * testable without network (test/codex-backend.mjs), matching the pattern
482
488
  * test/codex-oauth.mjs already uses.
483
489
  */
484
- export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false) {
490
+ export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false, onDone) {
485
491
  void req;
486
492
  const isAnthropic = shape === 'anthropic';
493
+ // Reported exactly once, on every exit that answered the client. Without
494
+ // this the proxy had no idea a codex request happened: no analytics row, no
495
+ // log line, no per-account count.
496
+ const startedAt = Date.now();
497
+ let reported = false;
498
+ const report = (status, usage, stream, model) => {
499
+ if (reported || !onDone)
500
+ return;
501
+ reported = true;
502
+ try {
503
+ onDone({ status, latencyMs: Date.now() - startedAt, inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0, stream, model, alias: creds.alias });
504
+ }
505
+ catch { /* a reporting failure must never break a served request */ }
506
+ };
487
507
  // An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
488
508
  // is {error}. A client SDK reads its own shape, so errors follow the request.
489
509
  const errBody = (message, extra = {}) => JSON.stringify(isAnthropic
@@ -496,6 +516,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
496
516
  catch {
497
517
  res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
498
518
  res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
519
+ report(400, null, false, '');
499
520
  return true;
500
521
  }
501
522
  const clientWantsStream = parsed.stream === true;
@@ -532,6 +553,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
532
553
  }
533
554
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
534
555
  res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
556
+ report(upstream.status, null, clientWantsStream, model);
535
557
  return true;
536
558
  }
537
559
  // OpenAI shape: one stateful line-in/line-out translator (unchanged).
@@ -625,6 +647,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
625
647
  console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
626
648
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
627
649
  res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
650
+ report(502, null, clientWantsStream, model);
628
651
  return true;
629
652
  }
630
653
  if (isAnthropic) {
@@ -654,6 +677,16 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
654
677
  });
655
678
  res.end(JSON.stringify(translator.complete()));
656
679
  }
680
+ // Token usage rides the terminal Responses event on either shape. A stream
681
+ // that failed upstream still ended as 200 on the wire (the client saw the
682
+ // failure event); analytics must count it as the 502 it was.
683
+ {
684
+ const tr = terminalResponse;
685
+ const usage = isAnthropic
686
+ ? (tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null)
687
+ : (() => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; })();
688
+ report(upstreamFailed ? 502 : 200, usage, clientWantsStream, model);
689
+ }
657
690
  return true;
658
691
  }
659
692
  catch (err) {
@@ -685,6 +718,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
685
718
  }
686
719
  catch { /* already closed */ }
687
720
  }
721
+ report(502, null, false, '');
688
722
  return true;
689
723
  }
690
724
  finally {
package/dist/proxy.js CHANGED
@@ -13,16 +13,16 @@ import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuf
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
15
15
  import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
16
- import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS } from './analytics.js';
16
+ import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
17
17
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
18
18
  import { notify as osNotify } from './notify.js';
19
19
  import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
20
20
  import { handleAdminRequest } from './admin-api.js';
21
21
  import { createTokenBucket } from './rate-limit.js';
22
22
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
23
- import { forwardToCodex, getCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
23
+ import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
24
24
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
25
- import { listCodexAccountAliases, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
25
+ import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
26
26
  import { route as routeProvider } from './provider-adapter.js';
27
27
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
28
28
  import { redactSecrets } from './redact.js';
@@ -1324,6 +1324,9 @@ export async function startProxy(opts = {}) {
1324
1324
  // : null` — that gated the /analytics endpoint, but burn-rate /
1325
1325
  // per-request visibility is useful for a pool of one too.
1326
1326
  const analytics = new Analytics();
1327
+ // Per-alias request counts for GET /codex — the pool has requestCount per
1328
+ // account; the codex accounts had nothing until now.
1329
+ const codexRequestCounts = new Map();
1327
1330
  // Overage-guard (v4.1, dario#288). Resolved from opts with built-in
1328
1331
  // defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
1329
1332
  // so an opts-less proxy still gets protection. The notifier is wired
@@ -2105,6 +2108,29 @@ export async function startProxy(opts = {}) {
2105
2108
  }
2106
2109
  // Analytics endpoint — rolling-window summary + burn-rate snapshot.
2107
2110
  // Always-on as of v4 (pre-v4 this was gated to pool mode).
2111
+ // The codex engine, for the admin surface. Key-gated like /accounts. Reads
2112
+ // only what is already on disk and in the model cache: no upstream call,
2113
+ // no token refresh, and no token in the answer — a status read must never
2114
+ // spend or expose a credential.
2115
+ if (urlPath === '/codex' && req.method === 'GET') {
2116
+ const now = Date.now();
2117
+ const stored = await loadAllCodexAccounts();
2118
+ const accounts = stored.map((a) => ({
2119
+ alias: a.alias,
2120
+ expiresAt: a.expiresAt,
2121
+ expiresInMs: Math.max(0, a.expiresAt - now),
2122
+ needsRefresh: codexAccountNeedsRefresh(a),
2123
+ models: peekCodexModelSlugs(a.alias) ?? [],
2124
+ requestCount: codexRequestCounts.get(a.alias) ?? 0,
2125
+ }));
2126
+ res.writeHead(200, JSON_HEADERS);
2127
+ res.end(JSON.stringify({
2128
+ backend: CODEX_BACKEND_BASE_URL,
2129
+ requests: [...codexRequestCounts.values()].reduce((n, c) => n + c, 0),
2130
+ accounts,
2131
+ }));
2132
+ return;
2133
+ }
2108
2134
  if (urlPath === '/analytics' && req.method === 'GET') {
2109
2135
  res.writeHead(200, JSON_HEADERS);
2110
2136
  // `queue` rides along the summary (dario#905): request-queue.ts always
@@ -2682,7 +2708,35 @@ export async function startProxy(opts = {}) {
2682
2708
  // request even with an idle Claude pool sitting right beside it.
2683
2709
  const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels);
2684
2710
  const canDefer = claudeTarget !== null && pool.size > 0 && !upstreamApiKey;
2685
- const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer);
2711
+ const codexReq = requestCount;
2712
+ const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
2713
+ // Before this hook a codex request left no trace: nothing in
2714
+ // /analytics, nothing in the request log, no per-account count.
2715
+ // The dock (and anyone reading /analytics) saw a proxy that
2716
+ // served GPT all day and reported zero of it. A decline (the
2717
+ // request handed to the Claude pool) reports nothing here; the
2718
+ // Claude path records what it then serves.
2719
+ (o) => {
2720
+ codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
2721
+ analytics.record({
2722
+ timestamp: Date.now(),
2723
+ account: o.alias,
2724
+ model: o.model || rawModel || 'codex',
2725
+ inputTokens: o.inputTokens, outputTokens: o.outputTokens,
2726
+ cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
2727
+ // No Anthropic rate-limit headers on this path; the claim
2728
+ // names the engine and is subscription billing, so the
2729
+ // overage guard (#288) leaves it alone.
2730
+ claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
2731
+ latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
2732
+ });
2733
+ writeLogLine(logFileStream, {
2734
+ ts: new Date().toISOString(), req: codexReq,
2735
+ method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
2736
+ status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
2737
+ claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
2738
+ });
2739
+ });
2686
2740
  if (served)
2687
2741
  return;
2688
2742
  const swapped = buildPoolFallbackBody(body, claudeTarget);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {