@askalf/dario 5.5.90 → 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.
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  <p><strong>One local endpoint. Every AI tool you own. The subscription you already pay for.</strong></p>
20
20
 
21
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~28k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
21
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~29k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
22
22
 
23
23
  <sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 12 open tools for owning your AI infra: <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/strongroom">strongroom</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
24
24
 
@@ -133,32 +133,85 @@ The tool doesn't know. The backend doesn't know. dario is the seam.
133
133
 
134
134
  ## ChatGPT subscription accounts (Codex engine)
135
135
 
136
- Your ChatGPT Plus/Pro plan, served on dario's OpenAI-compatible endpoint — so any client or harness that speaks `/v1/chat/completions` can use it: Codex CLI, OpenClaw-style clients, the OpenAI SDKs, your own scripts.
136
+ Your ChatGPT Plus/Pro plan, served on **both** of dario's endpoints — so any client that speaks `/v1/chat/completions` can use it (Codex CLI, the OpenAI SDKs, your own scripts), and so can any client that speaks `/v1/messages` (Claude Code, the Anthropic SDKs, agent runtimes). The harness does not need to know which subscription is behind it.
137
137
 
138
138
  ```bash
139
- dario codex add work # prints an authorize URL; paste the redirect URL back
139
+ dario add altman # prints an authorize URL; paste the redirect URL back
140
140
  dario codex list
141
- dario codex remove work
141
+ dario codex remove altman
142
142
  ```
143
143
 
144
+ `dario add altman` names whose plan you are attaching; `dario add amodei` attaches a Claude account instead. `dario codex add <name>` is the same command and still works.
145
+
144
146
  The browser lands on a `localhost` page that doesn't load — that's expected, nothing is listening there. Copy the whole address bar and paste it at the prompt; dario reads the code out of it. A bare code (or `code#state`) works too.
145
147
 
146
- Once an account is stored, an OpenAI-shape request naming a model that account may use is served from the subscription:
148
+ Once an account is stored, a request naming a model that account may use is served from the subscription — on either endpoint:
147
149
 
148
150
  ```bash
149
151
  curl localhost:3456/v1/models | jq -r '.data[].id'
150
152
  curl localhost:3456/v1/chat/completions -H 'content-type: application/json' \
151
153
  -d '{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}]}'
154
+
155
+ # same subscription, Anthropic wire shape — this is what Claude Code speaks
156
+ curl localhost:3456/v1/messages -H 'content-type: application/json' \
157
+ -d '{"model":"gpt-5.5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'
152
158
  ```
153
159
 
154
160
  **Model names are discovered, not hardcoded.** The set a ChatGPT subscription may use is per-account and moves; dario asks the backend which models this account lists, caches the answer, and advertises them on `GET /v1/models` so a client's model picker finds them. Anything not on that list — `gpt-4o` and friends — is untouched and still routes to a configured API-key backend as before. `codex:<model>` / `chatgpt:<model>` forces the route explicitly.
155
161
 
156
- Streaming, tool calls, and tool-result round trips work: dario translates chat/completions to the Responses API the subscription backend speaks, and translates the stream back to `chat.completion.chunk`. Inbound is chat/completions only there is no `/v1/responses` inbound yet.
162
+ Streaming, tool calls, and tool-result round trips work on both shapes: dario translates chat/completions **or** Messages into the Responses API the subscription backend speaks, and translates the stream back into `chat.completion.chunk` or Anthropic message events to match what the client asked in. There is no `/v1/responses` inbound yet.
157
163
 
158
164
  Codex accounts live in `~/.dario/codex-accounts/`, entirely separate from the Claude pool. Nothing about `dario login`, `dario accounts`, or Claude routing changes.
159
165
 
160
166
  ---
161
167
 
168
+ ## Failover between subscriptions
169
+
170
+ Two consumer plans, no API keys, and neither one able to take you down on its own.
171
+
172
+ ```bash
173
+ dario proxy --pool-fallback=gpt-5.6-sol,claude-sonnet-5
174
+ ```
175
+
176
+ That is a **chain**, read left to right, and each provider takes the first entry it can actually serve. When the Claude pool is drained or cooling, the request is served as `gpt-5.6-sol` from your ChatGPT subscription. When the subscription is rate-limited or down, the request is handed back to the Claude pool as `claude-sonnet-5`. Every substituted response carries `x-dario-pool-fallback: <model>` — a silently swapped model family is exactly the surprise this project exists to avoid.
177
+
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
+
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.
181
+
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
+
184
+ `dario doctor` tells you which of these you are actually in:
185
+
186
+ ```
187
+ [ OK ] Failover symmetric: gpt-5.6-sol → claude-sonnet-5, across 1 Codex account
188
+ [WARN] Failover armed (gpt-5.6-sol) but INERT — no Codex account and no backend
189
+ to fall back to. Add one: `dario add altman`
190
+ ```
191
+
192
+ That warning is the whole reason the check exists. Armed with nothing to fall back to is green on every other check and incapable of doing anything.
193
+
194
+ ---
195
+
196
+ ## Shadow compare
197
+
198
+ Once either subscription can serve either wire shape, the interesting question stops being *can I reach GPT* and becomes *which of these is better at my work*. Benchmarks answer that badly. Your own traffic answers it well.
199
+
200
+ ```bash
201
+ curl localhost:3456/v1/messages \
202
+ -H 'content-type: application/json' \
203
+ -H 'x-dario-compare: gpt-5.6-sol' \
204
+ -d '{"model":"claude-opus-4-8","max_tokens":1024,"messages":[…]}'
205
+ ```
206
+
207
+ You get the Claude answer, exactly as you would have. Beside it, dario runs the same prompt past `gpt-5.6-sol` and writes both to `~/.dario/compare/<timestamp>-<model>.json`, in your own wire shape so you are comparing like with like rather than eyeballing across two formats.
208
+
209
+ The comparison cannot degrade the request it observes: it only reads bytes already on their way out, your request is never held open for it, and a comparison that fails, times out, or has nowhere to go is dropped with the record still written. Both sides are stored as raw payloads — extracting text is where a bug would quietly make two answers look more alike than they are.
210
+
211
+ Compares run against a Codex account. Comparing against the Claude pool would occupy a seat for a request nobody is waiting on.
212
+
213
+ ---
214
+
162
215
  ## Multi-account pool
163
216
 
164
217
  **In v5 every dario is a pool** — a plain `dario login` is a pool of one, no separate mode to switch on. One Claude subscription has a ceiling; hold more than one seat — a personal Max and a work Max, a couple of Pros, team seats — and the same `localhost:3456` routes every request to whichever seat has the most headroom, live, per request. A single `dario accounts add` even bootstraps a servable proxy with no `dario login` step:
@@ -250,7 +303,7 @@ The split isn't live, but it was announced once on short notice and could return
250
303
 
251
304
  | Signal | Status |
252
305
  |---|---|
253
- | Source | **~25k** lines of TypeScript across **53** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
306
+ | Source | **~29k** lines of TypeScript across **58** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
254
307
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
255
308
  | Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore |
256
309
  | Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly |
@@ -303,7 +356,7 @@ Ongoing discussion, including other users' experiences: [#724](https://github.co
303
356
 
304
357
  ## Commands
305
358
 
306
- `dario` (TUI) · `login` · `proxy` · `doctor` · `accounts {list,add,remove}` · `backend {list,add,remove}` · `codex {list,add,remove}` · `mcp` · `subagent {install,status,remove}` · `usage` · `config` · `upgrade` · `status` · `refresh` · `resume` · `logout` · `help`
359
+ `dario` (TUI) · `login` · `proxy` · `doctor` · `add {altman,amodei}` · `accounts {list,add,remove}` · `backend {list,add,remove}` · `codex {list,add,remove}` · `mcp` · `subagent {install,status,remove}` · `usage` · `config` · `upgrade` · `status` · `refresh` · `resume` · `logout` · `help`
307
360
 
308
361
  Per-flag reference: [`docs/commands.md`](./docs/commands.md) · env vars grouped by task, for Docker / k8s / systemd: [`docs/configuration.md`](./docs/configuration.md) · SDK examples + per-tool setup: [`docs/usage.md`](./docs/usage.md)
309
362
 
@@ -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
  *
package/dist/cli.js CHANGED
@@ -534,11 +534,12 @@ async function proxy() {
534
534
  // DARIO_PASSTHROUGH_BETAS env var.
535
535
  const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
536
536
  // --pool-fallback=<model> / DARIO_POOL_FALLBACK / config poolFallback.model
537
- // — strictly opt-in. When the Claude pool can't serve, OpenAI-shape
538
- // requests are re-pointed at the configured openai-compat backend as
539
- // <model> (response marked x-dario-pool-fallback) instead of surfacing
540
- // the 429/503. `--pool-fallback=` (empty value) disables, overriding
541
- // env + config same clear-the-default shape as --passthrough-betas=.
537
+ // — strictly opt-in. When the Claude pool can't serve, the request is
538
+ // re-pointed at whichever provider can serve <model> (response marked
539
+ // x-dario-pool-fallback) instead of surfacing the 429/503: a stored Codex
540
+ // account when it lists <model>, otherwise the openai-compat backend.
541
+ // `--pool-fallback=` (empty value) disables, overriding env + config
542
+ // same clear-the-default shape as --passthrough-betas=.
542
543
  const poolFallbackFromFlag = args.find((a) => a.startsWith('--pool-fallback='))?.split('=').slice(1).join('=');
543
544
  const poolFallbackModel = (poolFallbackFromFlag
544
545
  ?? process.env['DARIO_POOL_FALLBACK']
@@ -1310,6 +1311,9 @@ async function help() {
1310
1311
  dario codex add NAME Add a ChatGPT-subscription account (prints an
1311
1312
  authorize URL; paste the redirect URL back).
1312
1313
  dario codex remove NAME Remove a ChatGPT-subscription account.
1314
+ dario add altman [NAME] Attach a ChatGPT subscription — the same thing
1315
+ as 'dario codex add', named by whose plan it is.
1316
+ 'dario add amodei' attaches a Claude account.
1313
1317
  dario backend list List configured OpenAI-compat backends
1314
1318
  dario backend add NAME --key=sk-... [--base-url=...]
1315
1319
  Add an OpenAI-compat backend (OpenAI, OpenRouter, Groq, etc.)
@@ -1558,14 +1562,14 @@ async function help() {
1558
1562
  Sticky bindings are unaffected.
1559
1563
  Env: DARIO_POOL_STRATEGY.
1560
1564
  --pool-fallback=<model> When every pool seat is drained or cooling,
1561
- forward OpenAI-shape requests to the
1562
- configured openai-compat backend as <model>
1563
- instead of surfacing the 429/503. Response
1564
- carries x-dario-pool-fallback. Anthropic-
1565
- shape requests keep the error (no reverse
1566
- response translation). Requires an
1567
- openai-compat backend. Empty value disables.
1568
- Env: DARIO_POOL_FALLBACK. Config:
1565
+ serve the request as <model> from whichever
1566
+ provider can, instead of surfacing the
1567
+ 429/503: a Codex/ChatGPT subscription that
1568
+ lists <model> (both wire shapes), else the
1569
+ openai-compat backend (OpenAI shape only).
1570
+ Response carries x-dario-pool-fallback.
1571
+ Needs one of those configured. Empty value
1572
+ disables. Env: DARIO_POOL_FALLBACK. Config:
1569
1573
  poolFallback.model.
1570
1574
  --model-alias=<name=target>
1571
1575
  User-defined model alias, repeatable.
@@ -2228,6 +2232,55 @@ function pkgVersion() {
2228
2232
  return 'unknown';
2229
2233
  }
2230
2234
  }
2235
+ /**
2236
+ * `dario add <who> [NAME]` — one verb for "give dario another subscription to
2237
+ * draw on", added in v6.0.0.
2238
+ *
2239
+ * This is the command dario#1009 actually asked for. Until now the answer was
2240
+ * "run `dario codex add` instead", which is a worse answer than it looks: by
2241
+ * v6.0.0 a Codex account is no longer a niche OpenAI-path add-on but one of two
2242
+ * symmetric subscriptions either of which can carry the whole deployment, and
2243
+ * the command surface should say so. `add` names WHOSE plan you are attaching,
2244
+ * which is the distinction that actually matters to someone holding two
2245
+ * consumer subscriptions and no API keys.
2246
+ *
2247
+ * It dispatches to the existing implementations rather than duplicating them —
2248
+ * there is exactly one add-a-Codex-account code path, and this is a second door
2249
+ * into it. `dario codex add` keeps working unchanged and undeprecated.
2250
+ *
2251
+ * The name defaults to the vendor word, so the bare `dario add altman` from the
2252
+ * issue works end to end instead of erroring on a missing alias.
2253
+ */
2254
+ async function add() {
2255
+ const who = (args[1] ?? '').toLowerCase();
2256
+ const CODEX = new Set(['altman', 'chatgpt', 'openai', 'codex', 'gpt']);
2257
+ const CLAUDE = new Set(['amodei', 'claude', 'anthropic']);
2258
+ if (CODEX.has(who)) {
2259
+ // Reshape argv into the `codex add [NAME]` form and reuse that one path.
2260
+ args[2] = args[2] ?? who;
2261
+ args[1] = 'add';
2262
+ return codex();
2263
+ }
2264
+ if (CLAUDE.has(who)) {
2265
+ if (args[2]) {
2266
+ console.error('');
2267
+ console.error(` Claude accounts are named during login, not on the command line.`);
2268
+ console.error(` Run \`dario add ${who}\` and follow the browser flow.`);
2269
+ console.error('');
2270
+ process.exit(1);
2271
+ }
2272
+ return login();
2273
+ }
2274
+ console.error('');
2275
+ console.error(who ? ` Unknown provider: ${who}` : ' Usage: dario add <provider> [NAME]');
2276
+ console.error('');
2277
+ console.error(' dario add altman [NAME] a ChatGPT subscription (Codex backend)');
2278
+ console.error(' dario add amodei a Claude subscription (adds a pool seat)');
2279
+ console.error('');
2280
+ console.error(' Aliases: altman = chatgpt/openai/codex/gpt, amodei = claude/anthropic.');
2281
+ console.error('');
2282
+ process.exit(1);
2283
+ }
2231
2284
  // Main
2232
2285
  const commands = {
2233
2286
  login,
@@ -2237,6 +2290,7 @@ const commands = {
2237
2290
  resume,
2238
2291
  logout,
2239
2292
  accounts,
2293
+ add,
2240
2294
  codex,
2241
2295
  backend,
2242
2296
  shim,
@@ -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
@@ -56,6 +72,20 @@ export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetch
56
72
  * configured API-key backend.
57
73
  */
58
74
  export declare function isCodexModel(model: string, slugs: readonly string[]): boolean;
75
+ /**
76
+ * A pool-fallback value may name a CHAIN — `gpt-5.6-sol,claude-sonnet-5` — and
77
+ * each provider takes the first entry it can actually serve. These two pickers
78
+ * are the whole selection rule, kept pure so it is testable without a socket.
79
+ *
80
+ * Reading the chain from both ends is what makes failover SYMMETRIC in v6.0.0:
81
+ * `pickCodexFallback` catches a drained Claude pool, `pickClaudeFallback`
82
+ * catches a ChatGPT subscription that is rate-limited or down. Neither
83
+ * subscription hitting its ceiling can take the whole deployment dark on its
84
+ * own. A single-entry chain keeps the pre-6.0 meaning exactly, so configs
85
+ * written before this release behave identically.
86
+ */
87
+ export declare function pickCodexFallback(models: readonly string[], slugs: readonly string[]): string | null;
88
+ export declare function pickClaudeFallback(models: readonly string[], slugs: readonly string[]): string | null;
59
89
  /**
60
90
  * Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
61
91
  * claim. Payload only — this is reading our own token for a routing header, not
@@ -111,6 +141,14 @@ export declare function createResponsesTranslator(model: string): {
111
141
  chunk(line: string): string | null;
112
142
  /** True when the upstream stream terminated as a FAILURE. */
113
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;
114
152
  /** Everything seen so far, as one non-streaming chat.completion body. */
115
153
  complete(): Record<string, unknown>;
116
154
  };
@@ -153,8 +191,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
153
191
  * shapes. For the Anthropic shape the non-streaming body is rebuilt from the
154
192
  * `response` object carried by the terminal `response.completed` event.
155
193
  *
194
+ * Returns TRUE when it answered the client. With `deferOnUnavailable` it may
195
+ * instead return FALSE having written NOTHING — that is the subscription
196
+ * saying "not right now" (429, a 5xx, or a transport failure that never got a
197
+ * status at all) so the caller can fail over to another provider rather than
198
+ * pass a rate limit or an outage through to the client. It only ever declines
199
+ * before any byte is written, so a stream in flight is never abandoned
200
+ * half-sent.
201
+ *
156
202
  * `fetchImpl` is injectable so the translation and header construction are
157
203
  * testable without network (test/codex-backend.mjs), matching the pattern
158
204
  * test/codex-oauth.mjs already uses.
159
205
  */
160
- 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): Promise<void>;
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)
@@ -94,6 +94,37 @@ export function isCodexModel(model, slugs) {
94
94
  const m = model.toLowerCase();
95
95
  return slugs.some(s => s.toLowerCase() === m);
96
96
  }
97
+ /**
98
+ * A pool-fallback value may name a CHAIN — `gpt-5.6-sol,claude-sonnet-5` — and
99
+ * each provider takes the first entry it can actually serve. These two pickers
100
+ * are the whole selection rule, kept pure so it is testable without a socket.
101
+ *
102
+ * Reading the chain from both ends is what makes failover SYMMETRIC in v6.0.0:
103
+ * `pickCodexFallback` catches a drained Claude pool, `pickClaudeFallback`
104
+ * catches a ChatGPT subscription that is rate-limited or down. Neither
105
+ * subscription hitting its ceiling can take the whole deployment dark on its
106
+ * own. A single-entry chain keeps the pre-6.0 meaning exactly, so configs
107
+ * written before this release behave identically.
108
+ */
109
+ export function pickCodexFallback(models, slugs) {
110
+ return models.find(m => isCodexModel(m, slugs)) ?? null;
111
+ }
112
+ export function pickClaudeFallback(models, slugs) {
113
+ // "Not a codex slug" is NOT the same as "the Claude pool can serve it". A
114
+ // typo, a retired model, or an entry meant for some third provider would all
115
+ // pass that test, and the request would be swapped to a model Anthropic 404s
116
+ // on — trading a recoverable 429 for an unrecoverable 404, which is strictly
117
+ // worse than not failing over at all.
118
+ //
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.
122
+ //
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;
127
+ }
97
128
  /**
98
129
  * Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
99
130
  * claim. Payload only — this is reading our own token for a routing header, not
@@ -355,6 +386,12 @@ export function createResponsesTranslator(model) {
355
386
  didFail() {
356
387
  return failed;
357
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
+ },
358
395
  /** Everything seen so far, as one non-streaming chat.completion body. */
359
396
  complete() {
360
397
  const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
@@ -438,13 +475,35 @@ export function buildCodexHeaders(creds) {
438
475
  * shapes. For the Anthropic shape the non-streaming body is rebuilt from the
439
476
  * `response` object carried by the terminal `response.completed` event.
440
477
  *
478
+ * Returns TRUE when it answered the client. With `deferOnUnavailable` it may
479
+ * instead return FALSE having written NOTHING — that is the subscription
480
+ * saying "not right now" (429, a 5xx, or a transport failure that never got a
481
+ * status at all) so the caller can fail over to another provider rather than
482
+ * pass a rate limit or an outage through to the client. It only ever declines
483
+ * before any byte is written, so a stream in flight is never abandoned
484
+ * half-sent.
485
+ *
441
486
  * `fetchImpl` is injectable so the translation and header construction are
442
487
  * testable without network (test/codex-backend.mjs), matching the pattern
443
488
  * test/codex-oauth.mjs already uses.
444
489
  */
445
- export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch) {
490
+ export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false, onDone) {
446
491
  void req;
447
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
+ };
448
507
  // An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
449
508
  // is {error}. A client SDK reads its own shape, so errors follow the request.
450
509
  const errBody = (message, extra = {}) => JSON.stringify(isAnthropic
@@ -457,7 +516,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
457
516
  catch {
458
517
  res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
459
518
  res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
460
- return;
519
+ report(400, null, false, '');
520
+ return true;
461
521
  }
462
522
  const clientWantsStream = parsed.stream === true;
463
523
  const model = String(parsed.model ?? '');
@@ -482,9 +542,19 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
482
542
  const detail = await upstream.text().catch(() => '');
483
543
  if (verbose)
484
544
  console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
545
+ // "Not right now" — a rate limit or an upstream fault — is the caller's
546
+ // cue to fail over, not something to hand the client. A 4xx that is our
547
+ // own fault (a bad body, an unsupported parameter) is NOT: failing over
548
+ // would just reproduce it somewhere else and hide the real error.
549
+ const unavailable = upstream.status === 429 || upstream.status >= 500;
550
+ if (deferOnUnavailable && unavailable) {
551
+ console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring to the next provider`);
552
+ return false;
553
+ }
485
554
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
486
555
  res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
487
- return;
556
+ report(upstream.status, null, clientWantsStream, model);
557
+ return true;
488
558
  }
489
559
  // OpenAI shape: one stateful line-in/line-out translator (unchanged).
490
560
  // Anthropic shape: parse the typed Responses event stream, then map events
@@ -577,7 +647,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
577
647
  console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
578
648
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
579
649
  res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
580
- return;
650
+ report(502, null, clientWantsStream, model);
651
+ return true;
581
652
  }
582
653
  if (isAnthropic) {
583
654
  if (clientWantsStream) {
@@ -606,6 +677,17 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
606
677
  });
607
678
  res.end(JSON.stringify(translator.complete()));
608
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
+ }
690
+ return true;
609
691
  }
610
692
  catch (err) {
611
693
  // Detail stays server-side (CodeQL js/stack-trace-exposure), same as
@@ -613,6 +695,19 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
613
695
  const detail = err instanceof Error ? err.message : String(err);
614
696
  if (verbose)
615
697
  console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
698
+ // A transport failure before any byte was written is the same "not right
699
+ // now" as a 429: DNS, a refused connection, a reset socket or our own
700
+ // upstream timeout all mean this subscription did not answer, and none of
701
+ // them is the client's bad request. Deferring here is what makes failover
702
+ // cover the outage case rather than only the rate-limit case — without it
703
+ // a ChatGPT backend that is merely unreachable is terminal for the request
704
+ // even with an idle Claude pool beside it. The headersSent guard keeps the
705
+ // contract intact: once a stream is in flight it is far too late to hand
706
+ // the request to anyone else.
707
+ if (deferOnUnavailable && !res.headersSent) {
708
+ console.log(`[dario] codex account ${creds.alias} unreachable (${detail}) — deferring to the next provider`);
709
+ return false;
710
+ }
616
711
  if (!res.headersSent) {
617
712
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
618
713
  res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
@@ -623,6 +718,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
623
718
  }
624
719
  catch { /* already closed */ }
625
720
  }
721
+ report(502, null, false, '');
722
+ return true;
626
723
  }
627
724
  finally {
628
725
  clearTimeout(timeout);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shadow compare (v6.0.0) — run one prompt past a second model family and keep
3
+ * both answers, without the client ever knowing.
4
+ *
5
+ * WHY THIS SHAPE. Once dario can serve either wire shape from either
6
+ * subscription, the obvious question stops being "can I reach GPT" and becomes
7
+ * "which of these two is actually better at MY work". Answering that from
8
+ * benchmarks is close to worthless; answering it from your own real traffic is
9
+ * not. So a compare is triggered per-request by a header, on prompts you were
10
+ * sending anyway.
11
+ *
12
+ * THE CLIENT IS NEVER AFFECTED. The primary answer streams through untouched
13
+ * and the comparison runs beside it — the request is not held open for it, and
14
+ * a compare that fails, times out, or has nowhere to go is dropped silently
15
+ * with the record still written. A diagnostic that can degrade the thing it is
16
+ * measuring is worse than no diagnostic.
17
+ *
18
+ * BOTH SIDES ARE RECORDED IN THE CLIENT'S OWN WIRE SHAPE. The comparison is
19
+ * dispatched through the same forwardToCodex translation the real path uses, so
20
+ * an Anthropic-shape request yields two Anthropic-shape answers. Comparing a
21
+ * Messages response against a raw Responses payload would mean eyeballing
22
+ * across two formats and calling the difference a model difference.
23
+ *
24
+ * Raw payloads are stored rather than extracted text: extraction is exactly
25
+ * where a subtle bug would quietly make two answers look more alike than they
26
+ * are, and this release was written off the back of five failures that read as
27
+ * successes.
28
+ */
29
+ import type { ServerResponse } from 'node:http';
30
+ /** Request header that arms a comparison: `x-dario-compare: <model>`. */
31
+ export declare const COMPARE_HEADER = "x-dario-compare";
32
+ /**
33
+ * Response header naming the model a comparison was REQUESTED against.
34
+ *
35
+ * Deliberately not "compared-with". The header has to be set before the primary
36
+ * response starts streaming, which is strictly earlier than we can know whether
37
+ * the comparison ran — it may still be skipped for a missing account, an
38
+ * unlisted model, or an unparseable body. A header claiming a comparison
39
+ * happened would therefore sometimes be a lie, which is the exact failure this
40
+ * release exists to stamp out. The name now states only what is true at the
41
+ * moment it is written; whether it actually ran is in the record.
42
+ */
43
+ export declare const COMPARE_RESULT_HEADER = "x-dario-compare-requested";
44
+ export declare const COMPARE_DIR: string;
45
+ export interface CompareSide {
46
+ status: number | null;
47
+ /** The response payload exactly as written — SSE text or a JSON body. */
48
+ body: string;
49
+ ms: number;
50
+ }
51
+ export interface CompareRecord {
52
+ ts: string;
53
+ path: string;
54
+ shape: 'openai' | 'anthropic';
55
+ streaming: boolean;
56
+ primaryModel: string;
57
+ comparedModel: string;
58
+ /** The client's request body, verbatim, so a record replays on its own. */
59
+ request: unknown;
60
+ primary: CompareSide;
61
+ compare: CompareSide | null;
62
+ /** Why there is no compare side, when there isn't one. */
63
+ skipped?: string;
64
+ }
65
+ /**
66
+ * Read the compare target from request headers. Returns null when unarmed, and
67
+ * for the empty value, so `-H 'x-dario-compare:'` reliably means "off" rather
68
+ * than "compare against a model named empty string".
69
+ */
70
+ export declare function readCompareTarget(headers: Record<string, unknown>): string | null;
71
+ /**
72
+ * Swap the model in a JSON request body. Returns null when the body is not
73
+ * JSON — the caller then skips the comparison rather than sending something it
74
+ * could not read.
75
+ */
76
+ export declare function withModel(body: Buffer, model: string): Buffer | null;
77
+ /**
78
+ * Tee everything written to a real response into a buffer, leaving delivery
79
+ * completely unchanged. `captured()` is meaningful once the response finishes.
80
+ */
81
+ export declare function teeResponse(res: ServerResponse): {
82
+ captured: () => CompareSide;
83
+ };
84
+ /**
85
+ * Run the comparison against the ChatGPT subscription.
86
+ *
87
+ * Resolves to a reason-for-skipping string when it declines, never throwing:
88
+ * the caller is on the success path of a request it has already answered, and
89
+ * a diagnostic must not be able to take that down.
90
+ *
91
+ * v6.0.0 compares against a Codex account only. Comparing against the Claude
92
+ * pool would mean occupying a seat for a request nobody is waiting on, which is
93
+ * a trade worth making deliberately rather than by default.
94
+ */
95
+ export declare function runCompare(opts: {
96
+ body: Buffer;
97
+ shape: 'openai' | 'anthropic';
98
+ targetModel: string;
99
+ corsOrigin: string;
100
+ timeoutMs: number;
101
+ verbose: boolean;
102
+ }): Promise<{
103
+ side: CompareSide | null;
104
+ skipped?: string;
105
+ }>;
106
+ /**
107
+ * Persist one record. Returns the path written, or null on failure — a compare
108
+ * log that cannot be written is not worth failing a served request over.
109
+ */
110
+ export declare function writeCompareRecord(record: CompareRecord, dir?: string): string | null;