@askalf/dario 4.8.116 → 4.8.118
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 +2 -6
- package/dist/health-response.d.ts +16 -0
- package/dist/health-response.js +48 -0
- package/dist/model-catalog.d.ts +10 -0
- package/dist/model-catalog.js +13 -0
- package/dist/proxy.js +52 -10
- package/dist/tui/proxy-client.d.ts +12 -5
- package/dist/tui/proxy-client.js +12 -7
- package/dist/tui/tabs/accounts.d.ts +17 -5
- package/dist/tui/tabs/accounts.js +99 -20
- package/dist/tui/tabs/status.js +10 -2
- package/dist/version.d.ts +3 -0
- package/dist/version.js +32 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
|
|
19
19
|
> 🗓️ **2026-06-15 — Anthropic splits Claude billing.** Agent-SDK and `claude -p` (headless) traffic stops counting against your subscription pool and moves to a small separate monthly credit ($20 / $100 / $200 by plan), then metered per-token API rates. Most proxies forward your requests in exactly the shape that gets reclassified into that bucket. dario rewrites every request into interactive Claude Code wire-shape before it leaves your machine, so your traffic stays in the subscription pool you already pay for — same install, no config change for the cliff. **[What changes, and how to verify it on your own machine →](#the-deadline-2026-06-15)**
|
|
20
20
|
|
|
21
|
-
> ⛔ **Claude Fable 5 / Mythos 5 — temporarily suspended for all Anthropic customers.** On 2026-06-12 a US-government legal directive disabled Fable 5 and Mythos 5 for **every** Anthropic plan and tier — `api.anthropic.com` now returns `not_found` for them, and no account or proxy can route around it ([details](https://www.anthropic.com/news/fable-mythos-access)). As of **v4.8.71**, dario filters both families out of `/v1/models` and rejects any spelling (`fable`, `fable1m`, `claude-fable-5`, `claude-fable-5[1m]`, `claude:fable`) up front with a clean `404` pointing at `claude-opus-4-8` / `claude-sonnet-5` — instead of forwarding into a confusing upstream error. Reversible without a code change once access is restored: set `DARIO_SUSPENDED_MODELS=` (empty) on the instance. `npm install -g @askalf/dario@latest`
|
|
22
|
-
>
|
|
23
21
|
> ⚠️ Still on a version **before 4.8.39**? Upgrade now — those could silently corrupt code/structured content routed through the proxy (the identifier scrub stripped tokens like the JS `continue` keyword). **[Details →](https://github.com/askalf/dario/issues/457)**
|
|
24
22
|
|
|
25
23
|
---
|
|
@@ -194,7 +192,7 @@ You point every tool at one URL. dario reads each request, decides which backend
|
|
|
194
192
|
|
|
195
193
|
The tool doesn't know. The backend doesn't know. dario is the seam.
|
|
196
194
|
|
|
197
|
-
**The full Claude lineup, autodetected.** Opus 4.8, Sonnet 5, and Haiku 4.5 — plus `[1m]` long-context variants, generated by one rule for every family — by full id (`claude-opus-4-8`) or shortcut (`opus` / `sonnet` / `haiku`, append `1m` for the long-context form). `GET /v1/models` asks Anthropic's live catalog (TTL-cached, baked fallback when offline), and the family shortcuts track it — a new model shows up and resolves the day it lands, no dario release needed; the model-specific wire shape (effort level, beta set, thinking config) is applied automatically. **
|
|
195
|
+
**The full Claude lineup, autodetected.** Opus 4.8, Sonnet 5, and Haiku 4.5 — plus `[1m]` long-context variants, generated by one rule for every family — by full id (`claude-opus-4-8`) or shortcut (`opus` / `sonnet` / `haiku`, append `1m` for the long-context form). `GET /v1/models` asks Anthropic's live catalog (TTL-cached, baked fallback when offline), and the family shortcuts track it — a new model shows up and resolves the day it lands, no dario release needed; the model-specific wire shape (effort level, beta set, thinking config) is applied automatically. **Suspended families are filtered out** of both the live catalog and the baked fallback, so `/v1/models` never advertises a model that 404s upstream and suspended ids are rejected locally with an actionable error. Nothing is suspended by default; the mechanism is retained via `DARIO_SUSPENDED_MODELS` (comma-separated families, every spelling caught) for if a family is ever pulled upstream again.
|
|
198
196
|
|
|
199
197
|
---
|
|
200
198
|
|
|
@@ -224,6 +222,7 @@ Tune via `~/.dario/config.json` → `overageGuard`, or CLI flags: `--overage-beh
|
|
|
224
222
|
## Capabilities
|
|
225
223
|
|
|
226
224
|
- **Multi-account pool.** Drop Claude accounts in `~/.dario/accounts/` and pool mode auto-activates — one is enough to serve, so `dario accounts add` alone bootstraps a proxy with no `dario login` step. With more, every request routes to the account with the most headroom, multi-turn sessions pin to one account so the prompt cache survives, in-flight 429s fail over to a peer before your client sees an error. → [`docs/multi-account-pool.md`](./docs/multi-account-pool.md)
|
|
225
|
+
- **Headless admin API (`DARIO_ADMIN=1`).** Provision and manage pool accounts entirely over HTTP — start with zero accounts, `POST /admin/login/start`, paste the code back, and the account is routable the moment the `200` lands (live pool hot-reload, no restart). Token-gated even on loopback, audit-logged, rate-limited; `GET /admin/accounts` reports live per-account headroom. Built for Docker / k8s / Raspberry-Pi deployments where a console is the awkward part. → [`docs/admin-api.md`](./docs/admin-api.md)
|
|
227
226
|
- **Behavioral stealth (`--stealth`).** Static wire fidelity covers *what* the request looks like; `--stealth` adds *when* it arrives — response-length-correlated think time and 1.2–4.2s session-start latency, the inter-arrival pattern real interactive sessions have and agent loops don't. → [`docs/wire-fidelity.md`](./docs/wire-fidelity.md)
|
|
228
227
|
- **Runs any non-Claude-Code agent.** A 64-entry schema-verified `TOOL_MAP` pre-maps Cline, Roo, Kilo, Cursor, Windsurf, Continue, Copilot, OpenHands, OpenClaw, Hermes, [hands](https://github.com/askalf/hands) tool names to CC's native set. No flag, no validator errors. → [`docs/integrations/agent-compat.md`](./docs/integrations/agent-compat.md)
|
|
229
228
|
- **Recover output capability.** `dario proxy --system-prompt=partial` strips CC's tone/verbosity/no-comments constraints for 1.2–2.8× more output on open-ended work — empirically without flipping billing (the classifier doesn't read that slot). [Discussion #183](https://github.com/askalf/dario/discussions/183) has the per-prompt receipts. → [`docs/system-prompt.md`](./docs/system-prompt.md)
|
|
@@ -302,9 +301,6 @@ No. `five_hour` and `seven_day` are both subscription billing — different acco
|
|
|
302
301
|
**Will the 2026-06-15 split break my setup? / What if Anthropic ships another silent change?**
|
|
303
302
|
No, and it's caught automatically — see [The deadline](#the-deadline-2026-06-15) and [How it stays working](#how-it-works-and-how-it-stays-working). dario rewrites every request to interactive-CC shape before it reaches `api.anthropic.com`, and the three-class drift watcher picks up new changes (npm-release hourly, remote-config every 30 min, classifier-rule daily). v3.38.5 + v3.38.6 — 13 minutes apart, same day as v2.1.142's silent drops — are the prior art.
|
|
304
303
|
|
|
305
|
-
**I'm getting `404 not_found` for `claude-fable-5` / `fable`. Did dario break?**
|
|
306
|
-
No — Claude Fable 5 and Mythos 5 were disabled for **all** Anthropic customers by a US-government legal directive on 2026-06-12 (every plan and tier; [details](https://www.anthropic.com/news/fable-mythos-access)). `api.anthropic.com` returns `not_found` for them, so no subscription or proxy can route them. Since v4.8.71 dario drops both families from `/v1/models` and rejects them locally with a clear 404 pointing at `claude-opus-4-8` / `claude-sonnet-5`, instead of forwarding into a confusing upstream error. When access is restored, set `DARIO_SUSPENDED_MODELS=` (empty) on the instance to re-enable — no upgrade needed.
|
|
307
|
-
|
|
308
304
|
Full FAQ: [`docs/faq.md`](./docs/faq.md)
|
|
309
305
|
|
|
310
306
|
---
|
|
@@ -20,9 +20,25 @@ export interface HealthStatusLike {
|
|
|
20
20
|
expiresIn?: string;
|
|
21
21
|
refreshFailures?: number;
|
|
22
22
|
lastRefreshError?: string;
|
|
23
|
+
/** dario's package version, surfaced to internal callers on /health (#640). */
|
|
24
|
+
version?: string;
|
|
23
25
|
}
|
|
24
26
|
export interface HealthResponse {
|
|
25
27
|
httpStatus: number;
|
|
26
28
|
body: Record<string, unknown>;
|
|
27
29
|
}
|
|
30
|
+
export interface PoolAccountStatusLike {
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
inAuthCooldown: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface PoolDerivedStatus {
|
|
35
|
+
authenticated: boolean;
|
|
36
|
+
status: 'healthy' | 'broken' | 'none';
|
|
37
|
+
expiresAt?: number;
|
|
38
|
+
expiresIn?: string;
|
|
39
|
+
/** Distinguishes the pool-derived shape from single-account getStatus(). */
|
|
40
|
+
mode: 'pool';
|
|
41
|
+
accounts: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
|
|
28
44
|
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' ||
|
|
@@ -24,6 +69,9 @@ export function buildHealthResponse(s, requestCount, viaPublicTunnel) {
|
|
|
24
69
|
? liveness
|
|
25
70
|
: {
|
|
26
71
|
...liveness,
|
|
72
|
+
// Version for internal callers only — an update-check signal (#640),
|
|
73
|
+
// withheld from the public-tunnel view alongside the OAuth internals.
|
|
74
|
+
...(s.version ? { version: s.version } : {}),
|
|
27
75
|
oauth: s.status,
|
|
28
76
|
expiresIn: s.expiresIn,
|
|
29
77
|
requests: requestCount,
|
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,8 @@ 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
|
+
import { darioVersion } from './version.js';
|
|
11
12
|
import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
|
|
12
13
|
import { stampCch } from './cch.js';
|
|
13
14
|
import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
|
|
@@ -21,7 +22,7 @@ import { createTokenBucket } from './rate-limit.js';
|
|
|
21
22
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
22
23
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
23
24
|
import { redactSecrets } from './redact.js';
|
|
24
|
-
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, isSuspendedModel } from './model-catalog.js';
|
|
25
|
+
import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
|
|
25
26
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
26
27
|
const DEFAULT_PORT = 3456;
|
|
27
28
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
|
|
@@ -1201,17 +1202,50 @@ export async function startProxy(opts = {}) {
|
|
|
1201
1202
|
...SECURITY_HEADERS,
|
|
1202
1203
|
};
|
|
1203
1204
|
const JSON_HEADERS = { 'Content-Type': 'application/json', ...SECURITY_HEADERS };
|
|
1205
|
+
// Pool-aware status for /status + /health (#636). In pool mode the legacy
|
|
1206
|
+
// single-account getStatus() reads credentials.json, which a login-less
|
|
1207
|
+
// pool setup (#618 pool-at-1, #599 admin bootstrap) legitimately doesn't
|
|
1208
|
+
// have — those endpoints reported none/503 while the pool served fine,
|
|
1209
|
+
// breaking docker healthchecks and making the TUI claim the proxy was down.
|
|
1210
|
+
async function currentStatus() {
|
|
1211
|
+
if (!pool)
|
|
1212
|
+
return getStatus();
|
|
1213
|
+
const now = Date.now();
|
|
1214
|
+
return derivePoolStatus(pool.all().map((a) => ({ expiresAt: a.expiresAt, inAuthCooldown: isInAuthCooldown(a, now) })), now, adminEnabled);
|
|
1215
|
+
}
|
|
1204
1216
|
// Model catalog wiring — /v1/models serves the upstream-autodetected set,
|
|
1205
1217
|
// authenticated the same way the request path is (per-token API key when
|
|
1206
|
-
// ANTHROPIC_UPSTREAM_API_KEY is set, OAuth bearer otherwise
|
|
1207
|
-
//
|
|
1218
|
+
// ANTHROPIC_UPSTREAM_API_KEY is set, OAuth bearer otherwise — from the pool
|
|
1219
|
+
// when pool mode is active, so a login-less setup doesn't fail the fetch
|
|
1220
|
+
// with a misleading "Run `dario login` first" (#636)). Prewarmed so the
|
|
1221
|
+
// first client call is answered from cache; every failure path inside
|
|
1208
1222
|
// getModelCatalog falls back to the baked list, so the route always 200s.
|
|
1209
1223
|
const catalogDeps = {
|
|
1210
1224
|
upstreamApiKey: upstreamApiKey || undefined,
|
|
1211
|
-
getToken:
|
|
1225
|
+
getToken: pool
|
|
1226
|
+
? async () => {
|
|
1227
|
+
const now = Date.now();
|
|
1228
|
+
const accounts = pool.all();
|
|
1229
|
+
if (accounts.length === 0) {
|
|
1230
|
+
throw new Error(adminEnabled
|
|
1231
|
+
? 'pool has no accounts yet — add one via POST /admin/login/start'
|
|
1232
|
+
: 'pool has no accounts yet — run `dario accounts add <alias>`');
|
|
1233
|
+
}
|
|
1234
|
+
const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
|
|
1235
|
+
return (usable[0] ?? accounts[0]).accessToken;
|
|
1236
|
+
}
|
|
1237
|
+
: getAccessToken,
|
|
1212
1238
|
log: verbose ? (m) => console.log(m) : undefined,
|
|
1213
1239
|
};
|
|
1214
|
-
|
|
1240
|
+
if (pool && pool.size === 0) {
|
|
1241
|
+
// Empty admin pool: skip the prewarm instead of logging a guaranteed
|
|
1242
|
+
// failure at startup. The catalog refetches when the first account is
|
|
1243
|
+
// hot-added (onAccountsChanged below).
|
|
1244
|
+
console.log('[dario] model catalog: no accounts yet — serving baked list until one is added');
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
prewarmModelCatalog(catalogDeps);
|
|
1248
|
+
}
|
|
1215
1249
|
const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
|
|
1216
1250
|
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
1251
|
const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
|
|
@@ -1234,11 +1268,11 @@ export async function startProxy(opts = {}) {
|
|
|
1234
1268
|
// and dependent services (`depends_on: service_healthy`) need this to
|
|
1235
1269
|
// react instead of cheerfully passing while every /v1/messages 401s.
|
|
1236
1270
|
if (urlPath === '/health' || urlPath === '/') {
|
|
1237
|
-
const s = await
|
|
1271
|
+
const s = await currentStatus();
|
|
1238
1272
|
// Public requests arrive through the Cloudflare tunnel (the edge stamps
|
|
1239
1273
|
// `cf-ray`); they get only the liveness verdict, never the OAuth internals.
|
|
1240
1274
|
// See buildHealthResponse for the full rationale.
|
|
1241
|
-
const { httpStatus, body } = buildHealthResponse(s, requestCount, req.headers['cf-ray'] !== undefined);
|
|
1275
|
+
const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, req.headers['cf-ray'] !== undefined);
|
|
1242
1276
|
res.writeHead(httpStatus, JSON_HEADERS);
|
|
1243
1277
|
res.end(JSON.stringify(body));
|
|
1244
1278
|
return;
|
|
@@ -1263,6 +1297,12 @@ export async function startProxy(opts = {}) {
|
|
|
1263
1297
|
const size = reconcilePoolAccounts(pool, await loadAllAccounts());
|
|
1264
1298
|
if (verbose)
|
|
1265
1299
|
console.log(`[dario] admin: pool hot-reloaded — ${size} account${size === 1 ? '' : 's'}`);
|
|
1300
|
+
// The startup prewarm was skipped (or failed) while the pool was
|
|
1301
|
+
// empty; now that a bearer exists, refetch past the failed-fetch
|
|
1302
|
+
// backoff so /v1/models upgrades from the baked list without
|
|
1303
|
+
// waiting out the retry window (#636).
|
|
1304
|
+
if (size > 0)
|
|
1305
|
+
retryModelCatalogNow(catalogDeps);
|
|
1266
1306
|
}
|
|
1267
1307
|
catch (err) {
|
|
1268
1308
|
console.error(`[dario] admin: pool hot-reload failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -1336,9 +1376,11 @@ export async function startProxy(opts = {}) {
|
|
|
1336
1376
|
}
|
|
1337
1377
|
// Status endpoint
|
|
1338
1378
|
if (urlPath === '/status') {
|
|
1339
|
-
const s = await
|
|
1379
|
+
const s = await currentStatus();
|
|
1380
|
+
// Version for auto-update checks (#640) — /status is key-gated (loopback
|
|
1381
|
+
// or DARIO_API_KEY), so no public-disclosure concern like /health has.
|
|
1340
1382
|
res.writeHead(200, JSON_HEADERS);
|
|
1341
|
-
res.end(JSON.stringify(s));
|
|
1383
|
+
res.end(JSON.stringify({ version: darioVersion(), ...s }));
|
|
1342
1384
|
return;
|
|
1343
1385
|
}
|
|
1344
1386
|
// Pool status endpoint — shows loaded accounts, headroom, and the
|
|
@@ -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;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accounts tab — list of OAuth subscription accounts in the pool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Source of truth is the RUNNING PROXY's live pool (`GET /accounts`), not a
|
|
5
|
+
* local disk read: the TUI is its own process, and in a containerized / admin
|
|
6
|
+
* (#599) / login-less-pool (#630) deployment the accounts live in the proxy's
|
|
7
|
+
* volume, so reading `~/.dario/accounts/` in the TUI process comes up empty
|
|
8
|
+
* while the proxy serves several accounts fine (#641). We fall back to the disk
|
|
9
|
+
* read only when the proxy is unreachable, and flag it so the user knows the
|
|
10
|
+
* view may be stale.
|
|
11
|
+
*
|
|
12
|
+
* Read-mostly. Mutations (add/remove) require the CLI or the admin API — the
|
|
13
|
+
* tab shows the relevant command in the footer.
|
|
6
14
|
*
|
|
7
15
|
* Layout:
|
|
8
16
|
*
|
|
@@ -16,17 +24,21 @@
|
|
|
16
24
|
* To add: `dario accounts add <alias>`
|
|
17
25
|
* To remove: `dario accounts remove <alias>`
|
|
18
26
|
*/
|
|
19
|
-
import type { Tab } from '../tab.js';
|
|
27
|
+
import type { Tab, TabContext } from '../tab.js';
|
|
20
28
|
export interface AccountsState {
|
|
21
29
|
loading: boolean;
|
|
22
30
|
accounts: Array<{
|
|
23
31
|
alias: string;
|
|
24
32
|
expiresAt: number;
|
|
25
|
-
/**
|
|
33
|
+
/** Live pool fields — present when sourced from the proxy's `/accounts`. */
|
|
26
34
|
util5h?: number;
|
|
27
35
|
util7d?: number;
|
|
36
|
+
status?: string;
|
|
28
37
|
}>;
|
|
29
38
|
error: string | null;
|
|
39
|
+
/** Where the list came from: the running proxy's pool, the proxy's
|
|
40
|
+
* single-account mode, or a local disk fallback when the proxy is down. */
|
|
41
|
+
source?: 'pool' | 'single-account' | 'disk';
|
|
30
42
|
}
|
|
31
43
|
export declare const AccountsTab: Tab<AccountsState>;
|
|
32
|
-
export declare function refreshAccounts(): Promise<AccountsState>;
|
|
44
|
+
export declare function refreshAccounts(ctx?: TabContext<AccountsState>): Promise<AccountsState>;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accounts tab — list of OAuth subscription accounts in the pool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Source of truth is the RUNNING PROXY's live pool (`GET /accounts`), not a
|
|
5
|
+
* local disk read: the TUI is its own process, and in a containerized / admin
|
|
6
|
+
* (#599) / login-less-pool (#630) deployment the accounts live in the proxy's
|
|
7
|
+
* volume, so reading `~/.dario/accounts/` in the TUI process comes up empty
|
|
8
|
+
* while the proxy serves several accounts fine (#641). We fall back to the disk
|
|
9
|
+
* read only when the proxy is unreachable, and flag it so the user knows the
|
|
10
|
+
* view may be stale.
|
|
11
|
+
*
|
|
12
|
+
* Read-mostly. Mutations (add/remove) require the CLI or the admin API — the
|
|
13
|
+
* tab shows the relevant command in the footer.
|
|
6
14
|
*
|
|
7
15
|
* Layout:
|
|
8
16
|
*
|
|
@@ -25,8 +33,8 @@ export const AccountsTab = {
|
|
|
25
33
|
initialState() {
|
|
26
34
|
return { loading: true, accounts: [], error: null };
|
|
27
35
|
},
|
|
28
|
-
async onMount(_state,
|
|
29
|
-
return refreshAccounts();
|
|
36
|
+
async onMount(_state, ctx) {
|
|
37
|
+
return refreshAccounts(ctx);
|
|
30
38
|
},
|
|
31
39
|
onKey(state, key) {
|
|
32
40
|
if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
|
|
@@ -34,6 +42,18 @@ export const AccountsTab = {
|
|
|
34
42
|
}
|
|
35
43
|
return undefined;
|
|
36
44
|
},
|
|
45
|
+
onTick(state, ctx) {
|
|
46
|
+
// onKey can only return new state, not run async work — so a manual
|
|
47
|
+
// refresh ('r') just sets loading:true and this tick drives the refetch.
|
|
48
|
+
// `refreshInFlight` guards against the 250ms tick stacking overlapping
|
|
49
|
+
// fetches while one is already running.
|
|
50
|
+
if (state.loading && !refreshInFlight) {
|
|
51
|
+
refreshInFlight = true;
|
|
52
|
+
void refreshAccounts(ctx)
|
|
53
|
+
.then((next) => ctx.setState(next))
|
|
54
|
+
.finally(() => { refreshInFlight = false; });
|
|
55
|
+
}
|
|
56
|
+
},
|
|
37
57
|
render(state, dimv) {
|
|
38
58
|
const lines = [];
|
|
39
59
|
const w = dimv.cols;
|
|
@@ -45,18 +65,42 @@ export const AccountsTab = {
|
|
|
45
65
|
}
|
|
46
66
|
if (state.accounts.length === 0) {
|
|
47
67
|
lines.push('');
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
if (state.source === 'single-account') {
|
|
69
|
+
lines.push(' ' + dim('Single-account mode (`dario login`) — no pool.'));
|
|
70
|
+
lines.push(' ' + 'Start a pool: ' + fg('cyan', 'dario accounts add <alias>'));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
lines.push(' ' + dim('No accounts in the pool.'));
|
|
74
|
+
lines.push(' ' + 'Add one: ' + fg('cyan', 'dario accounts add <alias>'));
|
|
75
|
+
}
|
|
50
76
|
return lines.join('\n');
|
|
51
77
|
}
|
|
78
|
+
// Live pool data (from /accounts) carries utilization; the disk fallback
|
|
79
|
+
// doesn't, so show the util columns only when the pool populated them.
|
|
80
|
+
const hasUtil = state.accounts.some((a) => a.util5h !== undefined);
|
|
81
|
+
if (state.source === 'disk') {
|
|
82
|
+
lines.push(' ' + fg('yellow', 'proxy unreachable — showing on-disk accounts (may be stale)'));
|
|
83
|
+
}
|
|
52
84
|
// Header row
|
|
53
|
-
lines.push(' ' + dim(
|
|
54
|
-
|
|
85
|
+
lines.push(' ' + dim(hasUtil
|
|
86
|
+
? pad('alias', 20) + pad('expires', 14) + pad('util5h', 9) + pad('util7d', 9) + pad('status', 14)
|
|
87
|
+
: pad('alias', 20) + pad('expires', 16) + pad('source', 24)));
|
|
88
|
+
lines.push(' ' + dim('─'.repeat(Math.min(w - 4, 66))));
|
|
55
89
|
for (const acc of state.accounts) {
|
|
56
90
|
const aliasCol = pad(acc.alias, 20);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
91
|
+
if (hasUtil) {
|
|
92
|
+
const expiresCol = pad(formatExpiry(acc.expiresAt), 14);
|
|
93
|
+
const u5 = pad(acc.util5h !== undefined ? `${Math.round(acc.util5h * 100)}%` : '—', 9);
|
|
94
|
+
const u7 = pad(acc.util7d !== undefined ? `${Math.round(acc.util7d * 100)}%` : '—', 9);
|
|
95
|
+
const statusCol = acc.status ?? '—';
|
|
96
|
+
const statusFg = statusCol === 'auth-cooldown' ? fg('yellow', statusCol) : dim(statusCol);
|
|
97
|
+
lines.push(' ' + aliasCol + expiresCol + u5 + u7 + statusFg);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const expiresCol = pad(formatExpiry(acc.expiresAt), 16);
|
|
101
|
+
const sourceCol = '~/.dario/accounts/' + acc.alias + '.json';
|
|
102
|
+
lines.push(' ' + aliasCol + expiresCol + dim(sourceCol));
|
|
103
|
+
}
|
|
60
104
|
}
|
|
61
105
|
lines.push('');
|
|
62
106
|
lines.push(' ' + dim('Mutations via CLI:'));
|
|
@@ -69,27 +113,62 @@ export const AccountsTab = {
|
|
|
69
113
|
return lines.join('\n');
|
|
70
114
|
},
|
|
71
115
|
};
|
|
72
|
-
|
|
116
|
+
/** Guards the onTick refetch against overlapping in-flight fetches. */
|
|
117
|
+
let refreshInFlight = false;
|
|
118
|
+
export async function refreshAccounts(ctx) {
|
|
119
|
+
// Preferred source: the running proxy's live pool. This is what actually
|
|
120
|
+
// serves traffic, and it works regardless of which process/host/volume the
|
|
121
|
+
// TUI itself runs on — the fix for #641, where a containerized proxy held
|
|
122
|
+
// the accounts and the TUI's local disk read came up empty.
|
|
123
|
+
if (ctx) {
|
|
124
|
+
try {
|
|
125
|
+
const r = await ctx.client.getJson('/accounts');
|
|
126
|
+
if (r.mode === 'single-account') {
|
|
127
|
+
return { loading: false, accounts: [], error: null, source: 'single-account' };
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(r.accounts)) {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
return {
|
|
132
|
+
loading: false,
|
|
133
|
+
source: 'pool',
|
|
134
|
+
accounts: r.accounts.map((a) => ({
|
|
135
|
+
alias: a.alias,
|
|
136
|
+
expiresAt: now + (a.expiresInMs ?? 0),
|
|
137
|
+
util5h: a.util5h,
|
|
138
|
+
util7d: a.util7d,
|
|
139
|
+
status: a.status,
|
|
140
|
+
})),
|
|
141
|
+
error: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Unknown shape — fall through to the disk read below.
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Proxy unreachable (not running, wrong port, missing key) — fall back
|
|
148
|
+
// to the on-disk view so a standalone TUI still shows something, flagged
|
|
149
|
+
// stale so the user knows it isn't the live pool.
|
|
150
|
+
return diskFallback();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return diskFallback();
|
|
154
|
+
}
|
|
155
|
+
async function diskFallback() {
|
|
73
156
|
try {
|
|
74
157
|
const { listAccountAliases, loadAllAccounts } = await import('../../accounts.js');
|
|
75
158
|
const aliases = await listAccountAliases();
|
|
76
159
|
if (aliases.length === 0) {
|
|
77
|
-
|
|
78
|
-
// entry sourced from ~/.dario/credentials.json or keychain.
|
|
79
|
-
return { loading: false, accounts: [], error: null };
|
|
160
|
+
return { loading: false, accounts: [], error: null, source: 'disk' };
|
|
80
161
|
}
|
|
81
162
|
const all = await loadAllAccounts();
|
|
82
163
|
return {
|
|
83
164
|
loading: false,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
expiresAt: a.expiresAt,
|
|
87
|
-
})),
|
|
165
|
+
source: 'disk',
|
|
166
|
+
accounts: all.map((a) => ({ alias: a.alias, expiresAt: a.expiresAt })),
|
|
88
167
|
error: null,
|
|
89
168
|
};
|
|
90
169
|
}
|
|
91
170
|
catch (e) {
|
|
92
|
-
return { loading: false, accounts: [], error: e.message };
|
|
171
|
+
return { loading: false, accounts: [], error: e.message, source: 'disk' };
|
|
93
172
|
}
|
|
94
173
|
}
|
|
95
174
|
function formatExpiry(expiresAt) {
|
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/dist/version.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dario's own package version, read once from the bundled package.json.
|
|
3
|
+
*
|
|
4
|
+
* Surfaced on `/status` and `/health` (#640) so a headless operator can confirm
|
|
5
|
+
* an auto-update actually rolled the running proxy — `curl /health | jq .version`
|
|
6
|
+
* beats exec-ing into the container to read package.json.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
let cached = null;
|
|
12
|
+
export function darioVersion() {
|
|
13
|
+
if (cached !== null)
|
|
14
|
+
return cached;
|
|
15
|
+
let v = 'unknown';
|
|
16
|
+
try {
|
|
17
|
+
// dist/version.js → ../package.json (same layout the MCP server + CLI use).
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8'));
|
|
20
|
+
if (typeof pkg.version === 'string')
|
|
21
|
+
v = pkg.version;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// package.json missing/malformed — keep 'unknown', never throw.
|
|
25
|
+
}
|
|
26
|
+
cached = v;
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
/** Test-only: clear the memoized version. */
|
|
30
|
+
export function _resetVersionCacheForTest() {
|
|
31
|
+
cached = null;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.118",
|
|
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": {
|