@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.
@@ -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;
@@ -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' ||
@@ -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;
@@ -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). Prewarmed so
1207
- // the first client call is answered from cache; every failure path inside
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: getAccessToken,
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
- prewarmModelCatalog(catalogDeps);
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 getStatus();
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 getStatus();
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): Promise<T>;
37
+ getJson<T = unknown>(path: string, opts?: {
38
+ anyStatus?: boolean;
39
+ }): Promise<T>;
36
40
  /**
37
- * Reachability probe — GET /health, returns parsed payload on
38
- * success or null on any failure. Never throws; callers use the
39
- * null as "proxy not running / unreachable".
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
  /**
@@ -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 on
69
- * success or null on any failure. Never throws; callers use the
70
- * null as "proxy not running / unreachable".
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;
@@ -88,7 +88,12 @@ export const StatusTab = {
88
88
  // ── Proxy section ──────────────────────────────────────────
89
89
  lines.push(' ' + brand('Proxy'));
90
90
  if (state.health) {
91
- lines.push(' ' + renderKvRow('Status', fg('green', state.health.status), w - 4));
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.116",
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": {