@askalf/dario 6.2.1 → 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/proxy.js +86 -14
- 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/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/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
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
|
};
|
|
@@ -2602,6 +2606,17 @@ export async function startProxy(opts = {}) {
|
|
|
2602
2606
|
}
|
|
2603
2607
|
// Detect OpenAI-format requests
|
|
2604
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);
|
|
2605
2620
|
// Allowlisted API paths — only these are proxied (prevents SSRF).
|
|
2606
2621
|
// count_tokens forwards thin (no template injection) — see resolveProxyTarget.
|
|
2607
2622
|
const route = resolveProxyTarget(urlPath, isOpenAI);
|
|
@@ -2966,7 +2981,7 @@ export async function startProxy(opts = {}) {
|
|
|
2966
2981
|
// (aliases, prefixes, the CC template); a mid-stream continuation
|
|
2967
2982
|
// re-issues the CLIENT's request, not the rewritten one, so dario's own
|
|
2968
2983
|
// rules apply to the resume the same way they applied to the original.
|
|
2969
|
-
|
|
2984
|
+
let clientBodyBytes = body;
|
|
2970
2985
|
// How deep in a continuation chain this request sits: 0 for a client
|
|
2971
2986
|
// request, 1 for its resume, 2 for the resume of that resume — which is
|
|
2972
2987
|
// never continued itself (MAX_CONTINUATION_DEPTH).
|
|
@@ -3084,6 +3099,45 @@ export async function startProxy(opts = {}) {
|
|
|
3084
3099
|
return;
|
|
3085
3100
|
}
|
|
3086
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
|
+
}
|
|
3087
3141
|
// Provider prefix (v3.10.0). If the body's model field is `<provider>:<model>`
|
|
3088
3142
|
// with a recognized prefix, strip the prefix and force routing regardless of
|
|
3089
3143
|
// regex. CLI-level `--model=<provider>:<name>` applies the same override
|
|
@@ -3412,14 +3466,13 @@ export async function startProxy(opts = {}) {
|
|
|
3412
3466
|
},
|
|
3413
3467
|
})
|
|
3414
3468
|
: null;
|
|
3415
|
-
const served = codexAvailable && await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer,
|
|
3416
3469
|
// Before this hook a codex request left no trace: nothing in
|
|
3417
3470
|
// /analytics, nothing in the request log, no per-account count.
|
|
3418
3471
|
// The dock (and anyone reading /analytics) saw a proxy that
|
|
3419
3472
|
// served GPT all day and reported zero of it. A decline (the
|
|
3420
3473
|
// request handed to the Claude pool) reports nothing here; the
|
|
3421
3474
|
// Claude path records what it then serves.
|
|
3422
|
-
(o) => {
|
|
3475
|
+
const codexOnDone = (o) => {
|
|
3423
3476
|
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
3424
3477
|
analytics.record({
|
|
3425
3478
|
timestamp: Date.now(),
|
|
@@ -3448,16 +3501,25 @@ export async function startProxy(opts = {}) {
|
|
|
3448
3501
|
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
3449
3502
|
cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
|
|
3450
3503
|
}, consumer));
|
|
3451
|
-
}
|
|
3452
|
-
//
|
|
3453
|
-
//
|
|
3454
|
-
//
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
//
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
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));
|
|
3461
3523
|
if (served) {
|
|
3462
3524
|
// A provider that just served is not rate-limited.
|
|
3463
3525
|
providerCooldowns.clear('codex');
|
|
@@ -3547,6 +3609,16 @@ export async function startProxy(opts = {}) {
|
|
|
3547
3609
|
}
|
|
3548
3610
|
catch { /* not JSON — fall through to existing path */ }
|
|
3549
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
|
+
}
|
|
3550
3622
|
// Claude's turn: the routing block above declined this request, so it
|
|
3551
3623
|
// needs a pool account. Selecting HERE and not before the body read is
|
|
3552
3624
|
// the fix for dario#1137 — a ChatGPT-subscription-only user has a
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound OpenAI Responses API (v6.3) — `POST /v1/responses` on dario.
|
|
3
|
+
*
|
|
4
|
+
* Codex CLI 0.154 dropped `wire_api = "chat"` (openai/codex discussion 7782):
|
|
5
|
+
* a custom provider must speak the Responses API or it cannot be used at all.
|
|
6
|
+
* The OpenAI Agents SDK and everything newer from OpenAI speak the same shape.
|
|
7
|
+
* This module makes dario a Responses endpoint, so those clients run on a
|
|
8
|
+
* Claude subscription — and on a ChatGPT one, through the codex leg.
|
|
9
|
+
*
|
|
10
|
+
* Shape of the work: the request is translated ONCE at the front door into
|
|
11
|
+
* the Anthropic Messages body every other dario path already understands
|
|
12
|
+
* (`responsesRequestToAnthropic`), the request then runs as an ordinary
|
|
13
|
+
* Anthropic-shape request — pool, template, codex leg, mid-stream
|
|
14
|
+
* continuation, all of it — and every byte written back to the client passes
|
|
15
|
+
* through `ResponsesOut`, which turns Anthropic SSE (or a buffered Anthropic
|
|
16
|
+
* message, or an Anthropic error body) into the Responses wire shape. Nothing
|
|
17
|
+
* downstream of the front door knows the client is a Responses client.
|
|
18
|
+
*
|
|
19
|
+
* The reverse direction — an Anthropic-shape request served by the codex
|
|
20
|
+
* backend — has lived in anthropic-responses-translate.ts since 5.5.87. The
|
|
21
|
+
* two translators share types and nothing else on purpose: each direction is
|
|
22
|
+
* read against the wire captures that motivated it.
|
|
23
|
+
*
|
|
24
|
+
* What is dropped, and said so once per process at verbose: hosted tool types
|
|
25
|
+
* the pool cannot run (`web_search`, `file_search`, `mcp`, …), `reasoning`
|
|
26
|
+
* items on the way in (the encrypted content is OpenAI's, and the pool does
|
|
27
|
+
* not need them back), `text.format`, `previous_response_id` (dario is
|
|
28
|
+
* stateless; a 400, not a silent ignore).
|
|
29
|
+
*/
|
|
30
|
+
import type { ServerResponse } from 'node:http';
|
|
31
|
+
export interface InboundTranslation {
|
|
32
|
+
body: Record<string, unknown>;
|
|
33
|
+
/** Things that did not survive the translation, one line each. */
|
|
34
|
+
warnings: string[];
|
|
35
|
+
/**
|
|
36
|
+
* Request features the Messages shape has no honest answer for — the
|
|
37
|
+
* route that serves the request decides what to do: the codex passthrough
|
|
38
|
+
* forwards the original body and never sees this; the Claude pool answers
|
|
39
|
+
* a 400 naming the field rather than silently ignoring it.
|
|
40
|
+
*/
|
|
41
|
+
unsupported: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare class ResponsesRequestError extends Error {
|
|
44
|
+
readonly param?: string | undefined;
|
|
45
|
+
constructor(message: string, param?: string | undefined);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The Responses request as the Anthropic Messages body the rest of dario
|
|
49
|
+
* serves. Throws ResponsesRequestError for shapes that cannot be served
|
|
50
|
+
* honestly (no model, no input, `previous_response_id`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function responsesRequestToAnthropic(req: Record<string, unknown>): InboundTranslation;
|
|
53
|
+
/** The 400 the Claude pool answers for a Responses feature it cannot serve. */
|
|
54
|
+
export declare function unsupportedOnClaudeError(field: string): Record<string, unknown>;
|
|
55
|
+
/** A buffered Anthropic message → a Responses response object. */
|
|
56
|
+
export declare function anthropicMessageToResponses(msg: Record<string, unknown>, createdAt?: number): Record<string, unknown>;
|
|
57
|
+
/** An Anthropic error body → the OpenAI error envelope. */
|
|
58
|
+
export declare function anthropicErrorToResponses(body: Record<string, unknown>): Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Anthropic SSE → Responses SSE, incrementally. One instance per response.
|
|
61
|
+
* Comments (`: dario continuation …`) ride through untouched; `ping` is
|
|
62
|
+
* dropped; `error` becomes `response.failed` + an `error` event.
|
|
63
|
+
*/
|
|
64
|
+
export declare class ResponsesOutStream {
|
|
65
|
+
private seq;
|
|
66
|
+
private id;
|
|
67
|
+
private createdAt;
|
|
68
|
+
private model;
|
|
69
|
+
private started;
|
|
70
|
+
private readonly output;
|
|
71
|
+
private readonly open;
|
|
72
|
+
private usage;
|
|
73
|
+
private stopReason;
|
|
74
|
+
private done;
|
|
75
|
+
private readonly splitter;
|
|
76
|
+
constructor(requestModel: string);
|
|
77
|
+
private ev;
|
|
78
|
+
private snapshot;
|
|
79
|
+
feed(chunk: string | Uint8Array): string;
|
|
80
|
+
/** Whatever is still buffered (a partial frame) — nothing a Responses client can use. */
|
|
81
|
+
end(): string;
|
|
82
|
+
get finished(): boolean;
|
|
83
|
+
private frame;
|
|
84
|
+
private blockStart;
|
|
85
|
+
private blockDelta;
|
|
86
|
+
private blockStop;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Everything dario writes to a Responses client passes through here. The
|
|
90
|
+
* first bytes decide the mode: SSE frames are translated as they arrive; a
|
|
91
|
+
* JSON body (a buffered message, or an error) is held and translated at end().
|
|
92
|
+
*/
|
|
93
|
+
export declare class ResponsesOut {
|
|
94
|
+
private mode;
|
|
95
|
+
private json;
|
|
96
|
+
private readonly stream;
|
|
97
|
+
private readonly decoder;
|
|
98
|
+
constructor(requestModel: string);
|
|
99
|
+
write(chunk: string | Uint8Array): string;
|
|
100
|
+
end(): string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The ServerResponse a Responses client is served through: every write is
|
|
104
|
+
* translated, everything else reaches the real response untouched (headers,
|
|
105
|
+
* events, `writableEnded`, `destroyed`). Bound methods, so `res.on('close')`
|
|
106
|
+
* and friends keep working on the real object.
|
|
107
|
+
*/
|
|
108
|
+
export declare function wrapResponsesClient(res: ServerResponse, out: ResponsesOut): ServerResponse;
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound OpenAI Responses API (v6.3) — `POST /v1/responses` on dario.
|
|
3
|
+
*
|
|
4
|
+
* Codex CLI 0.154 dropped `wire_api = "chat"` (openai/codex discussion 7782):
|
|
5
|
+
* a custom provider must speak the Responses API or it cannot be used at all.
|
|
6
|
+
* The OpenAI Agents SDK and everything newer from OpenAI speak the same shape.
|
|
7
|
+
* This module makes dario a Responses endpoint, so those clients run on a
|
|
8
|
+
* Claude subscription — and on a ChatGPT one, through the codex leg.
|
|
9
|
+
*
|
|
10
|
+
* Shape of the work: the request is translated ONCE at the front door into
|
|
11
|
+
* the Anthropic Messages body every other dario path already understands
|
|
12
|
+
* (`responsesRequestToAnthropic`), the request then runs as an ordinary
|
|
13
|
+
* Anthropic-shape request — pool, template, codex leg, mid-stream
|
|
14
|
+
* continuation, all of it — and every byte written back to the client passes
|
|
15
|
+
* through `ResponsesOut`, which turns Anthropic SSE (or a buffered Anthropic
|
|
16
|
+
* message, or an Anthropic error body) into the Responses wire shape. Nothing
|
|
17
|
+
* downstream of the front door knows the client is a Responses client.
|
|
18
|
+
*
|
|
19
|
+
* The reverse direction — an Anthropic-shape request served by the codex
|
|
20
|
+
* backend — has lived in anthropic-responses-translate.ts since 5.5.87. The
|
|
21
|
+
* two translators share types and nothing else on purpose: each direction is
|
|
22
|
+
* read against the wire captures that motivated it.
|
|
23
|
+
*
|
|
24
|
+
* What is dropped, and said so once per process at verbose: hosted tool types
|
|
25
|
+
* the pool cannot run (`web_search`, `file_search`, `mcp`, …), `reasoning`
|
|
26
|
+
* items on the way in (the encrypted content is OpenAI's, and the pool does
|
|
27
|
+
* not need them back), `text.format`, `previous_response_id` (dario is
|
|
28
|
+
* stateless; a 400, not a silent ignore).
|
|
29
|
+
*/
|
|
30
|
+
import { SseFrameSplitter } from './midstream.js';
|
|
31
|
+
export class ResponsesRequestError extends Error {
|
|
32
|
+
param;
|
|
33
|
+
constructor(message, param) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.param = param;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 32000;
|
|
39
|
+
const EFFORTS = {
|
|
40
|
+
none: 'low', minimal: 'low', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max',
|
|
41
|
+
};
|
|
42
|
+
function textPart(text) { return { type: 'text', text }; }
|
|
43
|
+
function imagePart(url, warnings) {
|
|
44
|
+
const m = /^data:([^;,]+);base64,(.+)$/s.exec(url);
|
|
45
|
+
if (m)
|
|
46
|
+
return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
|
|
47
|
+
if (/^https?:\/\//.test(url))
|
|
48
|
+
return { type: 'image', source: { type: 'url', url } };
|
|
49
|
+
warnings.push(`input_image with an unsupported image_url dropped (${url.slice(0, 24)}…)`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** Content of a Responses message → Anthropic content blocks. */
|
|
53
|
+
function contentParts(content, warnings) {
|
|
54
|
+
if (typeof content === 'string')
|
|
55
|
+
return content.length > 0 ? [textPart(content)] : [];
|
|
56
|
+
if (!Array.isArray(content))
|
|
57
|
+
return [];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const p of content) {
|
|
60
|
+
switch (p?.type) {
|
|
61
|
+
case 'input_text':
|
|
62
|
+
case 'output_text':
|
|
63
|
+
case 'text':
|
|
64
|
+
if (typeof p.text === 'string' && p.text.length > 0)
|
|
65
|
+
out.push(textPart(p.text));
|
|
66
|
+
break;
|
|
67
|
+
case 'refusal':
|
|
68
|
+
if (typeof p.refusal === 'string')
|
|
69
|
+
out.push(textPart(p.refusal));
|
|
70
|
+
break;
|
|
71
|
+
case 'input_image': {
|
|
72
|
+
const url = typeof p.image_url === 'string' ? p.image_url : p.image_url?.url;
|
|
73
|
+
if (typeof url === 'string') {
|
|
74
|
+
const img = imagePart(url, warnings);
|
|
75
|
+
if (img)
|
|
76
|
+
out.push(img);
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
warnings.push('input_image without image_url dropped (file_id images are not supported)');
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
warnings.push(`content part ${String(p?.type)} dropped`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** A tool's output as a tool_result `content` — string when it is only text. */
|
|
89
|
+
function toolOutput(output, warnings) {
|
|
90
|
+
if (typeof output === 'string')
|
|
91
|
+
return output;
|
|
92
|
+
if (Array.isArray(output)) {
|
|
93
|
+
const parts = contentParts(output, warnings);
|
|
94
|
+
return parts.every((p) => p.type === 'text') ? parts.map((p) => p.text).join('') : parts;
|
|
95
|
+
}
|
|
96
|
+
return output === undefined || output === null ? '' : JSON.stringify(output);
|
|
97
|
+
}
|
|
98
|
+
function parseArguments(args, name, warnings) {
|
|
99
|
+
if (typeof args !== 'string' || args.trim() === '')
|
|
100
|
+
return {};
|
|
101
|
+
try {
|
|
102
|
+
const v = JSON.parse(args);
|
|
103
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
104
|
+
return v;
|
|
105
|
+
warnings.push(`function_call ${name}: arguments is not a JSON object, sent as {}`);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
warnings.push(`function_call ${name}: arguments is not valid JSON, sent as {}`);
|
|
109
|
+
}
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
/** Function tools, with `namespace` groups flattened; hosted tool types dropped. */
|
|
113
|
+
function translateTools(tools, warnings) {
|
|
114
|
+
if (!Array.isArray(tools))
|
|
115
|
+
return [];
|
|
116
|
+
const out = [];
|
|
117
|
+
const add = (t) => {
|
|
118
|
+
switch (t?.type) {
|
|
119
|
+
case 'function': {
|
|
120
|
+
if (typeof t.name !== 'string' || t.name.length === 0) {
|
|
121
|
+
warnings.push('function tool without a name dropped');
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
const tool = { name: t.name, input_schema: (t.parameters && typeof t.parameters === 'object') ? t.parameters : { type: 'object', properties: {} } };
|
|
125
|
+
if (typeof t.description === 'string')
|
|
126
|
+
tool.description = t.description;
|
|
127
|
+
out.push(tool);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'namespace':
|
|
131
|
+
for (const inner of (Array.isArray(t.tools) ? t.tools : []))
|
|
132
|
+
add(inner);
|
|
133
|
+
break;
|
|
134
|
+
case 'custom':
|
|
135
|
+
warnings.push(`custom tool ${String(t.name)} dropped (freeform tools have no Anthropic equivalent)`);
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
warnings.push(`${String(t?.type)} tool dropped (hosted tools do not run on the Claude pool)`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const t of tools)
|
|
142
|
+
add(t);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
function translateToolChoice(choice, parallel) {
|
|
146
|
+
let out;
|
|
147
|
+
if (choice === undefined || choice === 'auto')
|
|
148
|
+
out = { type: 'auto' };
|
|
149
|
+
else if (choice === 'required')
|
|
150
|
+
out = { type: 'any' };
|
|
151
|
+
else if (choice === 'none')
|
|
152
|
+
out = { type: 'none' };
|
|
153
|
+
else if (choice && typeof choice === 'object') {
|
|
154
|
+
const c = choice;
|
|
155
|
+
if (c.type === 'function' && typeof c.name === 'string')
|
|
156
|
+
out = { type: 'tool', name: c.name };
|
|
157
|
+
else
|
|
158
|
+
out = { type: 'auto' }; // allowed_tools and hosted-tool choices: let the model decide
|
|
159
|
+
}
|
|
160
|
+
if (parallel === false && out && out.type !== 'none')
|
|
161
|
+
out.disable_parallel_tool_use = true;
|
|
162
|
+
// Anthropic's default is auto; only send the field when it says something.
|
|
163
|
+
if (out && out.type === 'auto' && !out.disable_parallel_tool_use)
|
|
164
|
+
return undefined;
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The Responses request as the Anthropic Messages body the rest of dario
|
|
169
|
+
* serves. Throws ResponsesRequestError for shapes that cannot be served
|
|
170
|
+
* honestly (no model, no input, `previous_response_id`).
|
|
171
|
+
*/
|
|
172
|
+
export function responsesRequestToAnthropic(req) {
|
|
173
|
+
const warnings = [];
|
|
174
|
+
const model = typeof req.model === 'string' ? req.model.trim() : '';
|
|
175
|
+
if (!model)
|
|
176
|
+
throw new ResponsesRequestError('model is required', 'model');
|
|
177
|
+
const unsupported = [];
|
|
178
|
+
if (req.previous_response_id !== undefined && req.previous_response_id !== null)
|
|
179
|
+
unsupported.push('previous_response_id');
|
|
180
|
+
const systemParts = [];
|
|
181
|
+
if (typeof req.instructions === 'string' && req.instructions.length > 0)
|
|
182
|
+
systemParts.push(req.instructions);
|
|
183
|
+
const messages = [];
|
|
184
|
+
// Tools declared inside the input (`additional_tools` items — what Codex
|
|
185
|
+
// CLI sends for models it has metadata for) join the top-level list.
|
|
186
|
+
const extraTools = [];
|
|
187
|
+
const push = (role, parts) => {
|
|
188
|
+
if (parts.length === 0)
|
|
189
|
+
return;
|
|
190
|
+
const last = messages[messages.length - 1];
|
|
191
|
+
if (last && last.role === role)
|
|
192
|
+
last.content.push(...parts);
|
|
193
|
+
else
|
|
194
|
+
messages.push({ role, content: parts });
|
|
195
|
+
};
|
|
196
|
+
const input = req.input;
|
|
197
|
+
if (typeof input === 'string') {
|
|
198
|
+
push('user', [textPart(input)]);
|
|
199
|
+
}
|
|
200
|
+
else if (Array.isArray(input)) {
|
|
201
|
+
for (const raw of input) {
|
|
202
|
+
const type = typeof raw?.type === 'string' ? raw.type : (raw?.role ? 'message' : '');
|
|
203
|
+
switch (type) {
|
|
204
|
+
case 'message': {
|
|
205
|
+
const role = String(raw.role ?? 'user');
|
|
206
|
+
if (role === 'system' || role === 'developer') {
|
|
207
|
+
const text = contentParts(raw.content, warnings).filter((p) => p.type === 'text').map((p) => p.text).join('\n');
|
|
208
|
+
if (text)
|
|
209
|
+
systemParts.push(text);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
push(role === 'assistant' ? 'assistant' : 'user', contentParts(raw.content, warnings));
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'function_call': {
|
|
217
|
+
const name = String(raw.name ?? '');
|
|
218
|
+
const callId = String(raw.call_id ?? raw.id ?? '');
|
|
219
|
+
if (!name || !callId) {
|
|
220
|
+
warnings.push('function_call without name/call_id dropped');
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
push('assistant', [{ type: 'tool_use', id: callId, name, input: parseArguments(raw.arguments, name, warnings) }]);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'function_call_output': {
|
|
227
|
+
const callId = String(raw.call_id ?? '');
|
|
228
|
+
if (!callId) {
|
|
229
|
+
warnings.push('function_call_output without call_id dropped');
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
push('user', [{ type: 'tool_result', tool_use_id: callId, content: toolOutput(raw.output, warnings) }]);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'reasoning':
|
|
236
|
+
break; // OpenAI's encrypted reasoning; the pool has its own
|
|
237
|
+
case 'additional_tools':
|
|
238
|
+
if (Array.isArray(raw.tools))
|
|
239
|
+
extraTools.push(...raw.tools);
|
|
240
|
+
break;
|
|
241
|
+
default:
|
|
242
|
+
warnings.push(`input item ${type || '(untyped)'} dropped`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
throw new ResponsesRequestError('input must be a string or an array of items', 'input');
|
|
248
|
+
}
|
|
249
|
+
if (messages.length === 0)
|
|
250
|
+
throw new ResponsesRequestError('input carries no user or assistant content', 'input');
|
|
251
|
+
if (messages[0].role !== 'user')
|
|
252
|
+
messages.unshift({ role: 'user', content: [textPart('(continue)')] });
|
|
253
|
+
const body = { model, messages };
|
|
254
|
+
if (systemParts.length > 0)
|
|
255
|
+
body.system = systemParts.join('\n\n');
|
|
256
|
+
const maxOut = typeof req.max_output_tokens === 'number' && req.max_output_tokens > 0 ? Math.floor(req.max_output_tokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
|
257
|
+
body.max_tokens = maxOut;
|
|
258
|
+
if (req.stream === true)
|
|
259
|
+
body.stream = true;
|
|
260
|
+
if (typeof req.temperature === 'number')
|
|
261
|
+
body.temperature = req.temperature;
|
|
262
|
+
if (typeof req.top_p === 'number')
|
|
263
|
+
body.top_p = req.top_p;
|
|
264
|
+
const tools = translateTools([...(Array.isArray(req.tools) ? req.tools : []), ...extraTools], warnings);
|
|
265
|
+
if (tools.length > 0)
|
|
266
|
+
body.tools = tools;
|
|
267
|
+
const choice = translateToolChoice(req.tool_choice, req.parallel_tool_calls);
|
|
268
|
+
if (choice && tools.length > 0)
|
|
269
|
+
body.tool_choice = choice;
|
|
270
|
+
const effort = req.reasoning?.effort;
|
|
271
|
+
if (typeof effort === 'string' && EFFORTS[effort]) {
|
|
272
|
+
// dario's own per-request effort spelling (model:high), parsed on the
|
|
273
|
+
// Claude path and the codex path alike — see parseEffortSuffix.
|
|
274
|
+
body.model = `${model}:${EFFORTS[effort]}`;
|
|
275
|
+
}
|
|
276
|
+
const text = req.text;
|
|
277
|
+
if (text?.format && text.format.type && text.format.type !== 'text')
|
|
278
|
+
warnings.push(`text.format ${text.format.type} dropped (structured output is not translated on this route)`);
|
|
279
|
+
return { body, warnings, unsupported };
|
|
280
|
+
}
|
|
281
|
+
/** The 400 the Claude pool answers for a Responses feature it cannot serve. */
|
|
282
|
+
export function unsupportedOnClaudeError(field) {
|
|
283
|
+
const why = field === 'previous_response_id'
|
|
284
|
+
? 'previous_response_id cannot be served from the Claude pool: dario is stateless there — send the full input each turn (store: false), or use a ChatGPT-subscription model, which passes the request through to a backend that keeps state'
|
|
285
|
+
: `${field} cannot be served from the Claude pool`;
|
|
286
|
+
return { error: { message: why, type: 'invalid_request_error', param: field, code: null } };
|
|
287
|
+
}
|
|
288
|
+
function responsesUsage(u) {
|
|
289
|
+
const cached = u?.cache_read_input_tokens ?? 0;
|
|
290
|
+
const input = (u?.input_tokens ?? 0) + cached + (u?.cache_creation_input_tokens ?? 0);
|
|
291
|
+
const output = u?.output_tokens ?? 0;
|
|
292
|
+
return {
|
|
293
|
+
input_tokens: input,
|
|
294
|
+
input_tokens_details: { cached_tokens: cached },
|
|
295
|
+
output_tokens: output,
|
|
296
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
297
|
+
total_tokens: input + output,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function statusFor(stopReason) {
|
|
301
|
+
if (stopReason === 'max_tokens')
|
|
302
|
+
return { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } };
|
|
303
|
+
return { status: 'completed', incomplete_details: null };
|
|
304
|
+
}
|
|
305
|
+
/** Anthropic message id `msg_01AB…` → a Responses response id. */
|
|
306
|
+
function responseIdFrom(messageId) {
|
|
307
|
+
const raw = typeof messageId === 'string' && messageId.length > 0 ? messageId.replace(/^msg_/, '') : Math.random().toString(36).slice(2);
|
|
308
|
+
return `resp_${raw}`;
|
|
309
|
+
}
|
|
310
|
+
function baseResponse(id, createdAt, model) {
|
|
311
|
+
return {
|
|
312
|
+
id, object: 'response', created_at: createdAt, status: 'in_progress', error: null, incomplete_details: null,
|
|
313
|
+
model, output: [], parallel_tool_calls: true, tool_choice: 'auto', tools: [], store: false, usage: null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** A buffered Anthropic message → a Responses response object. */
|
|
317
|
+
export function anthropicMessageToResponses(msg, createdAt = Math.floor(Date.now() / 1000)) {
|
|
318
|
+
const id = responseIdFrom(msg.id);
|
|
319
|
+
const model = typeof msg.model === 'string' ? msg.model : '';
|
|
320
|
+
const output = [];
|
|
321
|
+
let n = 0;
|
|
322
|
+
for (const block of (Array.isArray(msg.content) ? msg.content : [])) {
|
|
323
|
+
n++;
|
|
324
|
+
switch (block.type) {
|
|
325
|
+
case 'text':
|
|
326
|
+
output.push({ id: `msg_${id.slice(5)}_${n}`, type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text: String(block.text ?? ''), annotations: [] }] });
|
|
327
|
+
break;
|
|
328
|
+
case 'tool_use':
|
|
329
|
+
output.push({ id: `fc_${id.slice(5)}_${n}`, type: 'function_call', status: 'completed', call_id: String(block.id ?? ''), name: String(block.name ?? ''), arguments: JSON.stringify(block.input ?? {}) });
|
|
330
|
+
break;
|
|
331
|
+
case 'thinking': {
|
|
332
|
+
const t = typeof block.thinking === 'string' ? block.thinking : '';
|
|
333
|
+
output.push({ id: `rs_${id.slice(5)}_${n}`, type: 'reasoning', summary: t ? [{ type: 'summary_text', text: t }] : [] });
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
default: break; // redacted_thinking, server tool blocks: nothing a Responses client can use
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const st = statusFor(msg.stop_reason);
|
|
340
|
+
return { ...baseResponse(id, createdAt, model), status: st.status, incomplete_details: st.incomplete_details, output, usage: responsesUsage(msg.usage) };
|
|
341
|
+
}
|
|
342
|
+
/** An Anthropic error body → the OpenAI error envelope. */
|
|
343
|
+
export function anthropicErrorToResponses(body) {
|
|
344
|
+
// Anthropic: { type: 'error', error: { type, message } }. dario's own
|
|
345
|
+
// pre-upstream errors: { error: 'Proxy error', message: '…' }.
|
|
346
|
+
const e = body.error;
|
|
347
|
+
if (typeof e === 'string')
|
|
348
|
+
return { error: { message: typeof body.message === 'string' ? body.message : e, type: 'api_error', code: null, param: null } };
|
|
349
|
+
const err = (e ?? {});
|
|
350
|
+
return { error: { message: err.message ?? 'upstream error', type: err.type ?? 'api_error', code: err.type ?? null, param: null } };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Anthropic SSE → Responses SSE, incrementally. One instance per response.
|
|
354
|
+
* Comments (`: dario continuation …`) ride through untouched; `ping` is
|
|
355
|
+
* dropped; `error` becomes `response.failed` + an `error` event.
|
|
356
|
+
*/
|
|
357
|
+
export class ResponsesOutStream {
|
|
358
|
+
seq = 0;
|
|
359
|
+
id = '';
|
|
360
|
+
createdAt = Math.floor(Date.now() / 1000);
|
|
361
|
+
model;
|
|
362
|
+
started = false;
|
|
363
|
+
output = [];
|
|
364
|
+
open = new Map();
|
|
365
|
+
usage = {};
|
|
366
|
+
stopReason = null;
|
|
367
|
+
done = false;
|
|
368
|
+
splitter = new SseFrameSplitter();
|
|
369
|
+
constructor(requestModel) { this.model = requestModel; }
|
|
370
|
+
ev(type, payload) {
|
|
371
|
+
return `event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: this.seq++, ...payload })}\n\n`;
|
|
372
|
+
}
|
|
373
|
+
snapshot(status) {
|
|
374
|
+
return { ...baseResponse(this.id, this.createdAt, this.model), status, output: this.output.map((o) => ({ ...o })) };
|
|
375
|
+
}
|
|
376
|
+
feed(chunk) {
|
|
377
|
+
let out = '';
|
|
378
|
+
for (const f of this.splitter.feed(chunk))
|
|
379
|
+
out += this.frame(f);
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
/** Whatever is still buffered (a partial frame) — nothing a Responses client can use. */
|
|
383
|
+
end() { this.splitter.flush(); return ''; }
|
|
384
|
+
get finished() { return this.done; }
|
|
385
|
+
frame(f) {
|
|
386
|
+
if (f.comment)
|
|
387
|
+
return f.raw;
|
|
388
|
+
const d = f.data;
|
|
389
|
+
if (!d)
|
|
390
|
+
return '';
|
|
391
|
+
switch (d.type) {
|
|
392
|
+
case 'ping': return '';
|
|
393
|
+
case 'message_start': {
|
|
394
|
+
const m = (d.message ?? {});
|
|
395
|
+
this.id = responseIdFrom(m.id);
|
|
396
|
+
if (typeof m.model === 'string' && m.model)
|
|
397
|
+
this.model = m.model;
|
|
398
|
+
this.usage = { ...m.usage };
|
|
399
|
+
this.started = true;
|
|
400
|
+
return this.ev('response.created', { response: this.snapshot('in_progress') }) + this.ev('response.in_progress', { response: this.snapshot('in_progress') });
|
|
401
|
+
}
|
|
402
|
+
case 'content_block_start': return this.blockStart(d);
|
|
403
|
+
case 'content_block_delta': return this.blockDelta(d);
|
|
404
|
+
case 'content_block_stop': return this.blockStop(d);
|
|
405
|
+
case 'message_delta': {
|
|
406
|
+
const delta = d.delta;
|
|
407
|
+
if (delta?.stop_reason !== undefined)
|
|
408
|
+
this.stopReason = delta.stop_reason;
|
|
409
|
+
const u = d.usage;
|
|
410
|
+
if (u)
|
|
411
|
+
this.usage = { ...this.usage, ...u };
|
|
412
|
+
return '';
|
|
413
|
+
}
|
|
414
|
+
case 'message_stop': {
|
|
415
|
+
if (!this.started)
|
|
416
|
+
return '';
|
|
417
|
+
this.done = true;
|
|
418
|
+
const st = statusFor(this.stopReason);
|
|
419
|
+
const response = { ...this.snapshot(st.status), incomplete_details: st.incomplete_details, usage: responsesUsage(this.usage) };
|
|
420
|
+
return this.ev('response.completed', { response });
|
|
421
|
+
}
|
|
422
|
+
case 'error': {
|
|
423
|
+
const err = (d.error ?? {});
|
|
424
|
+
this.done = true;
|
|
425
|
+
if (!this.started) {
|
|
426
|
+
this.id = responseIdFrom(undefined);
|
|
427
|
+
this.started = true;
|
|
428
|
+
}
|
|
429
|
+
const response = { ...this.snapshot('failed'), error: { code: err.type ?? 'server_error', message: err.message ?? 'upstream error' } };
|
|
430
|
+
return this.ev('response.failed', { response }) + this.ev('error', { code: err.type ?? 'server_error', message: err.message ?? 'upstream error', param: null });
|
|
431
|
+
}
|
|
432
|
+
default: return '';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
blockStart(d) {
|
|
436
|
+
const idx = typeof d.index === 'number' ? d.index : this.output.length;
|
|
437
|
+
const cb = (d.content_block ?? {});
|
|
438
|
+
const n = this.output.length + 1;
|
|
439
|
+
const outputIndex = this.output.length;
|
|
440
|
+
switch (cb.type) {
|
|
441
|
+
case 'text': {
|
|
442
|
+
const item = { id: `msg_${this.id.slice(5)}_${n}`, type: 'message', status: 'in_progress', role: 'assistant', content: [] };
|
|
443
|
+
this.output.push(item);
|
|
444
|
+
this.open.set(idx, { item, kind: 'message', text: typeof cb.text === 'string' ? cb.text : '', summaryOpened: false });
|
|
445
|
+
return this.ev('response.output_item.added', { output_index: outputIndex, item: { ...item } })
|
|
446
|
+
+ this.ev('response.content_part.added', { item_id: item.id, output_index: outputIndex, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } });
|
|
447
|
+
}
|
|
448
|
+
case 'tool_use': {
|
|
449
|
+
const item = { id: `fc_${this.id.slice(5)}_${n}`, type: 'function_call', status: 'in_progress', call_id: String(cb.id ?? ''), name: String(cb.name ?? ''), arguments: '' };
|
|
450
|
+
this.output.push(item);
|
|
451
|
+
this.open.set(idx, { item, kind: 'function_call', text: '', summaryOpened: false });
|
|
452
|
+
return this.ev('response.output_item.added', { output_index: outputIndex, item: { ...item } });
|
|
453
|
+
}
|
|
454
|
+
case 'thinking': {
|
|
455
|
+
const item = { id: `rs_${this.id.slice(5)}_${n}`, type: 'reasoning', summary: [] };
|
|
456
|
+
this.output.push(item);
|
|
457
|
+
this.open.set(idx, { item, kind: 'reasoning', text: '', summaryOpened: false });
|
|
458
|
+
return this.ev('response.output_item.added', { output_index: outputIndex, item: { ...item } });
|
|
459
|
+
}
|
|
460
|
+
default:
|
|
461
|
+
return ''; // redacted_thinking, server tools: not surfaced
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
blockDelta(d) {
|
|
465
|
+
const idx = typeof d.index === 'number' ? d.index : -1;
|
|
466
|
+
const o = this.open.get(idx);
|
|
467
|
+
if (!o)
|
|
468
|
+
return '';
|
|
469
|
+
const delta = (d.delta ?? {});
|
|
470
|
+
const outputIndex = this.output.indexOf(o.item);
|
|
471
|
+
if (o.kind === 'message' && delta.type === 'text_delta' && typeof delta.text === 'string') {
|
|
472
|
+
o.text += delta.text;
|
|
473
|
+
return this.ev('response.output_text.delta', { item_id: o.item.id, output_index: outputIndex, content_index: 0, delta: delta.text });
|
|
474
|
+
}
|
|
475
|
+
if (o.kind === 'function_call' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
476
|
+
o.text += delta.partial_json;
|
|
477
|
+
return this.ev('response.function_call_arguments.delta', { item_id: o.item.id, output_index: outputIndex, delta: delta.partial_json });
|
|
478
|
+
}
|
|
479
|
+
if (o.kind === 'reasoning' && delta.type === 'thinking_delta' && typeof delta.thinking === 'string' && delta.thinking.length > 0) {
|
|
480
|
+
let out = '';
|
|
481
|
+
if (!o.summaryOpened) {
|
|
482
|
+
o.summaryOpened = true;
|
|
483
|
+
out += this.ev('response.reasoning_summary_part.added', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, part: { type: 'summary_text', text: '' } });
|
|
484
|
+
}
|
|
485
|
+
o.text += delta.thinking;
|
|
486
|
+
return out + this.ev('response.reasoning_summary_text.delta', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, delta: delta.thinking });
|
|
487
|
+
}
|
|
488
|
+
return '';
|
|
489
|
+
}
|
|
490
|
+
blockStop(d) {
|
|
491
|
+
const idx = typeof d.index === 'number' ? d.index : -1;
|
|
492
|
+
const o = this.open.get(idx);
|
|
493
|
+
if (!o)
|
|
494
|
+
return '';
|
|
495
|
+
this.open.delete(idx);
|
|
496
|
+
const outputIndex = this.output.indexOf(o.item);
|
|
497
|
+
switch (o.kind) {
|
|
498
|
+
case 'message': {
|
|
499
|
+
const part = { type: 'output_text', text: o.text, annotations: [] };
|
|
500
|
+
o.item.status = 'completed';
|
|
501
|
+
o.item.content = [part];
|
|
502
|
+
return this.ev('response.output_text.done', { item_id: o.item.id, output_index: outputIndex, content_index: 0, text: o.text })
|
|
503
|
+
+ this.ev('response.content_part.done', { item_id: o.item.id, output_index: outputIndex, content_index: 0, part })
|
|
504
|
+
+ this.ev('response.output_item.done', { output_index: outputIndex, item: { ...o.item } });
|
|
505
|
+
}
|
|
506
|
+
case 'function_call': {
|
|
507
|
+
const args = o.text.trim() === '' ? '{}' : o.text;
|
|
508
|
+
o.item.arguments = args;
|
|
509
|
+
o.item.status = 'completed';
|
|
510
|
+
return this.ev('response.function_call_arguments.done', { item_id: o.item.id, output_index: outputIndex, arguments: args })
|
|
511
|
+
+ this.ev('response.output_item.done', { output_index: outputIndex, item: { ...o.item } });
|
|
512
|
+
}
|
|
513
|
+
case 'reasoning': {
|
|
514
|
+
let out = '';
|
|
515
|
+
if (o.summaryOpened) {
|
|
516
|
+
out += this.ev('response.reasoning_summary_text.done', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, text: o.text })
|
|
517
|
+
+ this.ev('response.reasoning_summary_part.done', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, part: { type: 'summary_text', text: o.text } });
|
|
518
|
+
o.item.summary = [{ type: 'summary_text', text: o.text }];
|
|
519
|
+
}
|
|
520
|
+
return out + this.ev('response.output_item.done', { output_index: outputIndex, item: { ...o.item } });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// The write boundary
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
/**
|
|
529
|
+
* Everything dario writes to a Responses client passes through here. The
|
|
530
|
+
* first bytes decide the mode: SSE frames are translated as they arrive; a
|
|
531
|
+
* JSON body (a buffered message, or an error) is held and translated at end().
|
|
532
|
+
*/
|
|
533
|
+
export class ResponsesOut {
|
|
534
|
+
mode = 'undecided';
|
|
535
|
+
json = '';
|
|
536
|
+
stream;
|
|
537
|
+
decoder = new TextDecoder();
|
|
538
|
+
constructor(requestModel) { this.stream = new ResponsesOutStream(requestModel); }
|
|
539
|
+
write(chunk) {
|
|
540
|
+
const text = typeof chunk === 'string' ? chunk : this.decoder.decode(chunk, { stream: true });
|
|
541
|
+
if (this.mode === 'undecided') {
|
|
542
|
+
const head = text.trimStart();
|
|
543
|
+
if (head.length === 0)
|
|
544
|
+
return '';
|
|
545
|
+
this.mode = head.startsWith('event:') || head.startsWith('data:') || head.startsWith(':') ? 'sse' : 'json';
|
|
546
|
+
}
|
|
547
|
+
if (this.mode === 'sse')
|
|
548
|
+
return this.stream.feed(text);
|
|
549
|
+
this.json += text;
|
|
550
|
+
return '';
|
|
551
|
+
}
|
|
552
|
+
end() {
|
|
553
|
+
if (this.mode === 'sse')
|
|
554
|
+
return this.stream.end();
|
|
555
|
+
if (this.mode === 'json') {
|
|
556
|
+
this.json += this.decoder.decode();
|
|
557
|
+
try {
|
|
558
|
+
const parsed = JSON.parse(this.json);
|
|
559
|
+
if (parsed.type === 'message' && Array.isArray(parsed.content))
|
|
560
|
+
return JSON.stringify(anthropicMessageToResponses(parsed));
|
|
561
|
+
// Already the OpenAI envelope (dario answered this route in the
|
|
562
|
+
// client's shape itself): leave it alone.
|
|
563
|
+
if (parsed.type !== 'error' && parsed.error && typeof parsed.error === 'object' && 'param' in parsed.error)
|
|
564
|
+
return this.json;
|
|
565
|
+
if (parsed.type === 'error' || parsed.error)
|
|
566
|
+
return JSON.stringify(anthropicErrorToResponses(parsed));
|
|
567
|
+
return this.json;
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return this.json;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return '';
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The ServerResponse a Responses client is served through: every write is
|
|
578
|
+
* translated, everything else reaches the real response untouched (headers,
|
|
579
|
+
* events, `writableEnded`, `destroyed`). Bound methods, so `res.on('close')`
|
|
580
|
+
* and friends keep working on the real object.
|
|
581
|
+
*/
|
|
582
|
+
export function wrapResponsesClient(res, out) {
|
|
583
|
+
const target = res;
|
|
584
|
+
return new Proxy(res, {
|
|
585
|
+
get(_t, prop) {
|
|
586
|
+
if (prop === 'write') {
|
|
587
|
+
return (chunk, ...rest) => {
|
|
588
|
+
const translated = out.write(chunk);
|
|
589
|
+
const cb = rest.find((r) => typeof r === 'function');
|
|
590
|
+
if (translated.length === 0) {
|
|
591
|
+
cb?.();
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
return target.write(translated, ...(typeof rest[0] === 'string' ? rest : rest.filter((r) => typeof r === 'function')));
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (prop === 'end') {
|
|
598
|
+
return (chunk, ...rest) => {
|
|
599
|
+
if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
|
|
600
|
+
const t = out.write(chunk);
|
|
601
|
+
if (t.length > 0)
|
|
602
|
+
target.write(t);
|
|
603
|
+
}
|
|
604
|
+
const tail = out.end();
|
|
605
|
+
if (tail.length > 0)
|
|
606
|
+
target.write(tail);
|
|
607
|
+
return target.end(...(typeof chunk === 'function' ? [chunk] : rest.filter((r) => typeof r === 'function')));
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
// Getters run with `this` = the real response, never the proxy: Node's
|
|
611
|
+
// internals read symbol-keyed state off `this`.
|
|
612
|
+
const v = Reflect.get(target, prop, target);
|
|
613
|
+
return typeof v === 'function' ? v.bind(target) : v;
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|
package/docs/drift-monitor.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Drift monitor
|
|
2
2
|
|
|
3
|
+
> **The feed:** every change the watcher has ever observed, as a page with
|
|
4
|
+
> RSS and JSON Feed — <https://askalf.github.io/dario/drift-feed/>. Rebuilt
|
|
5
|
+
> from git history on every template change (`scripts/drift-feed.mjs`,
|
|
6
|
+
> `.github/workflows/drift-feed.yml`); a "nothing changed on the wire" line is
|
|
7
|
+
> a Claude Code release the watcher checked and found identical.
|
|
8
|
+
|
|
3
9
|
Dario's bundled CC template (`src/cc-template-data.json`) is the wire-shape
|
|
4
10
|
fallback the proxy uses when it can't fingerprint a live CC install. For that
|
|
5
11
|
fallback to be honest, the bundle has to keep up with what real CC is actually
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Codex CLI on your Claude plan
|
|
2
|
+
|
|
3
|
+
OpenAI's Codex CLI runs on a Claude subscription through dario, with its
|
|
4
|
+
tools, since 6.3. The other way round — Claude Code on a ChatGPT plan — has
|
|
5
|
+
worked since 5.5.89. Both on one machine, one `dario proxy`, is the whole
|
|
6
|
+
point of the project in two panes.
|
|
7
|
+
|
|
8
|
+
## Why this needed a new endpoint
|
|
9
|
+
|
|
10
|
+
Codex CLI 0.154 removed `wire_api = "chat"` for custom providers
|
|
11
|
+
([openai/codex discussion 7782](https://github.com/openai/codex/discussions/7782)):
|
|
12
|
+
a provider has to speak the **Responses API** or it cannot be used at all. So
|
|
13
|
+
dario now has `POST /v1/responses`. The request is translated once at the front
|
|
14
|
+
door into the Messages shape every other dario path already serves — pool,
|
|
15
|
+
template, failover, mid-stream continuation — and everything written back is
|
|
16
|
+
translated at the write boundary. For a ChatGPT-subscription model the body
|
|
17
|
+
goes through to the codex backend untouched instead (it speaks Responses
|
|
18
|
+
natively), which is what keeps Codex's newest request features working there:
|
|
19
|
+
`additional_tools` input items, `custom` tools, `reasoning.context`,
|
|
20
|
+
`include`.
|
|
21
|
+
|
|
22
|
+
The OpenAI Agents SDK and anything else that speaks Responses gets the same
|
|
23
|
+
endpoint.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
dario proxy # your Claude plan, port 3456
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`~/.codex/config.toml`:
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
model = "claude-opus-5"
|
|
35
|
+
model_provider = "dario"
|
|
36
|
+
|
|
37
|
+
[model_providers.dario]
|
|
38
|
+
name = "dario"
|
|
39
|
+
base_url = "http://127.0.0.1:3456/v1"
|
|
40
|
+
env_key = "DARIO_API_KEY"
|
|
41
|
+
wire_api = "responses"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`export DARIO_API_KEY=dario` (any value until you set one on the proxy; then it
|
|
45
|
+
has to match), and `codex` runs on the Claude pool. `-m claude-sonnet-5` /
|
|
46
|
+
`-m claude-opus-5` / any `claude-*` id; `codex exec` works the same way.
|
|
47
|
+
|
|
48
|
+
With a ChatGPT account attached as well (`dario add altman`),
|
|
49
|
+
`-m gpt-5.6-sol` on the same provider block goes to that plan through dario —
|
|
50
|
+
pooling, failover and `x-dario-*` headers included.
|
|
51
|
+
|
|
52
|
+
Codex prints `Model metadata for claude-opus-5 not found. Defaulting to fallback
|
|
53
|
+
metadata` for any model it does not ship metadata for. Harmless: it falls back
|
|
54
|
+
to its classic request profile (top-level `tools` and `instructions`), which is
|
|
55
|
+
exactly the one the translation reads.
|
|
56
|
+
|
|
57
|
+
## What runs, and what does not
|
|
58
|
+
|
|
59
|
+
Verified on 2026-09-12 with Codex CLI 0.154.0 on a Claude Max plan:
|
|
60
|
+
|
|
61
|
+
- a plain turn;
|
|
62
|
+
- the full agent loop — `exec_command` called by Claude, executed by Codex,
|
|
63
|
+
the `function_call_output` returned, Claude answering from it — with the
|
|
64
|
+
17 KB Codex system prompt cached on the Claude side (98–99% cache reads
|
|
65
|
+
from the second turn);
|
|
66
|
+
- the same loop on a ChatGPT plan through the passthrough.
|
|
67
|
+
|
|
68
|
+
Translated on the Claude pool: `instructions` and `developer` messages
|
|
69
|
+
(hoisted to the system prompt, in order), `message` items with `input_text`,
|
|
70
|
+
`output_text` and `input_image` parts (data-URL and https images),
|
|
71
|
+
`function_call` / `function_call_output` (tool_use / tool_result, call ids
|
|
72
|
+
preserved), `function` tools (and `namespace` groups, flattened),
|
|
73
|
+
`tool_choice` including `required` and a named function,
|
|
74
|
+
`parallel_tool_calls: false`, `max_output_tokens`, `temperature`, `top_p`,
|
|
75
|
+
`reasoning.effort` (as dario's own `model:high` effort spelling). Back out:
|
|
76
|
+
`message`, `function_call` and `reasoning` items, the full Responses event
|
|
77
|
+
sequence with sequence numbers, usage in OpenAI terms (cached prefix inside
|
|
78
|
+
`input_tokens`, reported again under `cached_tokens`), `incomplete` on
|
|
79
|
+
`max_tokens`, `response.failed` on an upstream error.
|
|
80
|
+
|
|
81
|
+
Dropped, with a line at `--verbose`: hosted tool types the pool cannot run
|
|
82
|
+
(`web_search`, `file_search`, `mcp`, …), `custom` freeform tools,
|
|
83
|
+
`reasoning` items on the way in (OpenAI's encrypted content — the pool does
|
|
84
|
+
not need it back), `text.format`. `previous_response_id` on the Claude pool is a 400 naming the
|
|
85
|
+
field (dario is stateless there; send the full input each turn, which Codex
|
|
86
|
+
does); on a ChatGPT-subscription model it is forwarded untouched to a backend
|
|
87
|
+
that keeps state, `store` as you sent it or omitted for the backend's default.
|
|
88
|
+
|
|
89
|
+
Not built: a buffered (non-streaming) response from a ChatGPT-subscription
|
|
90
|
+
model on this route — the backend streams, and folding a Responses stream into
|
|
91
|
+
a response object is not written yet; the answer is a 400 naming `stream`.
|
|
92
|
+
Mid-stream continuation runs under this route on the Claude pool (the
|
|
93
|
+
translated request is an ordinary Anthropic-shape request) and not on the
|
|
94
|
+
passthrough.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|