@askalf/dario 6.0.0 → 6.0.2

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/README.md CHANGED
@@ -177,7 +177,7 @@ That is a **chain**, read left to right, and each provider takes the first entry
177
177
 
178
178
  A single-entry chain is one-way and means what it always meant, so an existing config is unaffected. Failover is entirely opt-in: without `--pool-fallback`, a drained pool still returns its honest 429/503.
179
179
 
180
- The Claude entry must be a real `claude-*` model id. "Not a GPT model" is not the same as "the pool can serve it", and swapping in a typo would trade a recoverable 429 for an unrecoverable 404 — so an entry that doesn't look like an Anthropic model is ignored and the error surfaces honestly.
180
+ The Claude entry has to be a model the pool can actually serve. "Not a GPT model" is not the same thing, and swapping in a typo would trade a recoverable 429 for an unrecoverable 404 — so each entry is checked positively against the live model catalog and skipped if it fails, letting the real error surface. Canonical ids (`claude-sonnet-5`), long-context variants (`claude-sonnet-5[1m]`), catalog shorthands (`opus`, `sonnet1m`) and explicit provider prefixes (`claude:opus`, `anthropic:sonnet`) all qualify.
181
181
 
182
182
  Only a **429 or 5xx** fails over. A 400 surfaces to you, because a bad request that fails over just reproduces itself on the other provider and buries the real cause.
183
183
 
@@ -303,7 +303,7 @@ The split isn't live, but it was announced once on short notice and could return
303
303
 
304
304
  | Signal | Status |
305
305
  |---|---|
306
- | Source | **~29k** lines of TypeScript across **58** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
306
+ | Source | **~29k** lines of TypeScript across **59** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
307
307
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
308
308
  | Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore |
309
309
  | Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly |
@@ -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
  *
@@ -0,0 +1,12 @@
1
+ /** Maps a bare name to a canonical id, or returns it unchanged. */
2
+ export type ModelResolver = (model: string) => string;
3
+ /**
4
+ * The canonical id the Claude pool would serve `model` as, or null when it
5
+ * cannot serve it. `bases` is the catalog base set — `getCachedBases()` at a
6
+ * call site, a fixture in a test. `resolve` is the alias pipeline; the default
7
+ * knows only catalog family shorthands, so pass the proxy's full resolver
8
+ * (operator aliases + pinned aliases) where one exists.
9
+ */
10
+ export declare function resolveClaudeServable(model: string, bases?: readonly string[], resolve?: ModelResolver): string | null;
11
+ /** Whether the Claude pool can serve `model`. See resolveClaudeServable. */
12
+ export declare function isClaudeServableModel(model: string, bases?: readonly string[], resolve?: ModelResolver): boolean;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * "Can the Claude pool actually serve this model name?" — the positive test
3
+ * the reverse half of failover needs, and WHAT it resolves to.
4
+ *
5
+ * Chain selection used to answer this by elimination: anything absent from the
6
+ * Codex slugs was handed to the Claude pool. That is not a capability test, it
7
+ * is a default, and it is wrong for every name that belongs to neither
8
+ * provider. `--pool-fallback=gpt-5.6-sol,gpt-4o` on a rate-limited
9
+ * subscription re-pointed the request at Anthropic as `gpt-4o` and turned a
10
+ * 429 into a 400 — and a typo'd slug (`claude-sonnet-6`, `gtp-5.6-sol`) did
11
+ * the same, silently, because a misspelling is also "absent from the slugs".
12
+ * A discovery outage makes it worse: `getCodexModelSlugs` degrades to an EMPTY
13
+ * set, at which point elimination calls EVERY chain entry Claude-servable.
14
+ *
15
+ * So the rule is positive, in two steps that mirror the request path:
16
+ *
17
+ * 1. RESOLVE the entry the way a request model is resolved — an explicit
18
+ * `claude:` / `anthropic:` prefix is stripped, then operator
19
+ * `--model-alias` values, pinned aliases (`opus48`) and catalog family
20
+ * shorthands (`opus`, `sonnet1m`) map to a canonical id. The caller
21
+ * supplies that resolver so classification and forwarding cannot
22
+ * disagree.
23
+ * 2. VALIDATE the resolved id against the catalog base set. A `claude-`
24
+ * prefix on its own proves nothing — `claude-sonnet-6` has the prefix and
25
+ * does not exist — so the id (sans `[1m]`) must be a base the pool can
26
+ * forward. An explicit provider prefix can force the route; it cannot
27
+ * make an unknown model servable.
28
+ *
29
+ * The RESOLVED id is what the caller swaps into the body. The swap happens
30
+ * after the proxy's own alias pass, so returning the entry as written would
31
+ * send an alias upstream raw and Anthropic would 400 it.
32
+ *
33
+ * Pure over the base set it is handed, so the whole selection stays testable
34
+ * without a socket. `bases` defaults to the baked catalog, never to empty: a
35
+ * cold catalog must still admit real ids, and must still refuse typos.
36
+ */
37
+ import { BAKED_BASE_MODELS, resolveAliasAgainst } from './model-catalog.js';
38
+ /** Provider prefixes that force the Claude path (mirrors proxy.ts's PROVIDER_PREFIXES). */
39
+ const CLAUDE_PREFIXES = new Set(['claude', 'anthropic']);
40
+ /**
41
+ * The canonical id the Claude pool would serve `model` as, or null when it
42
+ * cannot serve it. `bases` is the catalog base set — `getCachedBases()` at a
43
+ * call site, a fixture in a test. `resolve` is the alias pipeline; the default
44
+ * knows only catalog family shorthands, so pass the proxy's full resolver
45
+ * (operator aliases + pinned aliases) where one exists.
46
+ */
47
+ export function resolveClaudeServable(model, bases = BAKED_BASE_MODELS, resolve) {
48
+ let m = model.trim().toLowerCase();
49
+ if (!m)
50
+ return null;
51
+ const idx = m.indexOf(':');
52
+ if (idx > 0) {
53
+ const prefix = m.slice(0, idx);
54
+ // A recognized non-Claude prefix is a definite NO, not a fall-through to
55
+ // the name test below — `openai:claude-sonnet-5` is the operator pointing
56
+ // somewhere else on purpose.
57
+ if (!CLAUDE_PREFIXES.has(prefix))
58
+ return null;
59
+ m = m.slice(idx + 1);
60
+ if (!m)
61
+ return null;
62
+ }
63
+ const resolved = (resolve ? resolve(m) : (resolveAliasAgainst(m, bases) ?? m)).trim().toLowerCase();
64
+ const base = resolved.endsWith('[1m]') ? resolved.slice(0, -4) : resolved;
65
+ return bases.some((b) => b.toLowerCase() === base) ? resolved : null;
66
+ }
67
+ /** Whether the Claude pool can serve `model`. See resolveClaudeServable. */
68
+ export function isClaudeServableModel(model, bases = BAKED_BASE_MODELS, resolve) {
69
+ return resolveClaudeServable(model, bases, resolve) !== null;
70
+ }
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { IncomingMessage, ServerResponse } from 'node:http';
25
25
  import type { CodexAccountCredentials } from './codex-accounts.js';
26
+ import { type ModelResolver } from './claude-model.js';
26
27
  export declare const CODEX_BACKEND_BASE_URL: string;
27
28
  /**
28
29
  * Client version sent on the model-discovery call. The backend REQUIRES the
@@ -46,6 +47,22 @@ export declare function fetchCodexModels(creds: CodexAccountCredentials, fetchIm
46
47
  * "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
47
48
  * routes, so discovery being down never makes the engine unusable.
48
49
  */
50
+ /** What the proxy learns from one forwarded codex request — enough for an
51
+ * analytics row and a log line. Reported once per request, on every exit
52
+ * that answered the client; a DECLINE (deferred to the Claude pool) reports
53
+ * nothing, since the Claude path records what it then serves. */
54
+ export interface CodexForwardOutcome {
55
+ status: number;
56
+ latencyMs: number;
57
+ inputTokens: number;
58
+ outputTokens: number;
59
+ stream: boolean;
60
+ model: string;
61
+ alias: string;
62
+ }
63
+ /** The cached slug list for an alias WITHOUT fetching. For the admin surface:
64
+ * a status read must never cost an upstream call or a token refresh. */
65
+ export declare function peekCodexModelSlugs(alias: string): readonly string[] | null;
49
66
  export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetchImpl?: typeof fetch): Promise<readonly string[]>;
50
67
  /**
51
68
  * Whether a request naming `model` should be served from the subscription: the
@@ -69,7 +86,7 @@ export declare function isCodexModel(model: string, slugs: readonly string[]): b
69
86
  * written before this release behave identically.
70
87
  */
71
88
  export declare function pickCodexFallback(models: readonly string[], slugs: readonly string[]): string | null;
72
- export declare function pickClaudeFallback(models: readonly string[], slugs: readonly string[]): string | null;
89
+ export declare function pickClaudeFallback(models: readonly string[], slugs: readonly string[], bases?: readonly string[], resolve?: ModelResolver): string | null;
73
90
  /**
74
91
  * Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
75
92
  * claim. Payload only — this is reading our own token for a routing header, not
@@ -125,6 +142,14 @@ export declare function createResponsesTranslator(model: string): {
125
142
  chunk(line: string): string | null;
126
143
  /** True when the upstream stream terminated as a FAILURE. */
127
144
  didFail(): boolean;
145
+ /** Token usage from the terminal event, or null if none arrived. Read by
146
+ * the proxy to record the request in analytics — before this, codex
147
+ * requests were invisible to /analytics and the request log entirely. */
148
+ usage(): {
149
+ prompt_tokens: number;
150
+ completion_tokens: number;
151
+ total_tokens: number;
152
+ } | null;
128
153
  /** Everything seen so far, as one non-streaming chat.completion body. */
129
154
  complete(): Record<string, unknown>;
130
155
  };
@@ -179,4 +204,4 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
179
204
  * testable without network (test/codex-backend.mjs), matching the pattern
180
205
  * test/codex-oauth.mjs already uses.
181
206
  */
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>;
207
+ 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>;
@@ -1,4 +1,6 @@
1
1
  import { anthropicToResponsesRequest, createResponsesSSEParser, formatResponsesAnthropicSSE, createAnthropicMessageAssembler, responsesStreamToAnthropicSSE, } from './anthropic-responses-translate.js';
2
+ import { resolveClaudeServable } from './claude-model.js';
3
+ import { BAKED_BASE_MODELS } from './model-catalog.js';
2
4
  export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
3
5
  /** Originator string the codex CLI identifies itself with. */
4
6
  const CODEX_ORIGINATOR = 'codex_cli_rs';
@@ -59,12 +61,12 @@ export async function fetchCodexModels(creds, fetchImpl = fetch) {
59
61
  }
60
62
  return slugs;
61
63
  }
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
- */
64
+ /** The cached slug list for an alias WITHOUT fetching. For the admin surface:
65
+ * a status read must never cost an upstream call or a token refresh. */
66
+ export function peekCodexModelSlugs(alias) {
67
+ const hit = modelCache.get(alias);
68
+ return hit ? hit.slugs : null;
69
+ }
68
70
  export async function getCodexModelSlugs(creds, fetchImpl = fetch) {
69
71
  const hit = modelCache.get(creds.alias);
70
72
  if (hit && Date.now() - hit.fetchedAt < hit.ttlMs)
@@ -109,21 +111,36 @@ export function isCodexModel(model, slugs) {
109
111
  export function pickCodexFallback(models, slugs) {
110
112
  return models.find(m => isCodexModel(m, slugs)) ?? null;
111
113
  }
112
- export function pickClaudeFallback(models, slugs) {
114
+ export function pickClaudeFallback(models, slugs, bases = BAKED_BASE_MODELS, resolve) {
113
115
  // "Not a codex slug" is NOT the same as "the Claude pool can serve it". A
114
116
  // typo, a retired model, or an entry meant for some third provider would all
115
117
  // pass that test, and the request would be swapped to a model Anthropic 404s
116
118
  // on — trading a recoverable 429 for an unrecoverable 404, which is strictly
117
119
  // worse than not failing over at all.
118
120
  //
119
- // So require it to look like an Anthropic model. Every model the pool serves
120
- // is `claude-*`; anything else means the chain has no Claude entry and the
121
- // codex error surfaces honestly. Failing CLOSED is the right direction here.
121
+ // v6.0.0 shipped this as `/^claude/i`, which was the right DIRECTION (fail
122
+ // closed) and the wrong TEST. It rejected `anthropic:opus`, where the operator
123
+ // named the provider explicitly, and every catalog shorthand (`opus`,
124
+ // `sonnet1m`) — so a legitimate chain entry silently never failed over, which
125
+ // is the same class of quiet wrongness it was written to prevent. It also did
126
+ // nothing about the case where discovery degrades and `slugs` arrives EMPTY,
127
+ // at which point elimination calls every entry Claude-servable.
122
128
  //
123
- // Known limitation: a `--model-alias` that resolves to a Claude model is not
124
- // accepted, because aliases resolve later in the request path than this. Name
125
- // the real model id in the chain.
126
- return models.find(m => !isCodexModel(m, slugs) && /^claude/i.test(m)) ?? null;
129
+ // `isClaudeServableModel` is the positive capability test instead, resolved
130
+ // against the live catalog, so aliases work and the limitation the old comment
131
+ // documented as accepted is simply gone.
132
+ //
133
+ // Returns the RESOLVED canonical id, not the entry as written: the caller
134
+ // swaps it into the body after the proxy's own alias pass has already run,
135
+ // so an alias returned raw would reach Anthropic unresolved and 400.
136
+ for (const m of models) {
137
+ if (isCodexModel(m, slugs))
138
+ continue;
139
+ const resolved = resolveClaudeServable(m, bases, resolve);
140
+ if (resolved)
141
+ return resolved;
142
+ }
143
+ return null;
127
144
  }
128
145
  /**
129
146
  * Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
@@ -386,6 +403,12 @@ export function createResponsesTranslator(model) {
386
403
  didFail() {
387
404
  return failed;
388
405
  },
406
+ /** Token usage from the terminal event, or null if none arrived. Read by
407
+ * the proxy to record the request in analytics — before this, codex
408
+ * requests were invisible to /analytics and the request log entirely. */
409
+ usage() {
410
+ return usage;
411
+ },
389
412
  /** Everything seen so far, as one non-streaming chat.completion body. */
390
413
  complete() {
391
414
  const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
@@ -481,9 +504,23 @@ export function buildCodexHeaders(creds) {
481
504
  * testable without network (test/codex-backend.mjs), matching the pattern
482
505
  * test/codex-oauth.mjs already uses.
483
506
  */
484
- export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false) {
507
+ export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false, onDone) {
485
508
  void req;
486
509
  const isAnthropic = shape === 'anthropic';
510
+ // Reported exactly once, on every exit that answered the client. Without
511
+ // this the proxy had no idea a codex request happened: no analytics row, no
512
+ // log line, no per-account count.
513
+ const startedAt = Date.now();
514
+ let reported = false;
515
+ const report = (status, usage, stream, model) => {
516
+ if (reported || !onDone)
517
+ return;
518
+ reported = true;
519
+ try {
520
+ onDone({ status, latencyMs: Date.now() - startedAt, inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0, stream, model, alias: creds.alias });
521
+ }
522
+ catch { /* a reporting failure must never break a served request */ }
523
+ };
487
524
  // An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
488
525
  // is {error}. A client SDK reads its own shape, so errors follow the request.
489
526
  const errBody = (message, extra = {}) => JSON.stringify(isAnthropic
@@ -496,6 +533,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
496
533
  catch {
497
534
  res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
498
535
  res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
536
+ report(400, null, false, '');
499
537
  return true;
500
538
  }
501
539
  const clientWantsStream = parsed.stream === true;
@@ -532,6 +570,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
532
570
  }
533
571
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
534
572
  res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
573
+ report(upstream.status, null, clientWantsStream, model);
535
574
  return true;
536
575
  }
537
576
  // OpenAI shape: one stateful line-in/line-out translator (unchanged).
@@ -625,6 +664,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
625
664
  console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
626
665
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
627
666
  res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
667
+ report(502, null, clientWantsStream, model);
628
668
  return true;
629
669
  }
630
670
  if (isAnthropic) {
@@ -654,6 +694,16 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
654
694
  });
655
695
  res.end(JSON.stringify(translator.complete()));
656
696
  }
697
+ // Token usage rides the terminal Responses event on either shape. A stream
698
+ // that failed upstream still ended as 200 on the wire (the client saw the
699
+ // failure event); analytics must count it as the 502 it was.
700
+ {
701
+ const tr = terminalResponse;
702
+ const usage = isAnthropic
703
+ ? (tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null)
704
+ : (() => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; })();
705
+ report(upstreamFailed ? 502 : 200, usage, clientWantsStream, model);
706
+ }
657
707
  return true;
658
708
  }
659
709
  catch (err) {
@@ -685,6 +735,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
685
735
  }
686
736
  catch { /* already closed */ }
687
737
  }
738
+ report(502, null, clientWantsStream, model);
688
739
  return true;
689
740
  }
690
741
  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
@@ -2680,9 +2706,42 @@ export async function startProxy(opts = {}) {
2680
2706
  // and pick the request back up on the Claude path below. Before
2681
2707
  // this, a rate-limited ChatGPT plan was terminal for a gpt-bound
2682
2708
  // request even with an idle Claude pool sitting right beside it.
2683
- const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels);
2709
+ // Resolve chain entries exactly as a request model is resolved —
2710
+ // operator --model-alias first, then pinned + catalog aliases — so
2711
+ // `backup` or `opus48` in the chain works the way it does on a
2712
+ // request, and the classifier validates the id that would actually
2713
+ // be forwarded.
2714
+ const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels, getCachedBases(), (m) => resolveClaudeAlias(applyModelAlias(m, modelAliases) ?? m));
2684
2715
  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);
2716
+ const codexReq = requestCount;
2717
+ const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
2718
+ // Before this hook a codex request left no trace: nothing in
2719
+ // /analytics, nothing in the request log, no per-account count.
2720
+ // The dock (and anyone reading /analytics) saw a proxy that
2721
+ // served GPT all day and reported zero of it. A decline (the
2722
+ // request handed to the Claude pool) reports nothing here; the
2723
+ // Claude path records what it then serves.
2724
+ (o) => {
2725
+ codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
2726
+ analytics.record({
2727
+ timestamp: Date.now(),
2728
+ account: o.alias,
2729
+ model: o.model || rawModel || 'codex',
2730
+ inputTokens: o.inputTokens, outputTokens: o.outputTokens,
2731
+ cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
2732
+ // No Anthropic rate-limit headers on this path; the claim
2733
+ // names the engine and is subscription billing, so the
2734
+ // overage guard (#288) leaves it alone.
2735
+ claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
2736
+ latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
2737
+ });
2738
+ writeLogLine(logFileStream, {
2739
+ ts: new Date().toISOString(), req: codexReq,
2740
+ method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
2741
+ status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
2742
+ claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
2743
+ });
2744
+ });
2686
2745
  if (served)
2687
2746
  return;
2688
2747
  const swapped = buildPoolFallbackBody(body, claudeTarget);
@@ -59,7 +59,7 @@ Neither subscription hitting its ceiling can take the deployment down on its own
59
59
  Deliberate limits:
60
60
 
61
61
  - **Only a 429 or 5xx fails over.** A 400 surfaces to the client. A bad request that fails over just reproduces itself on the other provider and buries the real cause.
62
- - **The Claude entry must be a real `claude-*` id.** "Not a codex slug" would also match a typo or a model meant for a third provider, and swapping that in trades a recoverable 429 for an unrecoverable 404. Anything that doesn't look like an Anthropic model is ignored failing closed. A `--model-alias` is not accepted here; name the real id.
62
+ - **The Claude entry is validated positively, against the live catalog.** "Not a codex slug" would also match a typo, or a model meant for a third provider, and swapping that in trades a recoverable 429 for an unrecoverable 404. Worse, when model discovery degrades the codex slug list arrives EMPTY, at which point elimination would call *every* entry Claude-servable. So each entry is tested for what it is rather than what it isn't: canonical ids, `[1m]` variants, catalog shorthands (`opus`, `sonnet1m`) and explicit `claude:` / `anthropic:` prefixes all qualify; anything else is skipped and the real error surfaces.
63
63
  - **The api-key backend is still OpenAI-shape only.** There is no Messages translation on that route. A Codex account has one, which is why it is preferred.
64
64
  - **Never silent.** Every substituted response carries `x-dario-pool-fallback: <model>`. A quietly swapped model is exactly the surprise this project exists to avoid.
65
65
  - **Empty pool still errors.** A pool with zero accounts is a setup mistake (`dario login` never ran); that returns the usual 503 rather than silently re-billing every request to another provider.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
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": {