@askalf/dario 6.2.0 → 6.3.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 +23 -3
- package/dist/codex-backend.d.ts +14 -0
- package/dist/codex-backend.js +142 -0
- package/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +26 -0
- package/dist/midstream.d.ts +28 -0
- package/dist/midstream.js +56 -0
- package/dist/proxy.js +103 -16
- package/dist/responses-inbound.d.ts +108 -0
- package/dist/responses-inbound.js +616 -0
- package/docs/drift-monitor.md +6 -0
- package/docs/integrations/codex-cli.md +94 -0
- package/docs/midstream-continuation.md +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
<p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
|
|
29
29
|
|
|
30
|
-
<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 · ~
|
|
30
|
+
<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 · ~35k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
|
|
31
31
|
|
|
32
32
|
<sub><a href="#start-in-60-seconds">Start</a> · <a href="#point-your-tools-at-it">Your tools</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#two-plans-one-endpoint">Two plans</a> · <a href="#many-seats-one-endpoint">Pool</a> · <a href="#it-tracks-a-moving-target">Drift</a> · <a href="#trust--transparency">Trust</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#commands">Commands</a> · <a href="#faq">FAQ</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
|
|
33
33
|
|
|
@@ -69,7 +69,25 @@ Prefer Docker? `ghcr.io/askalf/dario:latest` — multi-arch (`amd64` + `arm64`),
|
|
|
69
69
|
|
|
70
70
|
## Point your tools at it
|
|
71
71
|
|
|
72
|
-
Two base URLs, one key. Anthropic-shaped clients talk to `http://localhost:3456`; OpenAI-shaped clients talk to `http://localhost:3456/v1`. The key is `dario` (any value works until you set `DARIO_API_KEY`, which then has to match).
|
|
72
|
+
Two base URLs, one key. Anthropic-shaped clients talk to `http://localhost:3456`; OpenAI-shaped clients — chat/completions and, since 6.3, the Responses API — talk to `http://localhost:3456/v1`. The key is `dario` (any value works until you set `DARIO_API_KEY`, which then has to match).
|
|
73
|
+
|
|
74
|
+
<details>
|
|
75
|
+
<summary><strong>Codex CLI</strong> — OpenAI's agent, on your Claude plan</summary>
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
# ~/.codex/config.toml
|
|
79
|
+
model = "claude-opus-5"
|
|
80
|
+
model_provider = "dario"
|
|
81
|
+
|
|
82
|
+
[model_providers.dario]
|
|
83
|
+
name = "dario"
|
|
84
|
+
base_url = "http://127.0.0.1:3456/v1"
|
|
85
|
+
env_key = "DARIO_API_KEY"
|
|
86
|
+
wire_api = "responses"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Codex CLI 0.154 dropped the chat wire for custom providers, so dario speaks the Responses API: the request is translated once at the front door and served like any Claude request — pool, failover, mid-stream continuation, the 17 KB Codex system prompt cached on the Claude side. The full agent loop runs: Claude calls `exec_command`, Codex executes it, the result goes back, Claude answers from it. Add a ChatGPT account too and `-m gpt-5.6-sol` on the same block goes to that plan through the same proxy. [Walkthrough and what is dropped](./docs/integrations/codex-cli.md).
|
|
90
|
+
</details>
|
|
73
91
|
|
|
74
92
|
<details>
|
|
75
93
|
<summary><strong>Claude Code</strong> — forwarded verbatim</summary>
|
|
@@ -249,7 +267,7 @@ The tool doesn't know. The backend doesn't know. dario is the seam.
|
|
|
249
267
|
|
|
250
268
|
### Your ChatGPT plan, on both endpoints
|
|
251
269
|
|
|
252
|
-
A ChatGPT Plus or Pro plan is served on **
|
|
270
|
+
A ChatGPT Plus or Pro plan is served on **all three** of dario's endpoints: any client that speaks `/v1/chat/completions` or `/v1/responses` can use it (Codex CLI, the OpenAI SDKs, the Agents SDK, your scripts), and so can any client that speaks `/v1/messages` (Claude Code, the Anthropic SDKs, agent runtimes). The harness never needs to know which subscription is behind it — and the symmetry holds: Codex CLI runs on a Claude plan the same way.
|
|
253
271
|
|
|
254
272
|
```bash
|
|
255
273
|
dario add altman # prints an authorize URL; paste the redirect URL back
|
|
@@ -556,6 +574,8 @@ Full FAQ, including per-tool 401s and Team/Enterprise plans: [faq.md](./docs/faq
|
|
|
556
574
|
|
|
557
575
|
## Deep dives
|
|
558
576
|
|
|
577
|
+
- [Claude Code wire drift](https://askalf.github.io/dario/drift-feed/) — every change to what Claude Code sends on the wire, as the template watcher observed it; [RSS](https://askalf.github.io/dario/drift-feed/feed.xml) · [JSON Feed](https://askalf.github.io/dario/drift-feed/feed.json)
|
|
578
|
+
|
|
559
579
|
- [#183 — Modifying Claude Code's system prompt doesn't change billing; stripping its constraints recovers 1.2–2.8× output](https://github.com/askalf/dario/discussions/183)
|
|
560
580
|
- [#68 — dario vs LiteLLM / OpenRouter / Kong AI Gateway (when each wins)](https://github.com/askalf/dario/discussions/68)
|
|
561
581
|
- [#14 — Template replay: why we replay the shape instead of matching signals](https://github.com/askalf/dario/discussions/14)
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -262,6 +262,20 @@ export declare const CODEX_SUPPORTED_FIELDS: readonly string[];
|
|
|
262
262
|
/** Drop every field this backend does not accept. Pure; exported for tests. */
|
|
263
263
|
export declare function toCodexSupportedBody(body: Record<string, unknown>): Record<string, unknown>;
|
|
264
264
|
export declare function buildCodexHeaders(creds: CodexAccountCredentials): Record<string, string>;
|
|
265
|
+
/**
|
|
266
|
+
* Serve a Responses-shape request (`POST /v1/responses`) from a stored Codex
|
|
267
|
+
* account with NO translation: the ChatGPT backend speaks this shape natively,
|
|
268
|
+
* so the body goes through as the client wrote it (only `stream` forced) and
|
|
269
|
+
* the backend's SSE goes back byte for byte. This is what
|
|
270
|
+
* keeps the newest Codex CLI features working on a ChatGPT plan through dario
|
|
271
|
+
* — `additional_tools` input items, `custom` tools, `reasoning.context`,
|
|
272
|
+
* `include` — none of which survive a round trip through the Messages shape.
|
|
273
|
+
*
|
|
274
|
+
* Streaming only: the backend always streams, and folding a Responses stream
|
|
275
|
+
* into a buffered response object is not built yet. A non-streaming client
|
|
276
|
+
* gets a 400 saying so.
|
|
277
|
+
*/
|
|
278
|
+
export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void): Promise<boolean>;
|
|
265
279
|
/**
|
|
266
280
|
* Serve a request from a stored Codex account, in either client wire shape.
|
|
267
281
|
*
|
package/dist/codex-backend.js
CHANGED
|
@@ -743,6 +743,148 @@ export function buildCodexHeaders(creds) {
|
|
|
743
743
|
headers['ChatGPT-Account-ID'] = accountId;
|
|
744
744
|
return headers;
|
|
745
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* Serve a Responses-shape request (`POST /v1/responses`) from a stored Codex
|
|
748
|
+
* account with NO translation: the ChatGPT backend speaks this shape natively,
|
|
749
|
+
* so the body goes through as the client wrote it (only `stream` forced) and
|
|
750
|
+
* the backend's SSE goes back byte for byte. This is what
|
|
751
|
+
* keeps the newest Codex CLI features working on a ChatGPT plan through dario
|
|
752
|
+
* — `additional_tools` input items, `custom` tools, `reasoning.context`,
|
|
753
|
+
* `include` — none of which survive a round trip through the Messages shape.
|
|
754
|
+
*
|
|
755
|
+
* Streaming only: the backend always streams, and folding a Responses stream
|
|
756
|
+
* into a buffered response object is not built yet. A non-streaming client
|
|
757
|
+
* gets a 400 saying so.
|
|
758
|
+
*/
|
|
759
|
+
export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone) {
|
|
760
|
+
const startedAt = Date.now();
|
|
761
|
+
const model = String(body.model ?? '');
|
|
762
|
+
let reported = false;
|
|
763
|
+
const report = (status, usage) => {
|
|
764
|
+
if (reported || !onDone)
|
|
765
|
+
return;
|
|
766
|
+
reported = true;
|
|
767
|
+
try {
|
|
768
|
+
onDone({ status, latencyMs: Date.now() - startedAt, inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0,
|
|
769
|
+
cacheReadTokens: usage?.cacheRead ?? 0, cacheCreateTokens: usage?.cacheCreate ?? 0, stream: true, model, alias: creds.alias });
|
|
770
|
+
}
|
|
771
|
+
catch { /* never break a served request */ }
|
|
772
|
+
};
|
|
773
|
+
if (body.stream !== true) {
|
|
774
|
+
res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
775
|
+
res.end(JSON.stringify({ error: { message: 'stream: true is required on /v1/responses for a ChatGPT-subscription model (the backend streams; buffering is not built)', type: 'invalid_request_error', param: 'stream', code: null } }));
|
|
776
|
+
report(400, null);
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
// Only `stream` is forced. `store` is the client's — sent, or omitted so
|
|
780
|
+
// the backend applies its own default: a stateful client relies on that
|
|
781
|
+
// default to make its next turn's previous_response_id resolvable, and
|
|
782
|
+
// Codex CLI sends false itself.
|
|
783
|
+
const upstreamBody = { ...body, stream: true };
|
|
784
|
+
const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
|
|
785
|
+
const abort = new AbortController();
|
|
786
|
+
let clientGone = false;
|
|
787
|
+
let finished = false;
|
|
788
|
+
const onClientClose = () => { if (!finished && !clientGone) {
|
|
789
|
+
clientGone = true;
|
|
790
|
+
if (!abort.signal.aborted)
|
|
791
|
+
abort.abort();
|
|
792
|
+
} };
|
|
793
|
+
res.on('close', onClientClose);
|
|
794
|
+
const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
|
|
795
|
+
let usage = null;
|
|
796
|
+
try {
|
|
797
|
+
if (verbose)
|
|
798
|
+
console.log(`[dario] → codex backend (responses passthrough): ${target} (model: ${model})`);
|
|
799
|
+
const upstream = await fetchImpl(target, { method: 'POST', headers: buildCodexHeaders(creds), body: JSON.stringify(upstreamBody), signal: abort.signal });
|
|
800
|
+
if (!upstream.ok || !upstream.body) {
|
|
801
|
+
const detail = await upstream.text().catch(() => '');
|
|
802
|
+
if (verbose)
|
|
803
|
+
console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
|
|
804
|
+
if (!clientGone) {
|
|
805
|
+
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
806
|
+
// The backend's own error body, already in the client's shape.
|
|
807
|
+
res.end(detail || JSON.stringify({ error: { message: 'Upstream Codex backend error', type: 'server_error', code: null, param: null } }));
|
|
808
|
+
}
|
|
809
|
+
report(clientGone ? 499 : upstream.status, null);
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': corsOrigin, ...securityHeaders });
|
|
813
|
+
const reader = upstream.body.getReader();
|
|
814
|
+
const decoder = new TextDecoder();
|
|
815
|
+
let tail = '';
|
|
816
|
+
let terminal = false;
|
|
817
|
+
try {
|
|
818
|
+
while (true) {
|
|
819
|
+
const { done, value } = await reader.read();
|
|
820
|
+
if (done)
|
|
821
|
+
break;
|
|
822
|
+
if (clientGone)
|
|
823
|
+
break;
|
|
824
|
+
res.write(value);
|
|
825
|
+
// Usage rides the terminal event; read it off the wire as it passes.
|
|
826
|
+
// The terminal event also ENDS the response here: Codex CLI closes
|
|
827
|
+
// its side the moment it has `response.completed`, and waiting for the
|
|
828
|
+
// backend's EOF instead turned every finished turn into a "client
|
|
829
|
+
// disconnected" abort in the log.
|
|
830
|
+
tail += decoder.decode(value, { stream: true });
|
|
831
|
+
const frames = tail.split('\n\n');
|
|
832
|
+
tail = frames.pop() ?? '';
|
|
833
|
+
for (const f of frames) {
|
|
834
|
+
const line = f.split('\n').find((l) => l.startsWith('data:'));
|
|
835
|
+
if (!line)
|
|
836
|
+
continue;
|
|
837
|
+
let ev;
|
|
838
|
+
try {
|
|
839
|
+
ev = JSON.parse(line.slice(5));
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (!isTerminalResponsesEvent(ev.type ?? ''))
|
|
845
|
+
continue;
|
|
846
|
+
terminal = true;
|
|
847
|
+
usage = splitResponsesUsage(ev.response?.usage) ?? usage;
|
|
848
|
+
}
|
|
849
|
+
if (terminal)
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
finally {
|
|
854
|
+
reader.releaseLock();
|
|
855
|
+
if (terminal && !abort.signal.aborted)
|
|
856
|
+
abort.abort();
|
|
857
|
+
}
|
|
858
|
+
finished = true;
|
|
859
|
+
if (!clientGone)
|
|
860
|
+
res.end();
|
|
861
|
+
report(clientGone ? 499 : 200, usage);
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
866
|
+
if (verbose)
|
|
867
|
+
console.error(`[dario] codex backend (${creds.alias}) responses passthrough error: ${detail}`);
|
|
868
|
+
if (!clientGone) {
|
|
869
|
+
if (!res.headersSent) {
|
|
870
|
+
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
871
|
+
res.end(JSON.stringify({ error: { message: 'Upstream Codex backend error', type: 'server_error', code: null, param: null } }));
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
try {
|
|
875
|
+
res.end();
|
|
876
|
+
}
|
|
877
|
+
catch { /* already closed */ }
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
report(clientGone ? 499 : 502, usage);
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
clearTimeout(timeout);
|
|
885
|
+
res.removeListener('close', onClientClose);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
746
888
|
/**
|
|
747
889
|
* Serve a request from a stored Codex account, in either client wire shape.
|
|
748
890
|
*
|
package/dist/doctor-core.d.ts
CHANGED
|
@@ -37,6 +37,21 @@ export interface Check {
|
|
|
37
37
|
* live request, and this release was built on the lesson that a green config is
|
|
38
38
|
* not a working path.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* Mid-stream continuation readiness (v6.2.1). Configuration only, like
|
|
42
|
+
* failoverReadiness: it says which of the two hops a dying stream can take on
|
|
43
|
+
* this host, not that either works. The first hop (the same model again) needs
|
|
44
|
+
* nothing; the second (the other subscription) needs the failover chain AND
|
|
45
|
+
* somewhere for it to go — the exact INERT state the Failover row exists for.
|
|
46
|
+
*/
|
|
47
|
+
export declare function continuationReadiness(input: {
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
chain: readonly string[];
|
|
50
|
+
codexAccounts: number;
|
|
51
|
+
}): {
|
|
52
|
+
status: CheckStatus;
|
|
53
|
+
detail: string;
|
|
54
|
+
};
|
|
40
55
|
export declare function failoverReadiness(input: {
|
|
41
56
|
chain: readonly string[];
|
|
42
57
|
codexAccounts: number;
|
package/dist/doctor-core.js
CHANGED
|
@@ -41,6 +41,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
41
41
|
* live request, and this release was built on the lesson that a green config is
|
|
42
42
|
* not a working path.
|
|
43
43
|
*/
|
|
44
|
+
/**
|
|
45
|
+
* Mid-stream continuation readiness (v6.2.1). Configuration only, like
|
|
46
|
+
* failoverReadiness: it says which of the two hops a dying stream can take on
|
|
47
|
+
* this host, not that either works. The first hop (the same model again) needs
|
|
48
|
+
* nothing; the second (the other subscription) needs the failover chain AND
|
|
49
|
+
* somewhere for it to go — the exact INERT state the Failover row exists for.
|
|
50
|
+
*/
|
|
51
|
+
export function continuationReadiness(input) {
|
|
52
|
+
if (!input.enabled) {
|
|
53
|
+
return { status: 'info', detail: 'off — a stream that dies mid-answer ends truncated (unset DARIO_MIDSTREAM_CONTINUE / drop --no-midstream-continue)' };
|
|
54
|
+
}
|
|
55
|
+
const secondHop = input.chain.length > 0 && input.codexAccounts > 0;
|
|
56
|
+
if (secondHop) {
|
|
57
|
+
return { status: 'ok', detail: `on: a dying stream resumes on the same model, then on ${input.chain.join(' → ')} (two hops)` };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
status: 'ok',
|
|
61
|
+
detail: 'on: a dying stream resumes on the same model only — '
|
|
62
|
+
+ (input.chain.length === 0
|
|
63
|
+
? 'add --pool-fallback for a second hop on the other subscription'
|
|
64
|
+
: 'the chain has nowhere to go for a second hop (see Failover)'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
44
67
|
export function failoverReadiness(input) {
|
|
45
68
|
const { chain, codexAccounts, backends } = input;
|
|
46
69
|
const hasCodex = codexAccounts > 0;
|
|
@@ -1170,6 +1193,9 @@ export async function runChecks(opts = {}) {
|
|
|
1170
1193
|
backends: backends.map((b) => b.name),
|
|
1171
1194
|
});
|
|
1172
1195
|
checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
|
|
1196
|
+
const midstreamEnabled = !['0', 'false', 'no', 'off'].includes((process.env.DARIO_MIDSTREAM_CONTINUE ?? '').toLowerCase());
|
|
1197
|
+
const cont = continuationReadiness({ enabled: midstreamEnabled, chain, codexAccounts: codexAliases.length });
|
|
1198
|
+
checks.push({ status: cont.status, label: 'Continuation', detail: cont.detail });
|
|
1173
1199
|
}
|
|
1174
1200
|
catch (err) {
|
|
1175
1201
|
checks.push({ status: 'warn', label: 'Failover', detail: `check failed: ${err.message}` });
|
package/dist/midstream.d.ts
CHANGED
|
@@ -334,6 +334,34 @@ export declare class MidstreamGuard {
|
|
|
334
334
|
private continueFrom;
|
|
335
335
|
private log;
|
|
336
336
|
}
|
|
337
|
+
export interface ChaosCutOptions {
|
|
338
|
+
/** Characters of answer text an upstream stream is allowed before it is cut. */
|
|
339
|
+
afterChars: number;
|
|
340
|
+
/** How many streams to cut before the tap goes quiet (default 1). */
|
|
341
|
+
streams?: number;
|
|
342
|
+
log?: (line: string) => void;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The remaining-cuts counter, shared by every wrapper the proxy makes. The
|
|
346
|
+
* Claude leg and the codex leg wrap different fetch implementations, and a
|
|
347
|
+
* counter per wrapper would cut up to twice the promised number of streams
|
|
348
|
+
* (review finding on #1290): one budget for the proxy, not one per provider.
|
|
349
|
+
*/
|
|
350
|
+
export interface ChaosCutState {
|
|
351
|
+
left: number;
|
|
352
|
+
}
|
|
353
|
+
export declare function chaosCutState(o: ChaosCutOptions): ChaosCutState;
|
|
354
|
+
/**
|
|
355
|
+
* Wraps an upstream fetch so that the first `streams` streamed answers die
|
|
356
|
+
* after `afterChars` characters of text — the failure this module exists for,
|
|
357
|
+
* on demand. A resume (its body carries the anchor quote) is never cut, so
|
|
358
|
+
* the tap produces a primary death and lets the continuation play out.
|
|
359
|
+
*
|
|
360
|
+
* Demo and test affordance, never a default: `DARIO_CHAOS_CUT_AFTER=300
|
|
361
|
+
* dario proxy` then stream any request and watch the seam. Both providers'
|
|
362
|
+
* text framing is recognised (`text_delta` / `response.output_text.delta`).
|
|
363
|
+
*/
|
|
364
|
+
export declare function chaosCutFetch(inner: typeof fetch, o: ChaosCutOptions, state?: ChaosCutState): typeof fetch;
|
|
337
365
|
/** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
|
|
338
366
|
export declare function guardFor(res: ServerResponse, o: Omit<MidstreamGuardOptions, 'end'>): MidstreamGuard;
|
|
339
367
|
/**
|
package/dist/midstream.js
CHANGED
|
@@ -901,6 +901,62 @@ export class MidstreamGuard {
|
|
|
901
901
|
(this.o.log ?? ((l) => console.log(`[dario] ${l}`)))(line);
|
|
902
902
|
}
|
|
903
903
|
}
|
|
904
|
+
export function chaosCutState(o) {
|
|
905
|
+
return { left: o.streams ?? 1 };
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Wraps an upstream fetch so that the first `streams` streamed answers die
|
|
909
|
+
* after `afterChars` characters of text — the failure this module exists for,
|
|
910
|
+
* on demand. A resume (its body carries the anchor quote) is never cut, so
|
|
911
|
+
* the tap produces a primary death and lets the continuation play out.
|
|
912
|
+
*
|
|
913
|
+
* Demo and test affordance, never a default: `DARIO_CHAOS_CUT_AFTER=300
|
|
914
|
+
* dario proxy` then stream any request and watch the seam. Both providers'
|
|
915
|
+
* text framing is recognised (`text_delta` / `response.output_text.delta`).
|
|
916
|
+
*/
|
|
917
|
+
export function chaosCutFetch(inner, o, state = chaosCutState(o)) {
|
|
918
|
+
const log = o.log ?? ((l) => console.warn(`[dario] ${l}`));
|
|
919
|
+
return async (input, init) => {
|
|
920
|
+
const res = await inner(input, init);
|
|
921
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
922
|
+
const isStream = /\/v1\/messages|\/responses/.test(url);
|
|
923
|
+
const bodyText = typeof init?.body === 'string' ? init.body : init?.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : '';
|
|
924
|
+
const isResume = bodyText.includes(ANCHOR_OPEN);
|
|
925
|
+
if (!isStream || isResume || state.left <= 0 || res.status !== 200 || !res.body)
|
|
926
|
+
return res;
|
|
927
|
+
state.left--;
|
|
928
|
+
const reader = res.body.getReader();
|
|
929
|
+
const dec = new TextDecoder();
|
|
930
|
+
let text = '';
|
|
931
|
+
const body = new ReadableStream({
|
|
932
|
+
async pull(c) {
|
|
933
|
+
const { done, value } = await reader.read();
|
|
934
|
+
if (done) {
|
|
935
|
+
c.close();
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
c.enqueue(value);
|
|
939
|
+
for (const m of dec.decode(value, { stream: true }).matchAll(/"(?:text|delta)":"((?:[^"\\]|\\.)*)"/g)) {
|
|
940
|
+
try {
|
|
941
|
+
text += JSON.parse(`"${m[1]}"`);
|
|
942
|
+
}
|
|
943
|
+
catch { /* not a text fragment */ }
|
|
944
|
+
}
|
|
945
|
+
if (text.length >= o.afterChars) {
|
|
946
|
+
log(`CHAOS: cutting this stream after ${text.length} chars (${state.left} more to go)`);
|
|
947
|
+
await new Promise((r) => setTimeout(r, 30)); // let what is queued reach the reader first
|
|
948
|
+
try {
|
|
949
|
+
await reader.cancel();
|
|
950
|
+
}
|
|
951
|
+
catch { /* already gone */ }
|
|
952
|
+
c.error(new Error('chaos: read ECONNRESET'));
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
cancel() { reader.cancel().catch(() => { }); },
|
|
956
|
+
});
|
|
957
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
958
|
+
};
|
|
959
|
+
}
|
|
904
960
|
/** Convenience for sites that hold a ServerResponse: the guard writes through `write`, ends through `res.end()`. */
|
|
905
961
|
export function guardFor(res, o) {
|
|
906
962
|
return new MidstreamGuard({ ...o, end: () => { if (!res.writableEnded)
|
package/dist/proxy.js
CHANGED
|
@@ -24,9 +24,10 @@ import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncL
|
|
|
24
24
|
import { handleAdminRequest } from './admin-api.js';
|
|
25
25
|
import { createTokenBucket } from './rate-limit.js';
|
|
26
26
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
27
|
-
import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
27
|
+
import { forwardToCodex, forwardResponsesToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
28
28
|
import { effortForCodex } from './effort.js';
|
|
29
|
-
import { MidstreamGuard, guardFor, loopbackBaseFor, CONTINUATION_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth } from './midstream.js';
|
|
29
|
+
import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth } from './midstream.js';
|
|
30
|
+
import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequestError, ResponsesOut, wrapResponsesClient } from './responses-inbound.js';
|
|
30
31
|
import { isClaudeServableModel } from './claude-model.js';
|
|
31
32
|
import { MODEL_UNROUTABLE } from './upstream-rejection.js';
|
|
32
33
|
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
@@ -609,6 +610,9 @@ export function resolveProxyTarget(urlPath, isOpenAI) {
|
|
|
609
610
|
return { target: `${ANTHROPIC_API}/v1/messages?beta=true`, thin: false };
|
|
610
611
|
const allowed = {
|
|
611
612
|
'/v1/messages': { target: `${ANTHROPIC_API}/v1/messages?beta=true`, thin: false },
|
|
613
|
+
// OpenAI Responses shape (v6.3, src/responses-inbound.ts): translated to
|
|
614
|
+
// a Messages body at the front door, served like any Anthropic request.
|
|
615
|
+
'/v1/responses': { target: `${ANTHROPIC_API}/v1/messages?beta=true`, thin: false },
|
|
612
616
|
'/v1/messages/count_tokens': { target: `${ANTHROPIC_API}/v1/messages/count_tokens`, thin: true },
|
|
613
617
|
'/v1/complete': { target: `${ANTHROPIC_API}/v1/complete`, thin: false },
|
|
614
618
|
};
|
|
@@ -1093,7 +1097,22 @@ export async function startProxy(opts = {}) {
|
|
|
1093
1097
|
// Upstream auth override: a per-token API key forwards to the standard API
|
|
1094
1098
|
// pool via `x-api-key`, bypassing OAuth/Max + the account pool entirely.
|
|
1095
1099
|
// Env-only so the key never lands in `ps`/argv. Default (empty) = OAuth/Max.
|
|
1096
|
-
|
|
1100
|
+
// DARIO_CHAOS_CUT_AFTER=<chars> [DARIO_CHAOS_CUT_STREAMS=<n>]: the first n
|
|
1101
|
+
// streamed answers die on purpose after that many characters, so the
|
|
1102
|
+
// mid-stream continuation can be watched on demand. Demo and test only —
|
|
1103
|
+
// loud at startup, never a default. Applied to the codex leg as well.
|
|
1104
|
+
const chaosCutAfter = Number.parseInt(process.env.DARIO_CHAOS_CUT_AFTER ?? '', 10);
|
|
1105
|
+
const chaosCut = Number.isFinite(chaosCutAfter) && chaosCutAfter > 0
|
|
1106
|
+
? { afterChars: chaosCutAfter, streams: Math.max(1, Number.parseInt(process.env.DARIO_CHAOS_CUT_STREAMS ?? '1', 10) || 1) }
|
|
1107
|
+
: null;
|
|
1108
|
+
if (chaosCut)
|
|
1109
|
+
console.warn(`[dario] ⚠ CHAOS: the first ${chaosCut.streams} streamed answer${chaosCut.streams === 1 ? '' : 's'} will be cut after ${chaosCut.afterChars} chars (DARIO_CHAOS_CUT_AFTER) — demo/test only`);
|
|
1110
|
+
// One cut budget for the whole proxy. The two legs wrap different fetch
|
|
1111
|
+
// implementations (the Claude leg honours opts.fetchImpl, the codex leg is
|
|
1112
|
+
// the global fetch), so the counter lives outside both wrappers.
|
|
1113
|
+
const chaosState = chaosCut ? chaosCutState(chaosCut) : null;
|
|
1114
|
+
const upstreamFetch = chaosCut && chaosState ? chaosCutFetch(opts.fetchImpl ?? fetch, chaosCut, chaosState) : (opts.fetchImpl ?? fetch);
|
|
1115
|
+
const codexFetch = chaosCut && chaosState ? chaosCutFetch(fetch, chaosCut, chaosState) : fetch;
|
|
1097
1116
|
const upstreamApiKey = (opts.upstreamApiKey ?? process.env.ANTHROPIC_UPSTREAM_API_KEY ?? '').trim();
|
|
1098
1117
|
if (upstreamApiKey)
|
|
1099
1118
|
console.error('[dario] upstream auth: per-token API key (x-api-key) — OAuth/Max + account pool bypassed');
|
|
@@ -2587,6 +2606,17 @@ export async function startProxy(opts = {}) {
|
|
|
2587
2606
|
}
|
|
2588
2607
|
// Detect OpenAI-format requests
|
|
2589
2608
|
const isOpenAI = urlPath === '/v1/chat/completions';
|
|
2609
|
+
// A Responses-API client (Codex CLI, the OpenAI Agents SDK). Its request
|
|
2610
|
+
// becomes an Anthropic Messages body below and everything written back to
|
|
2611
|
+
// it is translated at the write boundary — from here on `res` IS that
|
|
2612
|
+
// boundary, so even a pre-upstream error reaches the client in its shape.
|
|
2613
|
+
const isResponses = urlPath === '/v1/responses';
|
|
2614
|
+
const responsesOut = isResponses ? new ResponsesOut('') : null;
|
|
2615
|
+
// The untranslated response, for the one path that answers a Responses
|
|
2616
|
+
// client in its own shape without translation: the codex passthrough.
|
|
2617
|
+
const rawRes = res;
|
|
2618
|
+
if (responsesOut)
|
|
2619
|
+
res = wrapResponsesClient(res, responsesOut);
|
|
2590
2620
|
// Allowlisted API paths — only these are proxied (prevents SSRF).
|
|
2591
2621
|
// count_tokens forwards thin (no template injection) — see resolveProxyTarget.
|
|
2592
2622
|
const route = resolveProxyTarget(urlPath, isOpenAI);
|
|
@@ -2951,7 +2981,7 @@ export async function startProxy(opts = {}) {
|
|
|
2951
2981
|
// (aliases, prefixes, the CC template); a mid-stream continuation
|
|
2952
2982
|
// re-issues the CLIENT's request, not the rewritten one, so dario's own
|
|
2953
2983
|
// rules apply to the resume the same way they applied to the original.
|
|
2954
|
-
|
|
2984
|
+
let clientBodyBytes = body;
|
|
2955
2985
|
// How deep in a continuation chain this request sits: 0 for a client
|
|
2956
2986
|
// request, 1 for its resume, 2 for the resume of that resume — which is
|
|
2957
2987
|
// never continued itself (MAX_CONTINUATION_DEPTH).
|
|
@@ -3069,6 +3099,45 @@ export async function startProxy(opts = {}) {
|
|
|
3069
3099
|
return;
|
|
3070
3100
|
}
|
|
3071
3101
|
}
|
|
3102
|
+
// Responses shape → Messages shape, once, before any routing peeks at
|
|
3103
|
+
// the body. The translated body is what a continuation re-issues too:
|
|
3104
|
+
// the loopback goes to /v1/messages, which is what this body now is.
|
|
3105
|
+
// The client's Responses body as written — a ChatGPT-subscription model
|
|
3106
|
+
// gets it verbatim (forwardResponsesToCodex), every other route gets the
|
|
3107
|
+
// translation.
|
|
3108
|
+
let responsesBodyRaw = null;
|
|
3109
|
+
// NOTHING IS REFUSED HERE. Routing has not happened yet, so the
|
|
3110
|
+
// translation only RECORDS what the Messages shape cannot carry
|
|
3111
|
+
// (`t.unsupported`, e.g. previous_response_id). The route decides: the
|
|
3112
|
+
// codex passthrough below forwards `responsesBodyRaw` untouched, so a
|
|
3113
|
+
// stateful follow-up on a ChatGPT-subscription model reaches the backend
|
|
3114
|
+
// that keeps state; only the Claude path, after the codex branch has
|
|
3115
|
+
// passed on the request, answers a 400 naming the field. Both halves are
|
|
3116
|
+
// asserted in test/responses-inbound-wiring.mjs ("previous_response_id
|
|
3117
|
+
// on a ChatGPT-subscription model → forwarded untouched").
|
|
3118
|
+
let responsesUnsupported = [];
|
|
3119
|
+
if (isResponses && parsedBody !== null) {
|
|
3120
|
+
try {
|
|
3121
|
+
responsesBodyRaw = parsedBody;
|
|
3122
|
+
const t = responsesRequestToAnthropic(parsedBody);
|
|
3123
|
+
if (verbose && t.warnings.length > 0)
|
|
3124
|
+
console.log(`[dario] #${requestCount} /v1/responses: ${t.warnings.join('; ')}`);
|
|
3125
|
+
responsesUnsupported = t.unsupported;
|
|
3126
|
+
parsedBody = t.body;
|
|
3127
|
+
body = Buffer.from(JSON.stringify(t.body));
|
|
3128
|
+
clientBodyBytes = body;
|
|
3129
|
+
}
|
|
3130
|
+
catch (err) {
|
|
3131
|
+
// Only the translator's own verdicts reach the client; anything else
|
|
3132
|
+
// is an internal failure and says so without its message.
|
|
3133
|
+
const known = err instanceof ResponsesRequestError;
|
|
3134
|
+
if (!known && verbose)
|
|
3135
|
+
console.error(`[dario] #${requestCount} /v1/responses translation failed: ${sanitizeError(err)}`);
|
|
3136
|
+
res.writeHead(400, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
3137
|
+
res.end(JSON.stringify({ error: { message: known ? err.message : 'request could not be translated', type: 'invalid_request_error', param: known ? err.param ?? null : null, code: null } }));
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3072
3141
|
// Provider prefix (v3.10.0). If the body's model field is `<provider>:<model>`
|
|
3073
3142
|
// with a recognized prefix, strip the prefix and force routing regardless of
|
|
3074
3143
|
// regex. CLI-level `--model=<provider>:<name>` applies the same override
|
|
@@ -3397,14 +3466,13 @@ export async function startProxy(opts = {}) {
|
|
|
3397
3466
|
},
|
|
3398
3467
|
})
|
|
3399
3468
|
: null;
|
|
3400
|
-
const served = codexAvailable && await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
|
|
3401
3469
|
// Before this hook a codex request left no trace: nothing in
|
|
3402
3470
|
// /analytics, nothing in the request log, no per-account count.
|
|
3403
3471
|
// The dock (and anyone reading /analytics) saw a proxy that
|
|
3404
3472
|
// served GPT all day and reported zero of it. A decline (the
|
|
3405
3473
|
// request handed to the Claude pool) reports nothing here; the
|
|
3406
3474
|
// Claude path records what it then serves.
|
|
3407
|
-
(o) => {
|
|
3475
|
+
const codexOnDone = (o) => {
|
|
3408
3476
|
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
3409
3477
|
analytics.record({
|
|
3410
3478
|
timestamp: Date.now(),
|
|
@@ -3433,16 +3501,25 @@ export async function startProxy(opts = {}) {
|
|
|
3433
3501
|
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
3434
3502
|
cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
|
|
3435
3503
|
}, consumer));
|
|
3436
|
-
}
|
|
3437
|
-
//
|
|
3438
|
-
//
|
|
3439
|
-
//
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
//
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3504
|
+
};
|
|
3505
|
+
// A Responses client on a ChatGPT-subscription model: the backend
|
|
3506
|
+
// speaks that shape natively, so the body goes through as written
|
|
3507
|
+
// (model resolved) and the SSE comes back untouched — no round
|
|
3508
|
+
// trip through the Messages shape, which cannot carry the newest
|
|
3509
|
+
// Codex CLI request features. Answers on the raw response: these
|
|
3510
|
+
// bytes are already in the client's shape.
|
|
3511
|
+
const served = codexAvailable && (isResponses && responsesBodyRaw
|
|
3512
|
+
? await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone)
|
|
3513
|
+
: await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer, codexOnDone,
|
|
3514
|
+
// Cool codex on a rate limit only — a 5xx or an unreachable backend
|
|
3515
|
+
// is an outage, and parking a provider for that would keep it out
|
|
3516
|
+
// of the chain while it was already coming back.
|
|
3517
|
+
(d) => { if (d.status === 429)
|
|
3518
|
+
providerCooldowns.note('codex', d.retryAfterMs); },
|
|
3519
|
+
// dario#1260 — the effort named by the model-name suffix stripped
|
|
3520
|
+
// above. Undefined for every request that did not name one, which
|
|
3521
|
+
// leaves the outbound body exactly as it was.
|
|
3522
|
+
effortForCodex(requestEffort), codexGuard));
|
|
3446
3523
|
if (served) {
|
|
3447
3524
|
// A provider that just served is not rate-limited.
|
|
3448
3525
|
providerCooldowns.clear('codex');
|
|
@@ -3532,6 +3609,16 @@ export async function startProxy(opts = {}) {
|
|
|
3532
3609
|
}
|
|
3533
3610
|
catch { /* not JSON — fall through to existing path */ }
|
|
3534
3611
|
}
|
|
3612
|
+
// A Responses feature only the codex passthrough can honour, on a
|
|
3613
|
+
// request the Claude pool is about to serve: refuse it by name here,
|
|
3614
|
+
// after routing, so the same field on a ChatGPT-subscription model was
|
|
3615
|
+
// forwarded untouched above.
|
|
3616
|
+
if (isResponses && responsesUnsupported.length > 0) {
|
|
3617
|
+
requestCount++;
|
|
3618
|
+
res.writeHead(400, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
3619
|
+
res.end(JSON.stringify(unsupportedOnClaudeError(responsesUnsupported[0])));
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3535
3622
|
// Claude's turn: the routing block above declined this request, so it
|
|
3536
3623
|
// needs a pool account. Selecting HERE and not before the body read is
|
|
3537
3624
|
// the fix for dario#1137 — a ChatGPT-subscription-only user has a
|