@askalf/dario 5.5.90 → 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 +61 -8
- package/dist/cli.js +67 -13
- package/dist/codex-backend.d.ts +23 -1
- package/dist/codex-backend.js +67 -4
- package/dist/compare.d.ts +110 -0
- package/dist/compare.js +210 -0
- package/dist/config-file.d.ts +6 -5
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.js +71 -0
- package/dist/provider-adapter.d.ts +31 -0
- package/dist/provider-adapter.js +36 -14
- package/dist/proxy.d.ts +15 -9
- package/dist/proxy.js +244 -42
- package/docs/commands.md +2 -2
- package/docs/multi-account-pool.md +30 -4
- package/package.json +1 -1
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 · ~
|
|
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
|
|
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
|
|
139
|
+
dario add altman # prints an authorize URL; paste the redirect URL back
|
|
140
140
|
dario codex list
|
|
141
|
-
dario codex remove
|
|
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,
|
|
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
|
|
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 | **~
|
|
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,
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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,
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -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
|
|
@@ -153,8 +167,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
|
|
|
153
167
|
* shapes. For the Anthropic shape the non-streaming body is rebuilt from the
|
|
154
168
|
* `response` object carried by the terminal `response.completed` event.
|
|
155
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
|
+
*
|
|
156
178
|
* `fetchImpl` is injectable so the translation and header construction are
|
|
157
179
|
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
158
180
|
* test/codex-oauth.mjs already uses.
|
|
159
181
|
*/
|
|
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<
|
|
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>;
|
package/dist/codex-backend.js
CHANGED
|
@@ -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
|
|
@@ -438,11 +469,19 @@ export function buildCodexHeaders(creds) {
|
|
|
438
469
|
* shapes. For the Anthropic shape the non-streaming body is rebuilt from the
|
|
439
470
|
* `response` object carried by the terminal `response.completed` event.
|
|
440
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
|
+
*
|
|
441
480
|
* `fetchImpl` is injectable so the translation and header construction are
|
|
442
481
|
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
443
482
|
* test/codex-oauth.mjs already uses.
|
|
444
483
|
*/
|
|
445
|
-
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) {
|
|
446
485
|
void req;
|
|
447
486
|
const isAnthropic = shape === 'anthropic';
|
|
448
487
|
// An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
|
|
@@ -457,7 +496,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
457
496
|
catch {
|
|
458
497
|
res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
459
498
|
res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
|
|
460
|
-
return;
|
|
499
|
+
return true;
|
|
461
500
|
}
|
|
462
501
|
const clientWantsStream = parsed.stream === true;
|
|
463
502
|
const model = String(parsed.model ?? '');
|
|
@@ -482,9 +521,18 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
482
521
|
const detail = await upstream.text().catch(() => '');
|
|
483
522
|
if (verbose)
|
|
484
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
|
+
}
|
|
485
533
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
486
534
|
res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
|
|
487
|
-
return;
|
|
535
|
+
return true;
|
|
488
536
|
}
|
|
489
537
|
// OpenAI shape: one stateful line-in/line-out translator (unchanged).
|
|
490
538
|
// Anthropic shape: parse the typed Responses event stream, then map events
|
|
@@ -577,7 +625,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
577
625
|
console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
|
|
578
626
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
579
627
|
res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
|
|
580
|
-
return;
|
|
628
|
+
return true;
|
|
581
629
|
}
|
|
582
630
|
if (isAnthropic) {
|
|
583
631
|
if (clientWantsStream) {
|
|
@@ -606,6 +654,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
606
654
|
});
|
|
607
655
|
res.end(JSON.stringify(translator.complete()));
|
|
608
656
|
}
|
|
657
|
+
return true;
|
|
609
658
|
}
|
|
610
659
|
catch (err) {
|
|
611
660
|
// Detail stays server-side (CodeQL js/stack-trace-exposure), same as
|
|
@@ -613,6 +662,19 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
613
662
|
const detail = err instanceof Error ? err.message : String(err);
|
|
614
663
|
if (verbose)
|
|
615
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
|
+
}
|
|
616
678
|
if (!res.headersSent) {
|
|
617
679
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
618
680
|
res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
|
|
@@ -623,6 +685,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
623
685
|
}
|
|
624
686
|
catch { /* already closed */ }
|
|
625
687
|
}
|
|
688
|
+
return true;
|
|
626
689
|
}
|
|
627
690
|
finally {
|
|
628
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;
|