@askalf/dario 5.5.89 → 6.0.0

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
 
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,
@@ -56,6 +56,20 @@ export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetch
56
56
  * configured API-key backend.
57
57
  */
58
58
  export declare function isCodexModel(model: string, slugs: readonly string[]): boolean;
59
+ /**
60
+ * A pool-fallback value may name a CHAIN — `gpt-5.6-sol,claude-sonnet-5` — and
61
+ * each provider takes the first entry it can actually serve. These two pickers
62
+ * are the whole selection rule, kept pure so it is testable without a socket.
63
+ *
64
+ * Reading the chain from both ends is what makes failover SYMMETRIC in v6.0.0:
65
+ * `pickCodexFallback` catches a drained Claude pool, `pickClaudeFallback`
66
+ * catches a ChatGPT subscription that is rate-limited or down. Neither
67
+ * subscription hitting its ceiling can take the whole deployment dark on its
68
+ * own. A single-entry chain keeps the pre-6.0 meaning exactly, so configs
69
+ * written before this release behave identically.
70
+ */
71
+ 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;
59
73
  /**
60
74
  * Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
61
75
  * claim. Payload only — this is reading our own token for a routing header, not
@@ -114,6 +128,27 @@ export declare function createResponsesTranslator(model: string): {
114
128
  /** Everything seen so far, as one non-streaming chat.completion body. */
115
129
  complete(): Record<string, unknown>;
116
130
  };
131
+ /**
132
+ * Fields the ChatGPT Codex backend accepts on /responses.
133
+ *
134
+ * It is NOT the public Responses API: it rejects a whole class of sampling and
135
+ * metadata parameters outright, one 400 at a time —
136
+ * 400 {"detail":"Unsupported parameter: <name>"}
137
+ * Probed directly against a live subscription (2026-08-30); rejected were
138
+ * temperature, top_p, max_output_tokens, presence_penalty, frequency_penalty,
139
+ * seed, metadata, top_logprobs, truncation and service_tier.
140
+ *
141
+ * This is an ALLOWLIST rather than a list of the ten known-bad names on
142
+ * purpose. The backend is undocumented and clearly restrictive, so the failure
143
+ * we must not have is "we started sending a new field and every request 400s".
144
+ * Dropping an unknown field degrades one request; sending one breaks all of
145
+ * them. Both request builders stay correct for an API-key Responses endpoint —
146
+ * which does accept these — because the scrub happens HERE, at the transport
147
+ * that knows which backend it is talking to.
148
+ */
149
+ export declare const CODEX_SUPPORTED_FIELDS: readonly string[];
150
+ /** Drop every field this backend does not accept. Pure; exported for tests. */
151
+ export declare function toCodexSupportedBody(body: Record<string, unknown>): Record<string, unknown>;
117
152
  export declare function buildCodexHeaders(creds: CodexAccountCredentials): Record<string, string>;
118
153
  /**
119
154
  * Serve a request from a stored Codex account, in either client wire shape.
@@ -132,8 +167,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
132
167
  * shapes. For the Anthropic shape the non-streaming body is rebuilt from the
133
168
  * `response` object carried by the terminal `response.completed` event.
134
169
  *
170
+ * Returns TRUE when it answered the client. With `deferOnUnavailable` it may
171
+ * instead return FALSE having written NOTHING — that is the subscription
172
+ * saying "not right now" (429, a 5xx, or a transport failure that never got a
173
+ * status at all) so the caller can fail over to another provider rather than
174
+ * pass a rate limit or an outage through to the client. It only ever declines
175
+ * before any byte is written, so a stream in flight is never abandoned
176
+ * half-sent.
177
+ *
135
178
  * `fetchImpl` is injectable so the translation and header construction are
136
179
  * testable without network (test/codex-backend.mjs), matching the pattern
137
180
  * test/codex-oauth.mjs already uses.
138
181
  */
139
- 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>;
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>;
@@ -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
@@ -377,6 +408,37 @@ export function createResponsesTranslator(model) {
377
408
  },
378
409
  };
379
410
  }
411
+ /**
412
+ * Fields the ChatGPT Codex backend accepts on /responses.
413
+ *
414
+ * It is NOT the public Responses API: it rejects a whole class of sampling and
415
+ * metadata parameters outright, one 400 at a time —
416
+ * 400 {"detail":"Unsupported parameter: <name>"}
417
+ * Probed directly against a live subscription (2026-08-30); rejected were
418
+ * temperature, top_p, max_output_tokens, presence_penalty, frequency_penalty,
419
+ * seed, metadata, top_logprobs, truncation and service_tier.
420
+ *
421
+ * This is an ALLOWLIST rather than a list of the ten known-bad names on
422
+ * purpose. The backend is undocumented and clearly restrictive, so the failure
423
+ * we must not have is "we started sending a new field and every request 400s".
424
+ * Dropping an unknown field degrades one request; sending one breaks all of
425
+ * them. Both request builders stay correct for an API-key Responses endpoint —
426
+ * which does accept these — because the scrub happens HERE, at the transport
427
+ * that knows which backend it is talking to.
428
+ */
429
+ export const CODEX_SUPPORTED_FIELDS = [
430
+ 'model', 'input', 'stream', 'store', 'instructions',
431
+ 'tools', 'tool_choice', 'parallel_tool_calls', 'reasoning',
432
+ ];
433
+ /** Drop every field this backend does not accept. Pure; exported for tests. */
434
+ export function toCodexSupportedBody(body) {
435
+ const out = {};
436
+ for (const k of CODEX_SUPPORTED_FIELDS) {
437
+ if (body[k] !== undefined)
438
+ out[k] = body[k];
439
+ }
440
+ return out;
441
+ }
380
442
  export function buildCodexHeaders(creds) {
381
443
  const headers = {
382
444
  'Content-Type': 'application/json',
@@ -407,11 +469,19 @@ export function buildCodexHeaders(creds) {
407
469
  * shapes. For the Anthropic shape the non-streaming body is rebuilt from the
408
470
  * `response` object carried by the terminal `response.completed` event.
409
471
  *
472
+ * Returns TRUE when it answered the client. With `deferOnUnavailable` it may
473
+ * instead return FALSE having written NOTHING — that is the subscription
474
+ * saying "not right now" (429, a 5xx, or a transport failure that never got a
475
+ * status at all) so the caller can fail over to another provider rather than
476
+ * pass a rate limit or an outage through to the client. It only ever declines
477
+ * before any byte is written, so a stream in flight is never abandoned
478
+ * half-sent.
479
+ *
410
480
  * `fetchImpl` is injectable so the translation and header construction are
411
481
  * testable without network (test/codex-backend.mjs), matching the pattern
412
482
  * test/codex-oauth.mjs already uses.
413
483
  */
414
- export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch) {
484
+ export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false) {
415
485
  void req;
416
486
  const isAnthropic = shape === 'anthropic';
417
487
  // An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
@@ -426,7 +496,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
426
496
  catch {
427
497
  res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
428
498
  res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
429
- return;
499
+ return true;
430
500
  }
431
501
  const clientWantsStream = parsed.stream === true;
432
502
  const model = String(parsed.model ?? '');
@@ -434,17 +504,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
434
504
  const upstreamBody = isAnthropic
435
505
  ? { ...anthropicToResponsesRequest(parsed, model), stream: true }
436
506
  : chatCompletionsToResponses(parsed);
437
- // The ChatGPT Codex backend REJECTS an output cap outright:
438
- // 400 {"detail":"Unsupported parameter: max_output_tokens"}
439
- // Both builders set it from the client's max_tokens / max_completion_tokens,
440
- // so BOTH shapes 400 whenever a client asks for one. It stayed hidden because
441
- // every smoke test so far happened to omit max_tokens; it then showed up as a
442
- // 100% failure on the Anthropic path, where the Messages API REQUIRES
443
- // max_tokens and so always produced it. Stripped here rather than in either
444
- // translator because it is a property of THIS backend, not of either wire
445
- // format — the same builders are correct against an API-key Responses
446
- // endpoint, which does support the parameter.
447
- delete upstreamBody.max_output_tokens;
507
+ const scrubbed = toCodexSupportedBody(upstreamBody);
448
508
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
449
509
  const abort = new AbortController();
450
510
  const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
@@ -454,16 +514,25 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
454
514
  const upstream = await fetchImpl(target, {
455
515
  method: 'POST',
456
516
  headers: buildCodexHeaders(creds),
457
- body: JSON.stringify(upstreamBody),
517
+ body: JSON.stringify(scrubbed),
458
518
  signal: abort.signal,
459
519
  });
460
520
  if (!upstream.ok) {
461
521
  const detail = await upstream.text().catch(() => '');
462
522
  if (verbose)
463
523
  console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
524
+ // "Not right now" — a rate limit or an upstream fault — is the caller's
525
+ // cue to fail over, not something to hand the client. A 4xx that is our
526
+ // own fault (a bad body, an unsupported parameter) is NOT: failing over
527
+ // would just reproduce it somewhere else and hide the real error.
528
+ const unavailable = upstream.status === 429 || upstream.status >= 500;
529
+ if (deferOnUnavailable && unavailable) {
530
+ console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring to the next provider`);
531
+ return false;
532
+ }
464
533
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
465
534
  res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
466
- return;
535
+ return true;
467
536
  }
468
537
  // OpenAI shape: one stateful line-in/line-out translator (unchanged).
469
538
  // Anthropic shape: parse the typed Responses event stream, then map events
@@ -556,7 +625,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
556
625
  console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
557
626
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
558
627
  res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
559
- return;
628
+ return true;
560
629
  }
561
630
  if (isAnthropic) {
562
631
  if (clientWantsStream) {
@@ -585,6 +654,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
585
654
  });
586
655
  res.end(JSON.stringify(translator.complete()));
587
656
  }
657
+ return true;
588
658
  }
589
659
  catch (err) {
590
660
  // Detail stays server-side (CodeQL js/stack-trace-exposure), same as
@@ -592,6 +662,19 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
592
662
  const detail = err instanceof Error ? err.message : String(err);
593
663
  if (verbose)
594
664
  console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
665
+ // A transport failure before any byte was written is the same "not right
666
+ // now" as a 429: DNS, a refused connection, a reset socket or our own
667
+ // upstream timeout all mean this subscription did not answer, and none of
668
+ // them is the client's bad request. Deferring here is what makes failover
669
+ // cover the outage case rather than only the rate-limit case — without it
670
+ // a ChatGPT backend that is merely unreachable is terminal for the request
671
+ // even with an idle Claude pool beside it. The headersSent guard keeps the
672
+ // contract intact: once a stream is in flight it is far too late to hand
673
+ // the request to anyone else.
674
+ if (deferOnUnavailable && !res.headersSent) {
675
+ console.log(`[dario] codex account ${creds.alias} unreachable (${detail}) — deferring to the next provider`);
676
+ return false;
677
+ }
595
678
  if (!res.headersSent) {
596
679
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
597
680
  res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
@@ -602,6 +685,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
602
685
  }
603
686
  catch { /* already closed */ }
604
687
  }
688
+ return true;
605
689
  }
606
690
  finally {
607
691
  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;