@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 +61 -8
- package/dist/cli.js +67 -13
- package/dist/codex-backend.d.ts +44 -1
- package/dist/codex-backend.js +100 -16
- 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
|
|
@@ -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<
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
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;
|