@askalf/dario 4.8.116 → 4.8.117
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 +14 -0
- package/dist/health-response.js +45 -0
- package/dist/model-catalog.d.ts +10 -0
- package/dist/model-catalog.js +13 -0
- package/dist/proxy.js +47 -8
- package/dist/tui/proxy-client.d.ts +12 -5
- package/dist/tui/proxy-client.js +12 -7
- package/dist/tui/tabs/status.js +10 -2
- package/package.json +1 -1
|
@@ -25,4 +25,18 @@ export interface HealthResponse {
|
|
|
25
25
|
httpStatus: number;
|
|
26
26
|
body: Record<string, unknown>;
|
|
27
27
|
}
|
|
28
|
+
export interface PoolAccountStatusLike {
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
inAuthCooldown: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface PoolDerivedStatus {
|
|
33
|
+
authenticated: boolean;
|
|
34
|
+
status: 'healthy' | 'broken' | 'none';
|
|
35
|
+
expiresAt?: number;
|
|
36
|
+
expiresIn?: string;
|
|
37
|
+
/** Distinguishes the pool-derived shape from single-account getStatus(). */
|
|
38
|
+
mode: 'pool';
|
|
39
|
+
accounts: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
|
|
28
42
|
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, viaPublicTunnel: boolean): HealthResponse;
|
package/dist/health-response.js
CHANGED
|
@@ -14,6 +14,51 @@
|
|
|
14
14
|
* The HTTP status (200 healthy / 503 degraded) is identical either way, so external
|
|
15
15
|
* uptime monitoring that keys on the status code is unaffected.
|
|
16
16
|
*/
|
|
17
|
+
function formatMsLeft(ms) {
|
|
18
|
+
const clamped = Math.max(0, ms);
|
|
19
|
+
return `${Math.floor(clamped / 3_600_000)}h ${Math.floor((clamped % 3_600_000) / 60_000)}m`;
|
|
20
|
+
}
|
|
21
|
+
export function derivePoolStatus(accounts, now, adminEnabled) {
|
|
22
|
+
if (accounts.length === 0) {
|
|
23
|
+
// Empty admin pool: 'none'/503 is CORRECT here (every LLM request 503s
|
|
24
|
+
// until an account exists) — but say how to fix it instead of implying
|
|
25
|
+
// `dario login`, which is exactly what an admin-mode operator avoids.
|
|
26
|
+
return {
|
|
27
|
+
authenticated: false,
|
|
28
|
+
status: 'none',
|
|
29
|
+
mode: 'pool',
|
|
30
|
+
accounts: 0,
|
|
31
|
+
expiresIn: adminEnabled
|
|
32
|
+
? 'no accounts yet — add one via POST /admin/login/start'
|
|
33
|
+
: 'no accounts yet — run `dario accounts add <alias>`',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const usable = accounts.filter((a) => !a.inAuthCooldown);
|
|
37
|
+
if (usable.length === 0) {
|
|
38
|
+
// Every account is routing-excluded after upstream auth failures — the
|
|
39
|
+
// next request will fail, which is the deadness /health exists to signal.
|
|
40
|
+
return {
|
|
41
|
+
authenticated: false,
|
|
42
|
+
status: 'broken',
|
|
43
|
+
mode: 'pool',
|
|
44
|
+
accounts: accounts.length,
|
|
45
|
+
expiresAt: Math.min(...accounts.map((a) => a.expiresAt)),
|
|
46
|
+
expiresIn: 'all accounts in auth-cooldown',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Earliest expiry among USABLE accounts — the pool's background refresh
|
|
50
|
+
// (15-min loop) keeps these rolling, mirroring what the startup banner
|
|
51
|
+
// reports for a warm pool.
|
|
52
|
+
const earliest = Math.min(...usable.map((a) => a.expiresAt));
|
|
53
|
+
return {
|
|
54
|
+
authenticated: true,
|
|
55
|
+
status: 'healthy',
|
|
56
|
+
mode: 'pool',
|
|
57
|
+
accounts: accounts.length,
|
|
58
|
+
expiresAt: earliest,
|
|
59
|
+
expiresIn: formatMsLeft(earliest - now),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
17
62
|
export function buildHealthResponse(s, requestCount, viaPublicTunnel) {
|
|
18
63
|
const dead = s.status === 'broken' ||
|
|
19
64
|
s.status === 'none' ||
|
package/dist/model-catalog.d.ts
CHANGED
|
@@ -118,4 +118,14 @@ export declare function getModelCatalog(deps?: CatalogDeps): Promise<ModelCatalo
|
|
|
118
118
|
export declare function getCachedBases(): readonly string[];
|
|
119
119
|
/** Fire-and-forget warmup so the first client /v1/models call is served warm. */
|
|
120
120
|
export declare function prewarmModelCatalog(deps?: CatalogDeps): void;
|
|
121
|
+
/**
|
|
122
|
+
* Reset the failed-fetch backoff and kick a refetch now. For the moment
|
|
123
|
+
* upstream auth first becomes available — e.g. the first account hot-added
|
|
124
|
+
* through the admin API (#599): the startup prewarm was skipped (or a client
|
|
125
|
+
* /v1/models call already failed) while the pool was empty, and the 5-min
|
|
126
|
+
* retry backoff would otherwise keep serving the baked list even though a
|
|
127
|
+
* bearer now exists (#636). No-ops into a plain cache read when the catalog
|
|
128
|
+
* is already fresh from upstream.
|
|
129
|
+
*/
|
|
130
|
+
export declare function retryModelCatalogNow(deps?: CatalogDeps): void;
|
|
121
131
|
export declare function _resetModelCatalogForTest(): void;
|
package/dist/model-catalog.js
CHANGED
|
@@ -302,6 +302,19 @@ export function getCachedBases() {
|
|
|
302
302
|
export function prewarmModelCatalog(deps = {}) {
|
|
303
303
|
void getModelCatalog(deps);
|
|
304
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Reset the failed-fetch backoff and kick a refetch now. For the moment
|
|
307
|
+
* upstream auth first becomes available — e.g. the first account hot-added
|
|
308
|
+
* through the admin API (#599): the startup prewarm was skipped (or a client
|
|
309
|
+
* /v1/models call already failed) while the pool was empty, and the 5-min
|
|
310
|
+
* retry backoff would otherwise keep serving the baked list even though a
|
|
311
|
+
* bearer now exists (#636). No-ops into a plain cache read when the catalog
|
|
312
|
+
* is already fresh from upstream.
|
|
313
|
+
*/
|
|
314
|
+
export function retryModelCatalogNow(deps = {}) {
|
|
315
|
+
lastAttempt = 0;
|
|
316
|
+
void getModelCatalog(deps);
|
|
317
|
+
}
|
|
305
318
|
export function _resetModelCatalogForTest() {
|
|
306
319
|
cache = null;
|
|
307
320
|
lastAttempt = 0;
|
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 } from './health-response.js';
|
|
10
|
+
import { buildHealthResponse, derivePoolStatus } from './health-response.js';
|
|
11
11
|
import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
|
|
12
12
|
import { stampCch } from './cch.js';
|
|
13
13
|
import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
|
|
@@ -21,7 +21,7 @@ import { createTokenBucket } from './rate-limit.js';
|
|
|
21
21
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
22
22
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
23
23
|
import { redactSecrets } from './redact.js';
|
|
24
|
-
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, isSuspendedModel } from './model-catalog.js';
|
|
24
|
+
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
|
|
25
25
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
26
26
|
const DEFAULT_PORT = 3456;
|
|
27
27
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
|
|
@@ -1201,17 +1201,50 @@ export async function startProxy(opts = {}) {
|
|
|
1201
1201
|
...SECURITY_HEADERS,
|
|
1202
1202
|
};
|
|
1203
1203
|
const JSON_HEADERS = { 'Content-Type': 'application/json', ...SECURITY_HEADERS };
|
|
1204
|
+
// Pool-aware status for /status + /health (#636). In pool mode the legacy
|
|
1205
|
+
// single-account getStatus() reads credentials.json, which a login-less
|
|
1206
|
+
// pool setup (#618 pool-at-1, #599 admin bootstrap) legitimately doesn't
|
|
1207
|
+
// have — those endpoints reported none/503 while the pool served fine,
|
|
1208
|
+
// breaking docker healthchecks and making the TUI claim the proxy was down.
|
|
1209
|
+
async function currentStatus() {
|
|
1210
|
+
if (!pool)
|
|
1211
|
+
return getStatus();
|
|
1212
|
+
const now = Date.now();
|
|
1213
|
+
return derivePoolStatus(pool.all().map((a) => ({ expiresAt: a.expiresAt, inAuthCooldown: isInAuthCooldown(a, now) })), now, adminEnabled);
|
|
1214
|
+
}
|
|
1204
1215
|
// Model catalog wiring — /v1/models serves the upstream-autodetected set,
|
|
1205
1216
|
// authenticated the same way the request path is (per-token API key when
|
|
1206
|
-
// ANTHROPIC_UPSTREAM_API_KEY is set, OAuth bearer otherwise
|
|
1207
|
-
//
|
|
1217
|
+
// ANTHROPIC_UPSTREAM_API_KEY is set, OAuth bearer otherwise — from the pool
|
|
1218
|
+
// when pool mode is active, so a login-less setup doesn't fail the fetch
|
|
1219
|
+
// with a misleading "Run `dario login` first" (#636)). Prewarmed so the
|
|
1220
|
+
// first client call is answered from cache; every failure path inside
|
|
1208
1221
|
// getModelCatalog falls back to the baked list, so the route always 200s.
|
|
1209
1222
|
const catalogDeps = {
|
|
1210
1223
|
upstreamApiKey: upstreamApiKey || undefined,
|
|
1211
|
-
getToken:
|
|
1224
|
+
getToken: pool
|
|
1225
|
+
? async () => {
|
|
1226
|
+
const now = Date.now();
|
|
1227
|
+
const accounts = pool.all();
|
|
1228
|
+
if (accounts.length === 0) {
|
|
1229
|
+
throw new Error(adminEnabled
|
|
1230
|
+
? 'pool has no accounts yet — add one via POST /admin/login/start'
|
|
1231
|
+
: 'pool has no accounts yet — run `dario accounts add <alias>`');
|
|
1232
|
+
}
|
|
1233
|
+
const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
|
|
1234
|
+
return (usable[0] ?? accounts[0]).accessToken;
|
|
1235
|
+
}
|
|
1236
|
+
: getAccessToken,
|
|
1212
1237
|
log: verbose ? (m) => console.log(m) : undefined,
|
|
1213
1238
|
};
|
|
1214
|
-
|
|
1239
|
+
if (pool && pool.size === 0) {
|
|
1240
|
+
// Empty admin pool: skip the prewarm instead of logging a guaranteed
|
|
1241
|
+
// failure at startup. The catalog refetches when the first account is
|
|
1242
|
+
// hot-added (onAccountsChanged below).
|
|
1243
|
+
console.log('[dario] model catalog: no accounts yet — serving baked list until one is added');
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
prewarmModelCatalog(catalogDeps);
|
|
1247
|
+
}
|
|
1215
1248
|
const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
|
|
1216
1249
|
const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/chat/completions, GET /v1/models' });
|
|
1217
1250
|
const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
|
|
@@ -1234,7 +1267,7 @@ export async function startProxy(opts = {}) {
|
|
|
1234
1267
|
// and dependent services (`depends_on: service_healthy`) need this to
|
|
1235
1268
|
// react instead of cheerfully passing while every /v1/messages 401s.
|
|
1236
1269
|
if (urlPath === '/health' || urlPath === '/') {
|
|
1237
|
-
const s = await
|
|
1270
|
+
const s = await currentStatus();
|
|
1238
1271
|
// Public requests arrive through the Cloudflare tunnel (the edge stamps
|
|
1239
1272
|
// `cf-ray`); they get only the liveness verdict, never the OAuth internals.
|
|
1240
1273
|
// See buildHealthResponse for the full rationale.
|
|
@@ -1263,6 +1296,12 @@ export async function startProxy(opts = {}) {
|
|
|
1263
1296
|
const size = reconcilePoolAccounts(pool, await loadAllAccounts());
|
|
1264
1297
|
if (verbose)
|
|
1265
1298
|
console.log(`[dario] admin: pool hot-reloaded — ${size} account${size === 1 ? '' : 's'}`);
|
|
1299
|
+
// The startup prewarm was skipped (or failed) while the pool was
|
|
1300
|
+
// empty; now that a bearer exists, refetch past the failed-fetch
|
|
1301
|
+
// backoff so /v1/models upgrades from the baked list without
|
|
1302
|
+
// waiting out the retry window (#636).
|
|
1303
|
+
if (size > 0)
|
|
1304
|
+
retryModelCatalogNow(catalogDeps);
|
|
1266
1305
|
}
|
|
1267
1306
|
catch (err) {
|
|
1268
1307
|
console.error(`[dario] admin: pool hot-reload failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -1336,7 +1375,7 @@ export async function startProxy(opts = {}) {
|
|
|
1336
1375
|
}
|
|
1337
1376
|
// Status endpoint
|
|
1338
1377
|
if (urlPath === '/status') {
|
|
1339
|
-
const s = await
|
|
1378
|
+
const s = await currentStatus();
|
|
1340
1379
|
res.writeHead(200, JSON_HEADERS);
|
|
1341
1380
|
res.end(JSON.stringify(s));
|
|
1342
1381
|
return;
|
|
@@ -30,13 +30,20 @@ export declare class ProxyClient {
|
|
|
30
30
|
constructor(opts: ProxyClientOpts);
|
|
31
31
|
/**
|
|
32
32
|
* GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
|
|
33
|
-
* parse error, or timeout.
|
|
33
|
+
* parse error, or timeout. `opts.anyStatus` accepts every HTTP status
|
|
34
|
+
* and parses the body regardless — for endpoints like /health that
|
|
35
|
+
* deliberately answer 503 with a JSON body.
|
|
34
36
|
*/
|
|
35
|
-
getJson<T = unknown>(path: string
|
|
37
|
+
getJson<T = unknown>(path: string, opts?: {
|
|
38
|
+
anyStatus?: boolean;
|
|
39
|
+
}): Promise<T>;
|
|
36
40
|
/**
|
|
37
|
-
* Reachability probe — GET /health, returns parsed payload
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* Reachability probe — GET /health, returns the parsed payload or null.
|
|
42
|
+
* /health answers 503 WITH a JSON body when upstream auth is degraded —
|
|
43
|
+
* that's a running proxy telling us it's unhealthy, not an unreachable
|
|
44
|
+
* proxy, so any HTTP response with a JSON body resolves (#636). Null is
|
|
45
|
+
* reserved for no-HTTP-response-at-all (connection refused, timeout) or
|
|
46
|
+
* a non-JSON body (something else is squatting on the port).
|
|
40
47
|
*/
|
|
41
48
|
health(): Promise<HealthResponse | null>;
|
|
42
49
|
/**
|
package/dist/tui/proxy-client.js
CHANGED
|
@@ -28,9 +28,11 @@ export class ProxyClient {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
|
|
31
|
-
* parse error, or timeout.
|
|
31
|
+
* parse error, or timeout. `opts.anyStatus` accepts every HTTP status
|
|
32
|
+
* and parses the body regardless — for endpoints like /health that
|
|
33
|
+
* deliberately answer 503 with a JSON body.
|
|
32
34
|
*/
|
|
33
|
-
async getJson(path) {
|
|
35
|
+
async getJson(path, opts) {
|
|
34
36
|
const url = new URL(this.baseUrl + path);
|
|
35
37
|
return new Promise((resolve, reject) => {
|
|
36
38
|
const req = httpRequest({
|
|
@@ -44,7 +46,7 @@ export class ProxyClient {
|
|
|
44
46
|
res.on('data', (c) => chunks.push(c));
|
|
45
47
|
res.on('end', () => {
|
|
46
48
|
const body = Buffer.concat(chunks).toString('utf-8');
|
|
47
|
-
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
49
|
+
if (!opts?.anyStatus && (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300)) {
|
|
48
50
|
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
49
51
|
return;
|
|
50
52
|
}
|
|
@@ -65,13 +67,16 @@ export class ProxyClient {
|
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
68
|
-
* Reachability probe — GET /health, returns parsed payload
|
|
69
|
-
*
|
|
70
|
-
*
|
|
70
|
+
* Reachability probe — GET /health, returns the parsed payload or null.
|
|
71
|
+
* /health answers 503 WITH a JSON body when upstream auth is degraded —
|
|
72
|
+
* that's a running proxy telling us it's unhealthy, not an unreachable
|
|
73
|
+
* proxy, so any HTTP response with a JSON body resolves (#636). Null is
|
|
74
|
+
* reserved for no-HTTP-response-at-all (connection refused, timeout) or
|
|
75
|
+
* a non-JSON body (something else is squatting on the port).
|
|
71
76
|
*/
|
|
72
77
|
async health() {
|
|
73
78
|
try {
|
|
74
|
-
return await this.getJson('/health');
|
|
79
|
+
return await this.getJson('/health', { anyStatus: true });
|
|
75
80
|
}
|
|
76
81
|
catch {
|
|
77
82
|
return null;
|
package/dist/tui/tabs/status.js
CHANGED
|
@@ -88,7 +88,12 @@ export const StatusTab = {
|
|
|
88
88
|
// ── Proxy section ──────────────────────────────────────────
|
|
89
89
|
lines.push(' ' + brand('Proxy'));
|
|
90
90
|
if (state.health) {
|
|
91
|
-
|
|
91
|
+
// /health answers 'degraded' (HTTP 503) from a RUNNING proxy whose
|
|
92
|
+
// upstream auth is unhealthy — render that honestly instead of the old
|
|
93
|
+
// behavior of collapsing any non-2xx into "unreachable" (#636).
|
|
94
|
+
const healthOk = state.health.status === 'ok';
|
|
95
|
+
lines.push(' ' + renderKvRow('Status', healthOk ? fg('green', state.health.status)
|
|
96
|
+
: fg('yellow', `${state.health.status} — proxy running, upstream auth not ready`), w - 4));
|
|
92
97
|
lines.push(' ' + renderKvRow('OAuth', formatOauth(state.health.oauth, state.health.expiresIn), w - 4));
|
|
93
98
|
lines.push(' ' + renderKvRow('Requests', String(state.health.requests ?? 0), w - 4));
|
|
94
99
|
}
|
|
@@ -220,8 +225,11 @@ function formatOauth(label, expiresIn) {
|
|
|
220
225
|
return fg('yellow', 'expired (refresh on next request)');
|
|
221
226
|
if (label === 'broken')
|
|
222
227
|
return fg('red', 'broken — run `dario login`');
|
|
228
|
+
// Pool mode passes a how-to-fix hint through expiresIn (e.g. "no accounts
|
|
229
|
+
// yet — add one via POST /admin/login/start"); single-account 'none' has no
|
|
230
|
+
// expiresIn and keeps the classic label.
|
|
223
231
|
if (label === 'none')
|
|
224
|
-
return dim('no credentials');
|
|
232
|
+
return dim(expiresIn ? `none — ${expiresIn}` : 'no credentials');
|
|
225
233
|
return label;
|
|
226
234
|
}
|
|
227
235
|
function formatAgo(ts) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.117",
|
|
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": {
|