@askalf/dario 5.5.90 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -8
- package/dist/analytics.d.ts +2 -0
- package/dist/analytics.js +9 -0
- package/dist/cli.js +67 -13
- package/dist/codex-backend.d.ts +47 -1
- package/dist/codex-backend.js +107 -10
- 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 +300 -44
- 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/analytics.d.ts
CHANGED
|
@@ -63,6 +63,8 @@ export declare function billingBucketFromClaim(claim: string | null | undefined)
|
|
|
63
63
|
* non-subscription billing classification or the `unknown` sentinel below.
|
|
64
64
|
*/
|
|
65
65
|
export declare const SUBSCRIPTION_CLAIMS: ReadonlySet<string>;
|
|
66
|
+
/** The claim the proxy stamps on codex-engine requests (see above). */
|
|
67
|
+
export declare const CODEX_CLAIM = "chatgpt_subscription";
|
|
66
68
|
/**
|
|
67
69
|
* One-line per-request usage summary for verbose (-v / -vv) logs.
|
|
68
70
|
*
|
package/dist/analytics.js
CHANGED
|
@@ -35,6 +35,7 @@ export function billingBucketFromClaim(claim) {
|
|
|
35
35
|
// (30-min cooldown loops) exactly when the weekly window tightens.
|
|
36
36
|
case 'five_hour_overage_included':
|
|
37
37
|
case 'seven_day_overage_included':
|
|
38
|
+
case 'chatgpt_subscription':
|
|
38
39
|
return 'subscription';
|
|
39
40
|
case 'five_hour_fallback':
|
|
40
41
|
case 'seven_day_fallback':
|
|
@@ -61,7 +62,15 @@ export const SUBSCRIPTION_CLAIMS = new Set([
|
|
|
61
62
|
'seven_day_fallback',
|
|
62
63
|
'five_hour_overage_included',
|
|
63
64
|
'seven_day_overage_included',
|
|
65
|
+
// The codex engine: a request served from a ChatGPT-subscription account
|
|
66
|
+
// (dario#1009). There is no Anthropic claim header on that path; the proxy
|
|
67
|
+
// stamps this one. It is subscription billing — the user's ChatGPT plan —
|
|
68
|
+
// so it must be recognised here, or the overage guard reads it as
|
|
69
|
+
// pay-as-you-go and halts the proxy after the first GPT request.
|
|
70
|
+
'chatgpt_subscription',
|
|
64
71
|
]);
|
|
72
|
+
/** The claim the proxy stamps on codex-engine requests (see above). */
|
|
73
|
+
export const CODEX_CLAIM = 'chatgpt_subscription';
|
|
65
74
|
/**
|
|
66
75
|
* One-line per-request usage summary for verbose (-v / -vv) logs.
|
|
67
76
|
*
|
package/dist/cli.js
CHANGED
|
@@ -534,11 +534,12 @@ async function proxy() {
|
|
|
534
534
|
// DARIO_PASSTHROUGH_BETAS env var.
|
|
535
535
|
const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
|
|
536
536
|
// --pool-fallback=<model> / DARIO_POOL_FALLBACK / config poolFallback.model
|
|
537
|
-
// — strictly opt-in. When the Claude pool can't serve,
|
|
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
|
@@ -46,6 +46,22 @@ export declare function fetchCodexModels(creds: CodexAccountCredentials, fetchIm
|
|
|
46
46
|
* "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
|
|
47
47
|
* routes, so discovery being down never makes the engine unusable.
|
|
48
48
|
*/
|
|
49
|
+
/** What the proxy learns from one forwarded codex request — enough for an
|
|
50
|
+
* analytics row and a log line. Reported once per request, on every exit
|
|
51
|
+
* that answered the client; a DECLINE (deferred to the Claude pool) reports
|
|
52
|
+
* nothing, since the Claude path records what it then serves. */
|
|
53
|
+
export interface CodexForwardOutcome {
|
|
54
|
+
status: number;
|
|
55
|
+
latencyMs: number;
|
|
56
|
+
inputTokens: number;
|
|
57
|
+
outputTokens: number;
|
|
58
|
+
stream: boolean;
|
|
59
|
+
model: string;
|
|
60
|
+
alias: string;
|
|
61
|
+
}
|
|
62
|
+
/** The cached slug list for an alias WITHOUT fetching. For the admin surface:
|
|
63
|
+
* a status read must never cost an upstream call or a token refresh. */
|
|
64
|
+
export declare function peekCodexModelSlugs(alias: string): readonly string[] | null;
|
|
49
65
|
export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetchImpl?: typeof fetch): Promise<readonly string[]>;
|
|
50
66
|
/**
|
|
51
67
|
* Whether a request naming `model` should be served from the subscription: the
|
|
@@ -56,6 +72,20 @@ export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetch
|
|
|
56
72
|
* configured API-key backend.
|
|
57
73
|
*/
|
|
58
74
|
export declare function isCodexModel(model: string, slugs: readonly string[]): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* A pool-fallback value may name a CHAIN — `gpt-5.6-sol,claude-sonnet-5` — and
|
|
77
|
+
* each provider takes the first entry it can actually serve. These two pickers
|
|
78
|
+
* are the whole selection rule, kept pure so it is testable without a socket.
|
|
79
|
+
*
|
|
80
|
+
* Reading the chain from both ends is what makes failover SYMMETRIC in v6.0.0:
|
|
81
|
+
* `pickCodexFallback` catches a drained Claude pool, `pickClaudeFallback`
|
|
82
|
+
* catches a ChatGPT subscription that is rate-limited or down. Neither
|
|
83
|
+
* subscription hitting its ceiling can take the whole deployment dark on its
|
|
84
|
+
* own. A single-entry chain keeps the pre-6.0 meaning exactly, so configs
|
|
85
|
+
* written before this release behave identically.
|
|
86
|
+
*/
|
|
87
|
+
export declare function pickCodexFallback(models: readonly string[], slugs: readonly string[]): string | null;
|
|
88
|
+
export declare function pickClaudeFallback(models: readonly string[], slugs: readonly string[]): string | null;
|
|
59
89
|
/**
|
|
60
90
|
* Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
|
|
61
91
|
* claim. Payload only — this is reading our own token for a routing header, not
|
|
@@ -111,6 +141,14 @@ export declare function createResponsesTranslator(model: string): {
|
|
|
111
141
|
chunk(line: string): string | null;
|
|
112
142
|
/** True when the upstream stream terminated as a FAILURE. */
|
|
113
143
|
didFail(): boolean;
|
|
144
|
+
/** Token usage from the terminal event, or null if none arrived. Read by
|
|
145
|
+
* the proxy to record the request in analytics — before this, codex
|
|
146
|
+
* requests were invisible to /analytics and the request log entirely. */
|
|
147
|
+
usage(): {
|
|
148
|
+
prompt_tokens: number;
|
|
149
|
+
completion_tokens: number;
|
|
150
|
+
total_tokens: number;
|
|
151
|
+
} | null;
|
|
114
152
|
/** Everything seen so far, as one non-streaming chat.completion body. */
|
|
115
153
|
complete(): Record<string, unknown>;
|
|
116
154
|
};
|
|
@@ -153,8 +191,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
|
|
|
153
191
|
* shapes. For the Anthropic shape the non-streaming body is rebuilt from the
|
|
154
192
|
* `response` object carried by the terminal `response.completed` event.
|
|
155
193
|
*
|
|
194
|
+
* Returns TRUE when it answered the client. With `deferOnUnavailable` it may
|
|
195
|
+
* instead return FALSE having written NOTHING — that is the subscription
|
|
196
|
+
* saying "not right now" (429, a 5xx, or a transport failure that never got a
|
|
197
|
+
* status at all) so the caller can fail over to another provider rather than
|
|
198
|
+
* pass a rate limit or an outage through to the client. It only ever declines
|
|
199
|
+
* before any byte is written, so a stream in flight is never abandoned
|
|
200
|
+
* half-sent.
|
|
201
|
+
*
|
|
156
202
|
* `fetchImpl` is injectable so the translation and header construction are
|
|
157
203
|
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
158
204
|
* test/codex-oauth.mjs already uses.
|
|
159
205
|
*/
|
|
160
|
-
export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, shape?: CodexRequestShape, fetchImpl?: typeof fetch): Promise<
|
|
206
|
+
export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, shape?: CodexRequestShape, fetchImpl?: typeof fetch, deferOnUnavailable?: boolean, onDone?: (outcome: CodexForwardOutcome) => void): Promise<boolean>;
|
package/dist/codex-backend.js
CHANGED
|
@@ -59,12 +59,12 @@ export async function fetchCodexModels(creds, fetchImpl = fetch) {
|
|
|
59
59
|
}
|
|
60
60
|
return slugs;
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
/** The cached slug list for an alias WITHOUT fetching. For the admin surface:
|
|
63
|
+
* a status read must never cost an upstream call or a token refresh. */
|
|
64
|
+
export function peekCodexModelSlugs(alias) {
|
|
65
|
+
const hit = modelCache.get(alias);
|
|
66
|
+
return hit ? hit.slugs : null;
|
|
67
|
+
}
|
|
68
68
|
export async function getCodexModelSlugs(creds, fetchImpl = fetch) {
|
|
69
69
|
const hit = modelCache.get(creds.alias);
|
|
70
70
|
if (hit && Date.now() - hit.fetchedAt < hit.ttlMs)
|
|
@@ -94,6 +94,37 @@ export function isCodexModel(model, slugs) {
|
|
|
94
94
|
const m = model.toLowerCase();
|
|
95
95
|
return slugs.some(s => s.toLowerCase() === m);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* A pool-fallback value may name a CHAIN — `gpt-5.6-sol,claude-sonnet-5` — and
|
|
99
|
+
* each provider takes the first entry it can actually serve. These two pickers
|
|
100
|
+
* are the whole selection rule, kept pure so it is testable without a socket.
|
|
101
|
+
*
|
|
102
|
+
* Reading the chain from both ends is what makes failover SYMMETRIC in v6.0.0:
|
|
103
|
+
* `pickCodexFallback` catches a drained Claude pool, `pickClaudeFallback`
|
|
104
|
+
* catches a ChatGPT subscription that is rate-limited or down. Neither
|
|
105
|
+
* subscription hitting its ceiling can take the whole deployment dark on its
|
|
106
|
+
* own. A single-entry chain keeps the pre-6.0 meaning exactly, so configs
|
|
107
|
+
* written before this release behave identically.
|
|
108
|
+
*/
|
|
109
|
+
export function pickCodexFallback(models, slugs) {
|
|
110
|
+
return models.find(m => isCodexModel(m, slugs)) ?? null;
|
|
111
|
+
}
|
|
112
|
+
export function pickClaudeFallback(models, slugs) {
|
|
113
|
+
// "Not a codex slug" is NOT the same as "the Claude pool can serve it". A
|
|
114
|
+
// typo, a retired model, or an entry meant for some third provider would all
|
|
115
|
+
// pass that test, and the request would be swapped to a model Anthropic 404s
|
|
116
|
+
// on — trading a recoverable 429 for an unrecoverable 404, which is strictly
|
|
117
|
+
// worse than not failing over at all.
|
|
118
|
+
//
|
|
119
|
+
// So require it to look like an Anthropic model. Every model the pool serves
|
|
120
|
+
// is `claude-*`; anything else means the chain has no Claude entry and the
|
|
121
|
+
// codex error surfaces honestly. Failing CLOSED is the right direction here.
|
|
122
|
+
//
|
|
123
|
+
// Known limitation: a `--model-alias` that resolves to a Claude model is not
|
|
124
|
+
// accepted, because aliases resolve later in the request path than this. Name
|
|
125
|
+
// the real model id in the chain.
|
|
126
|
+
return models.find(m => !isCodexModel(m, slugs) && /^claude/i.test(m)) ?? null;
|
|
127
|
+
}
|
|
97
128
|
/**
|
|
98
129
|
* Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
|
|
99
130
|
* claim. Payload only — this is reading our own token for a routing header, not
|
|
@@ -355,6 +386,12 @@ export function createResponsesTranslator(model) {
|
|
|
355
386
|
didFail() {
|
|
356
387
|
return failed;
|
|
357
388
|
},
|
|
389
|
+
/** Token usage from the terminal event, or null if none arrived. Read by
|
|
390
|
+
* the proxy to record the request in analytics — before this, codex
|
|
391
|
+
* requests were invisible to /analytics and the request log entirely. */
|
|
392
|
+
usage() {
|
|
393
|
+
return usage;
|
|
394
|
+
},
|
|
358
395
|
/** Everything seen so far, as one non-streaming chat.completion body. */
|
|
359
396
|
complete() {
|
|
360
397
|
const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
|
@@ -438,13 +475,35 @@ export function buildCodexHeaders(creds) {
|
|
|
438
475
|
* shapes. For the Anthropic shape the non-streaming body is rebuilt from the
|
|
439
476
|
* `response` object carried by the terminal `response.completed` event.
|
|
440
477
|
*
|
|
478
|
+
* Returns TRUE when it answered the client. With `deferOnUnavailable` it may
|
|
479
|
+
* instead return FALSE having written NOTHING — that is the subscription
|
|
480
|
+
* saying "not right now" (429, a 5xx, or a transport failure that never got a
|
|
481
|
+
* status at all) so the caller can fail over to another provider rather than
|
|
482
|
+
* pass a rate limit or an outage through to the client. It only ever declines
|
|
483
|
+
* before any byte is written, so a stream in flight is never abandoned
|
|
484
|
+
* half-sent.
|
|
485
|
+
*
|
|
441
486
|
* `fetchImpl` is injectable so the translation and header construction are
|
|
442
487
|
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
443
488
|
* test/codex-oauth.mjs already uses.
|
|
444
489
|
*/
|
|
445
|
-
export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch) {
|
|
490
|
+
export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false, onDone) {
|
|
446
491
|
void req;
|
|
447
492
|
const isAnthropic = shape === 'anthropic';
|
|
493
|
+
// Reported exactly once, on every exit that answered the client. Without
|
|
494
|
+
// this the proxy had no idea a codex request happened: no analytics row, no
|
|
495
|
+
// log line, no per-account count.
|
|
496
|
+
const startedAt = Date.now();
|
|
497
|
+
let reported = false;
|
|
498
|
+
const report = (status, usage, stream, model) => {
|
|
499
|
+
if (reported || !onDone)
|
|
500
|
+
return;
|
|
501
|
+
reported = true;
|
|
502
|
+
try {
|
|
503
|
+
onDone({ status, latencyMs: Date.now() - startedAt, inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0, stream, model, alias: creds.alias });
|
|
504
|
+
}
|
|
505
|
+
catch { /* a reporting failure must never break a served request */ }
|
|
506
|
+
};
|
|
448
507
|
// An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
|
|
449
508
|
// is {error}. A client SDK reads its own shape, so errors follow the request.
|
|
450
509
|
const errBody = (message, extra = {}) => JSON.stringify(isAnthropic
|
|
@@ -457,7 +516,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
457
516
|
catch {
|
|
458
517
|
res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
459
518
|
res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
|
|
460
|
-
|
|
519
|
+
report(400, null, false, '');
|
|
520
|
+
return true;
|
|
461
521
|
}
|
|
462
522
|
const clientWantsStream = parsed.stream === true;
|
|
463
523
|
const model = String(parsed.model ?? '');
|
|
@@ -482,9 +542,19 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
482
542
|
const detail = await upstream.text().catch(() => '');
|
|
483
543
|
if (verbose)
|
|
484
544
|
console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
|
|
545
|
+
// "Not right now" — a rate limit or an upstream fault — is the caller's
|
|
546
|
+
// cue to fail over, not something to hand the client. A 4xx that is our
|
|
547
|
+
// own fault (a bad body, an unsupported parameter) is NOT: failing over
|
|
548
|
+
// would just reproduce it somewhere else and hide the real error.
|
|
549
|
+
const unavailable = upstream.status === 429 || upstream.status >= 500;
|
|
550
|
+
if (deferOnUnavailable && unavailable) {
|
|
551
|
+
console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring to the next provider`);
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
485
554
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
486
555
|
res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
|
|
487
|
-
|
|
556
|
+
report(upstream.status, null, clientWantsStream, model);
|
|
557
|
+
return true;
|
|
488
558
|
}
|
|
489
559
|
// OpenAI shape: one stateful line-in/line-out translator (unchanged).
|
|
490
560
|
// Anthropic shape: parse the typed Responses event stream, then map events
|
|
@@ -577,7 +647,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
577
647
|
console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
|
|
578
648
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
579
649
|
res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
|
|
580
|
-
|
|
650
|
+
report(502, null, clientWantsStream, model);
|
|
651
|
+
return true;
|
|
581
652
|
}
|
|
582
653
|
if (isAnthropic) {
|
|
583
654
|
if (clientWantsStream) {
|
|
@@ -606,6 +677,17 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
606
677
|
});
|
|
607
678
|
res.end(JSON.stringify(translator.complete()));
|
|
608
679
|
}
|
|
680
|
+
// Token usage rides the terminal Responses event on either shape. A stream
|
|
681
|
+
// that failed upstream still ended as 200 on the wire (the client saw the
|
|
682
|
+
// failure event); analytics must count it as the 502 it was.
|
|
683
|
+
{
|
|
684
|
+
const tr = terminalResponse;
|
|
685
|
+
const usage = isAnthropic
|
|
686
|
+
? (tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null)
|
|
687
|
+
: (() => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; })();
|
|
688
|
+
report(upstreamFailed ? 502 : 200, usage, clientWantsStream, model);
|
|
689
|
+
}
|
|
690
|
+
return true;
|
|
609
691
|
}
|
|
610
692
|
catch (err) {
|
|
611
693
|
// Detail stays server-side (CodeQL js/stack-trace-exposure), same as
|
|
@@ -613,6 +695,19 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
613
695
|
const detail = err instanceof Error ? err.message : String(err);
|
|
614
696
|
if (verbose)
|
|
615
697
|
console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
|
|
698
|
+
// A transport failure before any byte was written is the same "not right
|
|
699
|
+
// now" as a 429: DNS, a refused connection, a reset socket or our own
|
|
700
|
+
// upstream timeout all mean this subscription did not answer, and none of
|
|
701
|
+
// them is the client's bad request. Deferring here is what makes failover
|
|
702
|
+
// cover the outage case rather than only the rate-limit case — without it
|
|
703
|
+
// a ChatGPT backend that is merely unreachable is terminal for the request
|
|
704
|
+
// even with an idle Claude pool beside it. The headersSent guard keeps the
|
|
705
|
+
// contract intact: once a stream is in flight it is far too late to hand
|
|
706
|
+
// the request to anyone else.
|
|
707
|
+
if (deferOnUnavailable && !res.headersSent) {
|
|
708
|
+
console.log(`[dario] codex account ${creds.alias} unreachable (${detail}) — deferring to the next provider`);
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
616
711
|
if (!res.headersSent) {
|
|
617
712
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
618
713
|
res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
|
|
@@ -623,6 +718,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
623
718
|
}
|
|
624
719
|
catch { /* already closed */ }
|
|
625
720
|
}
|
|
721
|
+
report(502, null, false, '');
|
|
722
|
+
return true;
|
|
626
723
|
}
|
|
627
724
|
finally {
|
|
628
725
|
clearTimeout(timeout);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow compare (v6.0.0) — run one prompt past a second model family and keep
|
|
3
|
+
* both answers, without the client ever knowing.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SHAPE. Once dario can serve either wire shape from either
|
|
6
|
+
* subscription, the obvious question stops being "can I reach GPT" and becomes
|
|
7
|
+
* "which of these two is actually better at MY work". Answering that from
|
|
8
|
+
* benchmarks is close to worthless; answering it from your own real traffic is
|
|
9
|
+
* not. So a compare is triggered per-request by a header, on prompts you were
|
|
10
|
+
* sending anyway.
|
|
11
|
+
*
|
|
12
|
+
* THE CLIENT IS NEVER AFFECTED. The primary answer streams through untouched
|
|
13
|
+
* and the comparison runs beside it — the request is not held open for it, and
|
|
14
|
+
* a compare that fails, times out, or has nowhere to go is dropped silently
|
|
15
|
+
* with the record still written. A diagnostic that can degrade the thing it is
|
|
16
|
+
* measuring is worse than no diagnostic.
|
|
17
|
+
*
|
|
18
|
+
* BOTH SIDES ARE RECORDED IN THE CLIENT'S OWN WIRE SHAPE. The comparison is
|
|
19
|
+
* dispatched through the same forwardToCodex translation the real path uses, so
|
|
20
|
+
* an Anthropic-shape request yields two Anthropic-shape answers. Comparing a
|
|
21
|
+
* Messages response against a raw Responses payload would mean eyeballing
|
|
22
|
+
* across two formats and calling the difference a model difference.
|
|
23
|
+
*
|
|
24
|
+
* Raw payloads are stored rather than extracted text: extraction is exactly
|
|
25
|
+
* where a subtle bug would quietly make two answers look more alike than they
|
|
26
|
+
* are, and this release was written off the back of five failures that read as
|
|
27
|
+
* successes.
|
|
28
|
+
*/
|
|
29
|
+
import type { ServerResponse } from 'node:http';
|
|
30
|
+
/** Request header that arms a comparison: `x-dario-compare: <model>`. */
|
|
31
|
+
export declare const COMPARE_HEADER = "x-dario-compare";
|
|
32
|
+
/**
|
|
33
|
+
* Response header naming the model a comparison was REQUESTED against.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately not "compared-with". The header has to be set before the primary
|
|
36
|
+
* response starts streaming, which is strictly earlier than we can know whether
|
|
37
|
+
* the comparison ran — it may still be skipped for a missing account, an
|
|
38
|
+
* unlisted model, or an unparseable body. A header claiming a comparison
|
|
39
|
+
* happened would therefore sometimes be a lie, which is the exact failure this
|
|
40
|
+
* release exists to stamp out. The name now states only what is true at the
|
|
41
|
+
* moment it is written; whether it actually ran is in the record.
|
|
42
|
+
*/
|
|
43
|
+
export declare const COMPARE_RESULT_HEADER = "x-dario-compare-requested";
|
|
44
|
+
export declare const COMPARE_DIR: string;
|
|
45
|
+
export interface CompareSide {
|
|
46
|
+
status: number | null;
|
|
47
|
+
/** The response payload exactly as written — SSE text or a JSON body. */
|
|
48
|
+
body: string;
|
|
49
|
+
ms: number;
|
|
50
|
+
}
|
|
51
|
+
export interface CompareRecord {
|
|
52
|
+
ts: string;
|
|
53
|
+
path: string;
|
|
54
|
+
shape: 'openai' | 'anthropic';
|
|
55
|
+
streaming: boolean;
|
|
56
|
+
primaryModel: string;
|
|
57
|
+
comparedModel: string;
|
|
58
|
+
/** The client's request body, verbatim, so a record replays on its own. */
|
|
59
|
+
request: unknown;
|
|
60
|
+
primary: CompareSide;
|
|
61
|
+
compare: CompareSide | null;
|
|
62
|
+
/** Why there is no compare side, when there isn't one. */
|
|
63
|
+
skipped?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read the compare target from request headers. Returns null when unarmed, and
|
|
67
|
+
* for the empty value, so `-H 'x-dario-compare:'` reliably means "off" rather
|
|
68
|
+
* than "compare against a model named empty string".
|
|
69
|
+
*/
|
|
70
|
+
export declare function readCompareTarget(headers: Record<string, unknown>): string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Swap the model in a JSON request body. Returns null when the body is not
|
|
73
|
+
* JSON — the caller then skips the comparison rather than sending something it
|
|
74
|
+
* could not read.
|
|
75
|
+
*/
|
|
76
|
+
export declare function withModel(body: Buffer, model: string): Buffer | null;
|
|
77
|
+
/**
|
|
78
|
+
* Tee everything written to a real response into a buffer, leaving delivery
|
|
79
|
+
* completely unchanged. `captured()` is meaningful once the response finishes.
|
|
80
|
+
*/
|
|
81
|
+
export declare function teeResponse(res: ServerResponse): {
|
|
82
|
+
captured: () => CompareSide;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Run the comparison against the ChatGPT subscription.
|
|
86
|
+
*
|
|
87
|
+
* Resolves to a reason-for-skipping string when it declines, never throwing:
|
|
88
|
+
* the caller is on the success path of a request it has already answered, and
|
|
89
|
+
* a diagnostic must not be able to take that down.
|
|
90
|
+
*
|
|
91
|
+
* v6.0.0 compares against a Codex account only. Comparing against the Claude
|
|
92
|
+
* pool would mean occupying a seat for a request nobody is waiting on, which is
|
|
93
|
+
* a trade worth making deliberately rather than by default.
|
|
94
|
+
*/
|
|
95
|
+
export declare function runCompare(opts: {
|
|
96
|
+
body: Buffer;
|
|
97
|
+
shape: 'openai' | 'anthropic';
|
|
98
|
+
targetModel: string;
|
|
99
|
+
corsOrigin: string;
|
|
100
|
+
timeoutMs: number;
|
|
101
|
+
verbose: boolean;
|
|
102
|
+
}): Promise<{
|
|
103
|
+
side: CompareSide | null;
|
|
104
|
+
skipped?: string;
|
|
105
|
+
}>;
|
|
106
|
+
/**
|
|
107
|
+
* Persist one record. Returns the path written, or null on failure — a compare
|
|
108
|
+
* log that cannot be written is not worth failing a served request over.
|
|
109
|
+
*/
|
|
110
|
+
export declare function writeCompareRecord(record: CompareRecord, dir?: string): string | null;
|