@askalf/dario 4.8.119 → 4.8.121
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/dist/health-response.d.ts +19 -1
- package/dist/health-response.js +34 -8
- package/dist/proxy.d.ts +8 -0
- package/dist/proxy.js +107 -48
- package/package.json +1 -1
|
@@ -41,4 +41,22 @@ export interface PoolDerivedStatus {
|
|
|
41
41
|
accounts: number;
|
|
42
42
|
}
|
|
43
43
|
export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
|
|
44
|
-
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number,
|
|
44
|
+
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean): HealthResponse;
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether a /health caller may see the OAuth internals (#642).
|
|
47
|
+
*
|
|
48
|
+
* /health is intentionally auth-free (docker healthchecks need it before a
|
|
49
|
+
* key is configured), so we cannot simply gate on the API key. Trust model:
|
|
50
|
+
* - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
|
|
51
|
+
* - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
|
|
52
|
+
* - otherwise bare loopback -> internal (docker HC / doctor)
|
|
53
|
+
* - otherwise (LAN, other container, WAN) -> public
|
|
54
|
+
* The cf-ray check is now only ever used to DENY (force public), never to
|
|
55
|
+
* grant, so spoofing it cannot widen disclosure — the previous fail-open
|
|
56
|
+
* direction is closed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function shouldDiscloseHealthInternals(opts: {
|
|
59
|
+
authenticated: boolean;
|
|
60
|
+
loopback: boolean;
|
|
61
|
+
viaCfRay: boolean;
|
|
62
|
+
}): boolean;
|
package/dist/health-response.js
CHANGED
|
@@ -59,24 +59,50 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
|
|
|
59
59
|
expiresIn: formatMsLeft(earliest - now),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
-
export function buildHealthResponse(s, requestCount,
|
|
62
|
+
export function buildHealthResponse(s, requestCount, includeInternal) {
|
|
63
63
|
const dead = s.status === 'broken' ||
|
|
64
64
|
s.status === 'none' ||
|
|
65
65
|
(s.status === 'expired' && s.canRefresh === false);
|
|
66
66
|
const httpStatus = dead ? 503 : 200;
|
|
67
67
|
const liveness = { status: dead ? 'degraded' : 'ok' };
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// Only trusted callers (authenticated, or bare loopback not via the CF
|
|
69
|
+
// tunnel — see shouldDiscloseHealthInternals) get the OAuth internals.
|
|
70
|
+
// Everyone else (LAN, public tunnel) gets the liveness verdict only; the
|
|
71
|
+
// HTTP status is identical either way so uptime checks still work. #642:
|
|
72
|
+
// this used to key on the presence of the client-suppliable `cf-ray`
|
|
73
|
+
// header, which failed OPEN — a direct non-tunnel caller omits it and got
|
|
74
|
+
// the full internal view. `lastRefreshError` is no longer exposed here at
|
|
75
|
+
// all (it can carry a raw upstream error string); it remains on the
|
|
76
|
+
// key-gated /status.
|
|
77
|
+
const body = includeInternal
|
|
78
|
+
? {
|
|
71
79
|
...liveness,
|
|
72
|
-
// Version for internal callers only — an update-check signal (#640),
|
|
73
|
-
// withheld from the public-tunnel view alongside the OAuth internals.
|
|
74
80
|
...(s.version ? { version: s.version } : {}),
|
|
75
81
|
oauth: s.status,
|
|
76
82
|
expiresIn: s.expiresIn,
|
|
77
83
|
requests: requestCount,
|
|
78
84
|
...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
}
|
|
86
|
+
: liveness;
|
|
81
87
|
return { httpStatus, body };
|
|
82
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Decide whether a /health caller may see the OAuth internals (#642).
|
|
91
|
+
*
|
|
92
|
+
* /health is intentionally auth-free (docker healthchecks need it before a
|
|
93
|
+
* key is configured), so we cannot simply gate on the API key. Trust model:
|
|
94
|
+
* - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
|
|
95
|
+
* - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
|
|
96
|
+
* - otherwise bare loopback -> internal (docker HC / doctor)
|
|
97
|
+
* - otherwise (LAN, other container, WAN) -> public
|
|
98
|
+
* The cf-ray check is now only ever used to DENY (force public), never to
|
|
99
|
+
* grant, so spoofing it cannot widen disclosure — the previous fail-open
|
|
100
|
+
* direction is closed.
|
|
101
|
+
*/
|
|
102
|
+
export function shouldDiscloseHealthInternals(opts) {
|
|
103
|
+
if (opts.authenticated)
|
|
104
|
+
return true;
|
|
105
|
+
if (opts.viaCfRay)
|
|
106
|
+
return false;
|
|
107
|
+
return opts.loopback;
|
|
108
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -174,6 +174,14 @@ export declare function buildOrchestrationPatterns(preserveTags?: Set<string>):
|
|
|
174
174
|
* opt any tag out of the scrub. dario#78.
|
|
175
175
|
*/
|
|
176
176
|
export declare function sanitizeMessages(body: Record<string, unknown>, preserveTags?: Set<string>): void;
|
|
177
|
+
/**
|
|
178
|
+
* Translate Anthropic SSE → OpenAI SSE. Returns a stateful per-CALL closure so
|
|
179
|
+
* concurrent /v1/chat/completions streams don't share tool-call index/id state
|
|
180
|
+
* (#642-audit: the previous module-global counters cross-contaminated
|
|
181
|
+
* interleaved streams and emitted malformed tool_calls deltas). One translator
|
|
182
|
+
* per request, mirroring createStreamingReverseMapper.
|
|
183
|
+
*/
|
|
184
|
+
export declare function createOpenAIStreamTranslator(): (line: string) => string | null;
|
|
177
185
|
export declare const OPENAI_MODELS_LIST: {
|
|
178
186
|
object: string;
|
|
179
187
|
data: Array<{
|
package/dist/proxy.js
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { setDefaultResultOrder } from 'node:dns';
|
|
8
8
|
import { arch, platform } from 'node:process';
|
|
9
9
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
10
|
-
import { buildHealthResponse, derivePoolStatus } from './health-response.js';
|
|
10
|
+
import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
|
|
11
11
|
import { darioVersion } from './version.js';
|
|
12
12
|
import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
|
|
13
13
|
import { stampCch } from './cch.js';
|
|
@@ -38,6 +38,18 @@ function isLoopbackHost(host) {
|
|
|
38
38
|
return true;
|
|
39
39
|
return host.startsWith('127.');
|
|
40
40
|
}
|
|
41
|
+
// A socket peer address is loopback if it is 127.0.0.0/8 or ::1, including the
|
|
42
|
+
// IPv4-mapped-IPv6 form Node reports on dual-stack sockets (`::ffff:127.0.0.1`).
|
|
43
|
+
// Distinct from isLoopbackHost (which classifies a bind-address string): this
|
|
44
|
+
// classifies an inbound connection's peer for the /health disclosure gate (#642).
|
|
45
|
+
function isLoopbackAddr(addr) {
|
|
46
|
+
if (!addr)
|
|
47
|
+
return false;
|
|
48
|
+
if (addr === '::1')
|
|
49
|
+
return true;
|
|
50
|
+
const v4 = addr.startsWith('::ffff:') ? addr.slice(7) : addr;
|
|
51
|
+
return v4 === '127.0.0.1' || v4.startsWith('127.');
|
|
52
|
+
}
|
|
41
53
|
// Concurrency control: see src/request-queue.ts for the bounded queue
|
|
42
54
|
// (replaced the v3.30.x-and-earlier simple unbounded semaphore in dario#80).
|
|
43
55
|
// Billing tag hash seed — matches Claude Code's value
|
|
@@ -532,48 +544,55 @@ function anthropicToOpenai(body) {
|
|
|
532
544
|
usage: { prompt_tokens: u?.input_tokens ?? 0, completion_tokens: u?.output_tokens ?? 0, total_tokens: (u?.input_tokens ?? 0) + (u?.output_tokens ?? 0) },
|
|
533
545
|
};
|
|
534
546
|
}
|
|
535
|
-
/**
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
547
|
+
/**
|
|
548
|
+
* Translate Anthropic SSE → OpenAI SSE. Returns a stateful per-CALL closure so
|
|
549
|
+
* concurrent /v1/chat/completions streams don't share tool-call index/id state
|
|
550
|
+
* (#642-audit: the previous module-global counters cross-contaminated
|
|
551
|
+
* interleaved streams and emitted malformed tool_calls deltas). One translator
|
|
552
|
+
* per request, mirroring createStreamingReverseMapper.
|
|
553
|
+
*/
|
|
554
|
+
export function createOpenAIStreamTranslator() {
|
|
555
|
+
let toolIndex = 0;
|
|
556
|
+
let toolId = '';
|
|
557
|
+
return function translateStreamChunk(line) {
|
|
558
|
+
if (!line.startsWith('data: '))
|
|
559
|
+
return null;
|
|
560
|
+
const json = line.slice(6).trim();
|
|
561
|
+
if (json === '[DONE]')
|
|
562
|
+
return 'data: [DONE]\n\n';
|
|
563
|
+
try {
|
|
564
|
+
const e = JSON.parse(json);
|
|
565
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
566
|
+
if (e.type === 'content_block_start') {
|
|
567
|
+
const block = e.content_block;
|
|
568
|
+
if (block?.type === 'tool_use' && block.name) {
|
|
569
|
+
toolId = block.id ?? `call_${toolIndex}`;
|
|
570
|
+
return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: toolIndex, id: toolId, type: 'function', function: { name: block.name, arguments: '' } }] }, finish_reason: null }] })}\n\n`;
|
|
571
|
+
}
|
|
553
572
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
573
|
+
if (e.type === 'content_block_delta') {
|
|
574
|
+
const d = e.delta;
|
|
575
|
+
if (d?.type === 'text_delta' && d.text)
|
|
576
|
+
return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { content: d.text }, finish_reason: null }] })}\n\n`;
|
|
577
|
+
if (d?.type === 'input_json_delta' && d.partial_json)
|
|
578
|
+
return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: toolIndex, function: { arguments: d.partial_json } }] }, finish_reason: null }] })}\n\n`;
|
|
579
|
+
}
|
|
580
|
+
if (e.type === 'content_block_stop') {
|
|
581
|
+
if (toolId) {
|
|
582
|
+
toolIndex++;
|
|
583
|
+
toolId = '';
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
if (e.type === 'message_stop') {
|
|
588
|
+
toolIndex = 0;
|
|
589
|
+
toolId = '';
|
|
590
|
+
return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`;
|
|
566
591
|
}
|
|
567
|
-
return null;
|
|
568
|
-
}
|
|
569
|
-
if (e.type === 'message_stop') {
|
|
570
|
-
_streamToolIndex = 0;
|
|
571
|
-
_streamToolId = '';
|
|
572
|
-
return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`;
|
|
573
592
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
593
|
+
catch { }
|
|
594
|
+
return null;
|
|
595
|
+
};
|
|
577
596
|
}
|
|
578
597
|
// Baked /v1/models payload — what the proxy advertises before (or without)
|
|
579
598
|
// a successful upstream catalog fetch. The live route serves the
|
|
@@ -1169,6 +1188,14 @@ export async function startProxy(opts = {}) {
|
|
|
1169
1188
|
const adminTokenBuf = adminToken ? Buffer.from(adminToken) : null;
|
|
1170
1189
|
if (adminEnabled) {
|
|
1171
1190
|
console.log(`[dario] admin API enabled at /admin/* (token ${adminTokenBuf ? 'configured' : 'MISSING — endpoints return 403 until DARIO_ADMIN_TOKEN is set'})`);
|
|
1191
|
+
// Hardening (#642): the admin API adds/removes OAuth accounts. When it
|
|
1192
|
+
// shares DARIO_API_KEY (which is embedded in every client config) instead
|
|
1193
|
+
// of a distinct DARIO_ADMIN_TOKEN, every client that can proxy can also
|
|
1194
|
+
// control the account pool. Non-fatal for back-compat, but warn loudly.
|
|
1195
|
+
if (adminTokenBuf && !process.env.DARIO_ADMIN_TOKEN) {
|
|
1196
|
+
console.warn('[dario] WARNING: admin API is using DARIO_API_KEY as its token (no DARIO_ADMIN_TOKEN set).');
|
|
1197
|
+
console.warn('[dario] every client holding the proxy key can add/remove accounts. Set a distinct DARIO_ADMIN_TOKEN.');
|
|
1198
|
+
}
|
|
1172
1199
|
}
|
|
1173
1200
|
// Admin rate limiting (#620). Two token buckets, created once per proxy:
|
|
1174
1201
|
// failed auth (brute-force / broken-client throttle) and mutations. Global,
|
|
@@ -1269,10 +1296,16 @@ export async function startProxy(opts = {}) {
|
|
|
1269
1296
|
// react instead of cheerfully passing while every /v1/messages 401s.
|
|
1270
1297
|
if (urlPath === '/health' || urlPath === '/') {
|
|
1271
1298
|
const s = await currentStatus();
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
|
|
1299
|
+
// Disclose OAuth internals only to trusted callers (#642). This used to
|
|
1300
|
+
// key on the presence of the client-suppliable `cf-ray` header and failed
|
|
1301
|
+
// OPEN — a direct non-tunnel caller omits it and got the full internal
|
|
1302
|
+
// view. Now: authenticated, OR bare loopback that did not arrive via CF.
|
|
1303
|
+
const includeInternal = shouldDiscloseHealthInternals({
|
|
1304
|
+
authenticated: authenticateRequest(req.headers, apiKeyBuf),
|
|
1305
|
+
loopback: isLoopbackAddr(req.socket?.remoteAddress),
|
|
1306
|
+
viaCfRay: req.headers['cf-ray'] !== undefined,
|
|
1307
|
+
});
|
|
1308
|
+
const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, includeInternal);
|
|
1276
1309
|
res.writeHead(httpStatus, JSON_HEADERS);
|
|
1277
1310
|
res.end(JSON.stringify(body));
|
|
1278
1311
|
return;
|
|
@@ -2753,14 +2786,29 @@ export async function startProxy(opts = {}) {
|
|
|
2753
2786
|
const streamMapper = ccToolMap && !isOpenAI
|
|
2754
2787
|
? createStreamingReverseMapper(ccToolMap, reqCtx)
|
|
2755
2788
|
: null;
|
|
2789
|
+
// Per-request OpenAI SSE translator — isolated tool-call state so
|
|
2790
|
+
// concurrent /v1/chat/completions streams don't cross-contaminate (#642-audit).
|
|
2791
|
+
const openaiTranslate = isOpenAI ? createOpenAIStreamTranslator() : null;
|
|
2756
2792
|
// Gated writer — a no-op once the downstream client has gone away
|
|
2757
2793
|
// in drain-on-close mode. The read loop keeps consuming so the
|
|
2758
2794
|
// upstream sees a full-length read; writes to a closed socket are
|
|
2759
2795
|
// suppressed to avoid EPIPE/warnings and pointless work.
|
|
2796
|
+
// Honor downstream backpressure (#642-audit): when res.write() returns
|
|
2797
|
+
// false the client's socket buffer is full, so pause the read loop until
|
|
2798
|
+
// it drains instead of buffering the whole (fast) upstream in memory.
|
|
2799
|
+
let needsDrain = false;
|
|
2760
2800
|
const writeToClient = (chunk) => {
|
|
2761
|
-
if (
|
|
2762
|
-
|
|
2801
|
+
if (clientDisconnected)
|
|
2802
|
+
return;
|
|
2803
|
+
if (res.write(chunk) === false)
|
|
2804
|
+
needsDrain = true;
|
|
2763
2805
|
};
|
|
2806
|
+
// Resolves on 'close' too, so a vanished client can't wedge the loop.
|
|
2807
|
+
const waitForDrain = () => new Promise((resolve) => {
|
|
2808
|
+
const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
|
|
2809
|
+
res.once('drain', done);
|
|
2810
|
+
res.once('close', done);
|
|
2811
|
+
});
|
|
2764
2812
|
try {
|
|
2765
2813
|
let buffer = '';
|
|
2766
2814
|
const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
|
|
@@ -2834,7 +2882,7 @@ export async function startProxy(opts = {}) {
|
|
|
2834
2882
|
const lines = buffer.split('\n');
|
|
2835
2883
|
buffer = lines.pop() ?? '';
|
|
2836
2884
|
for (const line of lines) {
|
|
2837
|
-
const translated =
|
|
2885
|
+
const translated = openaiTranslate(line);
|
|
2838
2886
|
if (translated)
|
|
2839
2887
|
writeToClient(translated);
|
|
2840
2888
|
}
|
|
@@ -2847,10 +2895,16 @@ export async function startProxy(opts = {}) {
|
|
|
2847
2895
|
else {
|
|
2848
2896
|
writeToClient(value);
|
|
2849
2897
|
}
|
|
2898
|
+
// Backpressure (#642-audit): if the last writes filled the client
|
|
2899
|
+
// buffer, pause reading upstream until it drains.
|
|
2900
|
+
if (needsDrain && !clientDisconnected) {
|
|
2901
|
+
needsDrain = false;
|
|
2902
|
+
await waitForDrain();
|
|
2903
|
+
}
|
|
2850
2904
|
}
|
|
2851
2905
|
// Flush remaining buffer
|
|
2852
2906
|
if (isOpenAI && buffer.trim()) {
|
|
2853
|
-
const translated =
|
|
2907
|
+
const translated = openaiTranslate(buffer);
|
|
2854
2908
|
if (translated)
|
|
2855
2909
|
writeToClient(translated);
|
|
2856
2910
|
}
|
|
@@ -2863,6 +2917,11 @@ export async function startProxy(opts = {}) {
|
|
|
2863
2917
|
catch (err) {
|
|
2864
2918
|
if (verbose)
|
|
2865
2919
|
console.error('[dario] Stream error:', sanitizeError(err));
|
|
2920
|
+
// Tear down the upstream body reader deterministically on an abnormal
|
|
2921
|
+
// mid-stream error (e.g. a bare upstream socket reset) so the undici
|
|
2922
|
+
// socket is released now, not at GC (#642-audit). No-op if aborted.
|
|
2923
|
+
if (!upstreamAbort.signal.aborted)
|
|
2924
|
+
upstreamAbort.abort();
|
|
2866
2925
|
}
|
|
2867
2926
|
res.end();
|
|
2868
2927
|
// Stamp the response-completion timestamp + token count so the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.121",
|
|
4
4
|
"description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|