@askalf/dario 6.0.31 → 6.0.33

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.
@@ -40,9 +40,10 @@
40
40
  * writes behind one HTTP request.
41
41
  *
42
42
  * `GET /admin/accounts` reports each account's persisted metadata — alias,
43
- * scopes, token expiry — plus its live pool status (5h/7d utilization,
44
- * representative-claim, routing status, request count, consecutive auth
45
- * failures) when the proxy supplies a `poolStatus` snapshot, which it does
43
+ * scopes, token expiry — plus its live pool status (5h/7d utilization and
44
+ * how old that reading is, when its window resets, representative-claim,
45
+ * routing status, request and rejection counts, consecutive auth failures)
46
+ * when the proxy supplies a `poolStatus` snapshot, which it does
46
47
  * whenever pool mode is active. It's the headless, admin-token-gated
47
48
  * equivalent of the `GET /accounts` pool view.
48
49
  *
@@ -90,9 +91,24 @@ export interface AdminAccountLive {
90
91
  lastObservedAt: number | null;
91
92
  /** Age of that reading in ms, or `null` when never observed. */
92
93
  utilAgeMs: number | null;
94
+ /**
95
+ * When the window that reading was measured against rolls over — epoch ms
96
+ * and ms-from-now — or `null` when no response has stated one. For a
97
+ * `rejected` seat this is when the rejection lifts (dario#1244).
98
+ */
99
+ resetAt: number | null;
100
+ resetInMs: number | null;
93
101
  claim: string;
94
102
  status: string;
95
103
  requestCount: number;
104
+ /**
105
+ * Upstream 429s this account answered. `requestCount` counts requests it
106
+ * served and a 429 served nothing, so a seat parked on its first attempt
107
+ * read `request_count: 0` next to `status: rejected` (dario#1244).
108
+ */
109
+ rejectedCount: number;
110
+ /** Epoch ms of the most recent 429 on this account, or `null` if never. */
111
+ lastRejectedAt: number | null;
96
112
  /**
97
113
  * Consecutive auth failures on this account (dario#234's cool-down
98
114
  * counter). `status: 'auth-cooldown'` alone doesn't distinguish a single
package/dist/admin-api.js CHANGED
@@ -359,9 +359,18 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
359
359
  ...(l ? {
360
360
  util5h: l.util5h,
361
361
  util7d: l.util7d,
362
+ // The reading's age and its window's reset, so `rejected` says
363
+ // since when and until when — the same fields GET /accounts has
364
+ // carried since #1032 and #1232; this surface dropped them.
365
+ last_observed_at: l.lastObservedAt ?? null,
366
+ util_age_ms: l.utilAgeMs ?? null,
367
+ reset_at: l.resetAt ?? null,
368
+ reset_in_ms: l.resetInMs ?? null,
362
369
  claim: l.claim,
363
370
  status: l.status,
364
371
  request_count: l.requestCount,
372
+ rejected_count: l.rejectedCount ?? 0,
373
+ last_rejected_at: l.lastRejectedAt ?? null,
365
374
  consecutive_auth_failures: l.consecutiveAuthFailures,
366
375
  } : {}),
367
376
  };
@@ -72,7 +72,17 @@ function servableTarget(target, bases) {
72
72
  return null;
73
73
  const resolved = resolveAliasAgainst(stripped, bases) ?? stripped;
74
74
  const base = resolved.endsWith('[1m]') ? resolved.slice(0, -4) : resolved;
75
- return bases.some((b) => b.toLowerCase() === base) ? resolved : null;
75
+ // The catalog keeps ONE spelling per model the short id when upstream
76
+ // lists both `claude-opus-4-8` and `claude-opus-4-8-YYYYMMDD` (see
77
+ // normalizeUpstreamIds) — while a client may send either. Compare with the
78
+ // date stripped on both sides, and return the name as written: Anthropic
79
+ // accepts both forms, so the pool forwards whichever the caller chose.
80
+ const key = undated(base);
81
+ return bases.some((b) => undated(b.toLowerCase()) === key) ? resolved : null;
82
+ }
83
+ /** `claude-opus-4-8-20260101` → `claude-opus-4-8`; anything else unchanged. */
84
+ function undated(id) {
85
+ return id.replace(/-\d{8}$/, '');
76
86
  }
77
87
  /**
78
88
  * The id the Claude pool would serve `model` as AND the effort the entry asked
package/dist/pool.d.ts CHANGED
@@ -65,6 +65,34 @@ export interface UtilFreshness {
65
65
  * null rather than as an age of ~56 years since epoch.
66
66
  */
67
67
  export declare function utilFreshness(rl: RateLimitSnapshot, now: number): UtilFreshness;
68
+ /** When an account's rate-limit window rolls over — see `rateLimitWindow`. */
69
+ export interface RateLimitWindow {
70
+ /**
71
+ * Epoch ms the window resets at, from `anthropic-ratelimit-unified-reset`;
72
+ * null when no response on this account has stated one.
73
+ */
74
+ resetAt: number | null;
75
+ /** Ms until that reset, floored at 0 once it has passed; null when unknown. */
76
+ resetInMs: number | null;
77
+ }
78
+ /**
79
+ * The reset moment of the window an account's last reading was measured
80
+ * against (dario#1244). The snapshot has carried `reset` since the header was
81
+ * first parsed and routing has expired rejections on it since #1232, but no
82
+ * operator surface showed it: a seat read `status: rejected` with nothing
83
+ * saying until when, and `requestCount: 0` beside it (a 429 serves nothing,
84
+ * so the attempt was never counted) made the rejection look like one dario
85
+ * had made up. For a `rejected` seat this is when the rejection lifts; for
86
+ * an `allowed` one, when its representative window rolls. The header is
87
+ * epoch SECONDS; both fields here are milliseconds, like `expiresInMs` and
88
+ * `utilAgeMs`.
89
+ */
90
+ export declare function rateLimitWindow(rl: RateLimitSnapshot, now: number): RateLimitWindow;
91
+ /**
92
+ * One line for a log or a doctor row:
93
+ * `5h 104%, 7d 25%, claim five_hour, resets in 37m`.
94
+ */
95
+ export declare function describeRateLimitSnapshot(rl: RateLimitSnapshot, now?: number): string;
68
96
  export interface PoolAccount {
69
97
  alias: string;
70
98
  accessToken: string;
@@ -73,6 +101,16 @@ export interface PoolAccount {
73
101
  identity: AccountIdentity;
74
102
  rateLimit: RateLimitSnapshot;
75
103
  requestCount: number;
104
+ /**
105
+ * Upstream 429s this account has answered. `requestCount` counts requests
106
+ * the account SERVED, and a 429 served nothing — so a seat parked on its
107
+ * first attempt read `requestCount: 0` next to `status: rejected`, as if
108
+ * dario had rejected a seat it never called (dario#1244). This is the field
109
+ * that says it was tried.
110
+ */
111
+ rejectedCount: number;
112
+ /** Epoch ms of the most recent 429 on this account; undefined if never. */
113
+ lastRejectedAt?: number;
76
114
  /** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
77
115
  grantedAt?: number;
78
116
  /**
@@ -304,7 +342,14 @@ export declare class AccountPool {
304
342
  /** Select the next-best account, excluding the given set of aliases. */
305
343
  selectExcluding(excluded: Set<string>, family?: string | null): PoolAccount | null;
306
344
  updateRateLimits(alias: string, snapshot: RateLimitSnapshot): void;
307
- markRejected(alias: string, snapshot: RateLimitSnapshot): void;
345
+ /**
346
+ * Park `alias` on an upstream 429. Returns true when this takes a seat OUT
347
+ * of rotation — the first 429 of a window — and false when the seat was
348
+ * already parked inside a live window: the all-exhausted fallback in
349
+ * `select()` re-probes parked seats, so a pool with nothing left can 429
350
+ * the same seat many times, and only the transition is worth a log line.
351
+ */
352
+ markRejected(alias: string, snapshot: RateLimitSnapshot): boolean;
308
353
  updateTokens(alias: string, accessToken: string, refreshToken: string, expiresAt: number): void;
309
354
  get(alias: string): PoolAccount | undefined;
310
355
  all(): PoolAccount[];
package/dist/pool.js CHANGED
@@ -58,6 +58,42 @@ export function utilFreshness(rl, now) {
58
58
  utilAgeMs: lastObservedAt === null ? null : Math.max(0, now - lastObservedAt),
59
59
  };
60
60
  }
61
+ /**
62
+ * The reset moment of the window an account's last reading was measured
63
+ * against (dario#1244). The snapshot has carried `reset` since the header was
64
+ * first parsed and routing has expired rejections on it since #1232, but no
65
+ * operator surface showed it: a seat read `status: rejected` with nothing
66
+ * saying until when, and `requestCount: 0` beside it (a 429 serves nothing,
67
+ * so the attempt was never counted) made the rejection look like one dario
68
+ * had made up. For a `rejected` seat this is when the rejection lifts; for
69
+ * an `allowed` one, when its representative window rolls. The header is
70
+ * epoch SECONDS; both fields here are milliseconds, like `expiresInMs` and
71
+ * `utilAgeMs`.
72
+ */
73
+ export function rateLimitWindow(rl, now) {
74
+ if (!(rl.reset > 0))
75
+ return { resetAt: null, resetInMs: null };
76
+ const resetAt = rl.reset * 1000;
77
+ return { resetAt, resetInMs: Math.max(0, resetAt - now) };
78
+ }
79
+ /**
80
+ * One line for a log or a doctor row:
81
+ * `5h 104%, 7d 25%, claim five_hour, resets in 37m`.
82
+ */
83
+ export function describeRateLimitSnapshot(rl, now = Date.now()) {
84
+ const pct = (n) => `${Math.round(n * 100)}%`;
85
+ const { resetInMs } = rateLimitWindow(rl, now);
86
+ const reset = resetInMs === null ? 'no reset stated'
87
+ : resetInMs === 0 ? 'window already rolled'
88
+ : `resets in ${formatDurationMs(resetInMs)}`;
89
+ return `5h ${pct(rl.util5h)}, 7d ${pct(rl.util7d)}, claim ${rl.claim}, ${reset}`;
90
+ }
91
+ function formatDurationMs(ms) {
92
+ const totalMins = Math.max(1, Math.round(ms / 60_000));
93
+ const h = Math.floor(totalMins / 60);
94
+ const m = totalMins % 60;
95
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
96
+ }
61
97
  /**
62
98
  * Cool-down schedule after auth failures. First failure: 60s. Each
63
99
  * consecutive failure doubles the window up to 30 minutes. Cleared
@@ -318,21 +354,36 @@ export class AccountPool {
318
354
  }
319
355
  add(alias, opts) {
320
356
  const existing = this.accounts.get(alias);
357
+ // A record whose grantedAt differs from the live entry's is a NEW grant
358
+ // under this alias — a re-login, possibly on a different organization
359
+ // with its own windows. The live state describes the old credential (its
360
+ // rejection and reading, its auth streak, its identity), so it starts
361
+ // fresh (dario#1244): before this, a seat re-granted to clear
362
+ // `auth-cooldown` stayed cooling until the old streak's timer ran out,
363
+ // and one re-granted on another organization stayed parked on the old
364
+ // organization's window. A reconcile carrying the same grant — a token
365
+ // refresh, an admin change to another seat, a peer instance's rotation in
366
+ // HA — keeps the live state as before. So does a record with no grantedAt
367
+ // at all: it cannot be told apart from the same grant.
368
+ const regranted = existing !== undefined && opts.grantedAt !== undefined && opts.grantedAt !== existing.grantedAt;
369
+ const keep = regranted ? undefined : existing;
321
370
  this.accounts.set(alias, {
322
371
  alias,
323
372
  accessToken: opts.accessToken,
324
373
  refreshToken: opts.refreshToken,
325
374
  expiresAt: opts.expiresAt,
326
- grantedAt: opts.grantedAt ?? existing?.grantedAt,
327
- identity: existing?.identity ?? {
375
+ grantedAt: opts.grantedAt ?? keep?.grantedAt,
376
+ identity: keep?.identity ?? {
328
377
  deviceId: opts.deviceId,
329
378
  accountUuid: opts.accountUuid,
330
379
  sessionId: randomUUID(),
331
380
  },
332
- rateLimit: existing?.rateLimit ?? { ...EMPTY_SNAPSHOT },
333
- requestCount: existing?.requestCount ?? 0,
334
- lastAuthFailureAt: existing?.lastAuthFailureAt,
335
- consecutiveAuthFailures: existing?.consecutiveAuthFailures ?? 0,
381
+ rateLimit: keep?.rateLimit ?? { ...EMPTY_SNAPSHOT },
382
+ requestCount: keep?.requestCount ?? 0,
383
+ rejectedCount: keep?.rejectedCount ?? 0,
384
+ lastRejectedAt: keep?.lastRejectedAt,
385
+ lastAuthFailureAt: keep?.lastAuthFailureAt,
386
+ consecutiveAuthFailures: keep?.consecutiveAuthFailures ?? 0,
336
387
  });
337
388
  }
338
389
  remove(alias) {
@@ -549,11 +600,23 @@ export class AccountPool {
549
600
  account.rateLimit = snapshot;
550
601
  account.requestCount++;
551
602
  }
603
+ /**
604
+ * Park `alias` on an upstream 429. Returns true when this takes a seat OUT
605
+ * of rotation — the first 429 of a window — and false when the seat was
606
+ * already parked inside a live window: the all-exhausted fallback in
607
+ * `select()` re-probes parked seats, so a pool with nothing left can 429
608
+ * the same seat many times, and only the transition is worth a log line.
609
+ */
552
610
  markRejected(alias, snapshot) {
553
611
  const account = this.accounts.get(alias);
554
612
  if (!account)
555
- return;
613
+ return false;
614
+ const now = snapshot.updatedAt || Date.now();
615
+ const wasParked = account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now);
556
616
  account.rateLimit = { ...snapshot, status: 'rejected' };
617
+ account.rejectedCount++;
618
+ account.lastRejectedAt = now;
619
+ return !wasParked;
557
620
  }
558
621
  updateTokens(alias, accessToken, refreshToken, expiresAt) {
559
622
  const account = this.accounts.get(alias);
package/dist/proxy.js CHANGED
@@ -12,7 +12,7 @@ import { darioVersion } from './version.js';
12
12
  import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
13
13
  import { stampCch, hasCchSeed } from './cch.js';
14
14
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
15
- import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, describeRateLimitSnapshot } from './pool.js';
16
16
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
17
17
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
18
18
  import { notify as osNotify } from './notify.js';
@@ -23,6 +23,8 @@ import { handleAdminRequest } from './admin-api.js';
23
23
  import { createTokenBucket } from './rate-limit.js';
24
24
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
25
25
  import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
26
+ import { isClaudeServableModel } from './claude-model.js';
27
+ import { MODEL_UNROUTABLE } from './upstream-rejection.js';
26
28
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
27
29
  import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
28
30
  import { route as routeProvider } from './provider-adapter.js';
@@ -2100,9 +2102,12 @@ export async function startProxy(opts = {}) {
2100
2102
  // surface documents itself as reporting the same snapshot, so it
2101
2103
  // must not be the one place a stale reading still looks current.
2102
2104
  ...utilFreshness(a.rateLimit, snapNow),
2105
+ ...rateLimitWindow(a.rateLimit, snapNow),
2103
2106
  claim: a.rateLimit.claim,
2104
2107
  status: reportedAccountStatus(a, snapNow),
2105
2108
  requestCount: a.requestCount,
2109
+ rejectedCount: a.rejectedCount,
2110
+ lastRejectedAt: a.lastRejectedAt ?? null,
2106
2111
  // Raw streak, not just the cooldown boolean: a single 401 also
2107
2112
  // shows `auth-cooldown` for 60s, indistinguishable from a
2108
2113
  // genuinely dead refresh token by that field alone. The magnitude
@@ -2199,9 +2204,16 @@ export async function startProxy(opts = {}) {
2199
2204
  util5h: a.rateLimit.util5h,
2200
2205
  util7d: a.rateLimit.util7d,
2201
2206
  ...utilFreshness(a.rateLimit, now),
2207
+ // When that window rolls (dario#1244): for a rejected seat, when
2208
+ // the rejection lifts. Milliseconds, like expiresInMs.
2209
+ ...rateLimitWindow(a.rateLimit, now),
2202
2210
  claim: a.rateLimit.claim,
2203
2211
  status: reportedAccountStatus(a, now),
2204
2212
  requestCount: a.requestCount,
2213
+ // 429s answered — the attempts requestCount does not count, so a
2214
+ // parked seat no longer reads as one that was never called.
2215
+ rejectedCount: a.rejectedCount,
2216
+ lastRejectedAt: a.lastRejectedAt ?? null,
2205
2217
  expiresInMs: Math.max(0, a.expiresAt - now),
2206
2218
  // Refresh-token grant age (refresh-grant.ts): the wall a token
2207
2219
  // refresh cannot move. null fields = grant date unknown.
@@ -2726,6 +2738,11 @@ export async function startProxy(opts = {}) {
2726
2738
  // wire shape, and no upstream round-trip for a request nothing can serve.
2727
2739
  // Upstreams do the same (Anthropic: "The request body is not valid
2728
2740
  // JSON"; OpenAI: "We could not parse the JSON body of your request").
2741
+ //
2742
+ // The object this guard parses is kept as `parsedBody` and reused by the
2743
+ // provider-prefix block and the template build below, so the bytes are
2744
+ // JSON.parsed once per request (#642-audit; second-read finding on #1231).
2745
+ let parsedBody = null;
2729
2746
  {
2730
2747
  let invalid = null;
2731
2748
  if (body.length === 0)
@@ -2740,6 +2757,8 @@ export async function startProxy(opts = {}) {
2740
2757
  const v = JSON.parse(text);
2741
2758
  if (v === null || typeof v !== 'object' || Array.isArray(v))
2742
2759
  invalid = 'request body must be a JSON object';
2760
+ else
2761
+ parsedBody = v;
2743
2762
  }
2744
2763
  catch (err) {
2745
2764
  invalid = `request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`;
@@ -2775,15 +2794,14 @@ export async function startProxy(opts = {}) {
2775
2794
  // recognizes through its own Anthropic gateway, bypassing localhost).
2776
2795
  let forcedProvider = cliProviderOverride;
2777
2796
  let requestEffort; // dario#419 — per-request effort parsed from a model-name suffix (model:high / model-high)
2778
- // Parsed body, shared between the provider-prefix detection below and the
2779
- // template-build block further down so the same bytes are not JSON.parsed
2780
- // twice per request (#642-audit). Mutations in the prefix block re-serialize
2781
- // `body` FROM this object, so it always represents the current body.
2782
- let parsedBody = null;
2783
- if (body.length > 0) {
2797
+ // `parsedBody` was parsed by the invalid-body guard above and is shared
2798
+ // with the template-build block further down so the same bytes are not
2799
+ // JSON.parsed twice per request (#642-audit). Mutations in the prefix block
2800
+ // re-serialize `body` FROM this object, so it always represents the current
2801
+ // body.
2802
+ if (parsedBody !== null) {
2784
2803
  try {
2785
- const parsed = JSON.parse(body.toString());
2786
- parsedBody = parsed;
2804
+ const parsed = parsedBody;
2787
2805
  // User-defined aliases first — before provider-prefix parsing, so
2788
2806
  // an alias target carrying a prefix (`my-fast` → `openai:gpt-4o`)
2789
2807
  // retargets the backend through the existing machinery below.
@@ -2927,7 +2945,7 @@ export async function startProxy(opts = {}) {
2927
2945
  // reaches the subscription even when an API-key backend is configured too.
2928
2946
  if (body.length > 0) {
2929
2947
  try {
2930
- const peek = JSON.parse(body.toString());
2948
+ const peek = (parsedBody ?? {}); // parsed once by the invalid-body guard; `body` is re-serialized from it
2931
2949
  const rawModel = (peek.model || '').toString();
2932
2950
  const requestPoolFallbackModels = selectPoolFallbackModels(poolFallbackSpec, rawModel);
2933
2951
  const requestPoolFallbackModel = requestPoolFallbackModels[0] ?? null;
@@ -3117,6 +3135,42 @@ export async function startProxy(opts = {}) {
3117
3135
  await forwardToOpenAI(req, res, body, openaiBackend, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose);
3118
3136
  return;
3119
3137
  }
3138
+ // dario#1236 — the Claude adapter claims by default, so a model NO
3139
+ // provider lists used to reach api.anthropic.com verbatim and come
3140
+ // back as Anthropic's 404 `model: gpt-5.6-sol`, attributed to
3141
+ // whichever seat sent it, after spending a pool request. Ask the
3142
+ // positive question the failover chain already asks (claude-model.ts)
3143
+ // and refuse locally instead.
3144
+ //
3145
+ // Deliberately NOT refused: a `claude-*` name the catalog does not
3146
+ // know (the live catalog can lag a model by a fetch, and on a cold
3147
+ // start it is the baked list — Anthropic's own 404 stays
3148
+ // authoritative for those); a request under a server-wide
3149
+ // --model/--fast-model override, which replaces the name; upstream
3150
+ // API-key mode, which has no pool to protect and may reach models the
3151
+ // OAuth catalog never lists; and an OpenAI-shape name the legacy
3152
+ // OPENAI_MODEL_MAP translates to a Claude model.
3153
+ if (rawModel && decision.provider === 'claude' && !upstreamApiKey && !modelOverride && !fastModelOverride
3154
+ && !(isOpenAI && OPENAI_MODEL_MAP[rawModel])
3155
+ && !/^claude-/i.test(rawModel.trim())
3156
+ && !isClaudeServableModel(rawModel, getCachedBases(), (m) => resolveClaudeAlias(applyModelAlias(m, modelAliases) ?? m))) {
3157
+ const consulted = [
3158
+ codexCreds || codexUnavailable
3159
+ ? `codex account ${(codexCreds ?? codexUnavailable).alias} (${codexModels.length} listed slug${codexModels.length === 1 ? '' : 's'})`
3160
+ : 'no codex account',
3161
+ openaiBackend ? `openai backend ${openaiBackend.name}${isOpenAI ? '' : ' (OpenAI path only)'}` : 'no openai backend',
3162
+ `claude catalog (${getCachedBases().length} bases)`,
3163
+ ].join(', ');
3164
+ if (verbose)
3165
+ console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) no provider lists ${rawModel}; refusing — consulted ${consulted}`);
3166
+ requestCount++;
3167
+ const message = `no provider lists model "${rawModel}" (consulted ${consulted}); refused locally rather than forwarded to the Claude pool, which would 404 it after spending a request`;
3168
+ res.writeHead(400, { ...JSON_HEADERS, 'x-dario-upstream-rejection': MODEL_UNROUTABLE });
3169
+ res.end(JSON.stringify(isOpenAI
3170
+ ? { error: { message, type: 'invalid_request_error', param: 'model', code: 'model_not_found' } }
3171
+ : { type: 'error', error: { type: 'invalid_request_error', message } }));
3172
+ return;
3173
+ }
3120
3174
  }
3121
3175
  catch { /* not JSON — fall through to existing path */ }
3122
3176
  }
@@ -3679,7 +3733,15 @@ export async function startProxy(opts = {}) {
3679
3733
  if (poolAccount) {
3680
3734
  const snapshot = parseRateLimits(upstream.headers);
3681
3735
  if (upstream.status === 429) {
3682
- pool.markRejected(poolAccount.alias, snapshot);
3736
+ // Say so the moment a seat leaves rotation. With a peer to fail
3737
+ // over to the client sees 200, and nothing else named the seat,
3738
+ // the reading, or when it comes back (dario#1244). Once per
3739
+ // parking: the all-exhausted fallback re-probes parked seats, and
3740
+ // those repeats are verbose-only.
3741
+ const parked = pool.markRejected(poolAccount.alias, snapshot);
3742
+ if (parked || verbose) {
3743
+ console.error(`[dario] #${requestCount} rate limited (429) on account "${poolAccount.alias}": ${describeRateLimitSnapshot(snapshot)} — parked until the window rolls`);
3744
+ }
3683
3745
  }
3684
3746
  else {
3685
3747
  pool.updateRateLimits(poolAccount.alias, snapshot);
@@ -34,6 +34,8 @@ export interface AccountsState {
34
34
  util5h?: number;
35
35
  util7d?: number;
36
36
  status?: string;
37
+ /** Ms until the seat's rate-limit window rolls; null/absent when unknown. */
38
+ resetInMs?: number | null;
37
39
  }>;
38
40
  error: string | null;
39
41
  /** Where the list came from: the running proxy's pool, the proxy's
@@ -98,8 +98,11 @@ export const AccountsTab = {
98
98
  const expiresCol = pad(formatExpiry(acc.expiresAt), 14);
99
99
  const u5 = pad(acc.util5h !== undefined ? `${Math.round(acc.util5h * 100)}%` : '—', 9);
100
100
  const u7 = pad(acc.util7d !== undefined ? `${Math.round(acc.util7d * 100)}%` : '—', 9);
101
- const statusCol = acc.status ?? '—';
102
- const statusFg = statusCol === 'auth-cooldown' ? fg('yellow', statusCol) : dim(statusCol);
101
+ // A parked seat says for how long (dario#1244): "rejected 37m".
102
+ const statusCol = acc.status === 'rejected' && typeof acc.resetInMs === 'number'
103
+ ? `rejected ${formatCountdown(acc.resetInMs)}`
104
+ : (acc.status ?? '—');
105
+ const statusFg = statusCol === 'auth-cooldown' || acc.status === 'rejected' ? fg('yellow', statusCol) : dim(statusCol);
103
106
  push(' ' + aliasCol + expiresCol + u5 + u7 + statusFg);
104
107
  }
105
108
  else {
@@ -143,6 +146,7 @@ export async function refreshAccounts(ctx) {
143
146
  util5h: a.util5h,
144
147
  util7d: a.util7d,
145
148
  status: a.status,
149
+ resetInMs: a.resetInMs,
146
150
  })),
147
151
  error: null,
148
152
  };
@@ -177,6 +181,15 @@ async function diskFallback() {
177
181
  return { loading: false, accounts: [], error: e.message, source: 'disk' };
178
182
  }
179
183
  }
184
+ /** `37m` / `4h59m` / `now` — how long until a parked seat's window rolls. */
185
+ function formatCountdown(ms) {
186
+ if (ms <= 0)
187
+ return 'now';
188
+ const totalMins = Math.max(1, Math.round(ms / 60_000));
189
+ const h = Math.floor(totalMins / 60);
190
+ const m = totalMins % 60;
191
+ return h > 0 ? `${h}h${m}m` : `${m}m`;
192
+ }
180
193
  function formatExpiry(expiresAt) {
181
194
  if (expiresAt === 0)
182
195
  return dim('—');
@@ -3,6 +3,14 @@ export interface UpstreamRejection {
3
3
  class: UpstreamRejectionClass;
4
4
  marker: 'billing_required' | 'rate_limited' | 'credential_rejected' | 'upstream_rejected';
5
5
  }
6
+ /**
7
+ * `x-dario-upstream-rejection` value for a request dario refused LOCALLY
8
+ * because no provider lists its model (dario#1236). Not a classification of
9
+ * an upstream answer — there was no upstream request, which is the point — but
10
+ * it rides the same header so a fleet log or tracker reads one field for
11
+ * every "this request was not served" verdict.
12
+ */
13
+ export declare const MODEL_UNROUTABLE = "model_unroutable";
6
14
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
7
15
  export declare function classifyUpstreamRejection(status: number, body: string): UpstreamRejection;
8
16
  /** Operator action paired with the failure class. Never suggest credential churn for billing. */
@@ -1,3 +1,11 @@
1
+ /**
2
+ * `x-dario-upstream-rejection` value for a request dario refused LOCALLY
3
+ * because no provider lists its model (dario#1236). Not a classification of
4
+ * an upstream answer — there was no upstream request, which is the point — but
5
+ * it rides the same header so a fleet log or tracker reads one field for
6
+ * every "this request was not served" verdict.
7
+ */
8
+ export const MODEL_UNROUTABLE = 'model_unroutable';
1
9
  /** Classify subscription entitlement failures separately from temporary quota exhaustion. */
2
10
  export function classifyUpstreamRejection(status, body) {
3
11
  const normalized = body.toLowerCase();
package/docs/admin-api.md CHANGED
@@ -95,12 +95,20 @@ All endpoints accept the token as `authorization: Bearer <token>` or
95
95
  | `DELETE /admin/accounts/<alias>` | — | `{ alias, removed }` (`404` if no such alias) |
96
96
 
97
97
  `GET /admin/accounts` is the monitoring surface: each entry carries the
98
- persisted metadata (`alias`, `scopes`, `expires_in_ms`) **plus live pool
99
- status whenever pool mode is active** — `util5h` / `util7d` utilization,
100
- representative `claim` (e.g. `five_hour`), routing `status`,
101
- `request_count`, and `consecutive_auth_failures`. It's the admin-token-gated
102
- equivalent of the proxy-key-gated `GET /accounts` pool view; a headless
103
- operator needs only the admin token to watch headroom.
98
+ persisted metadata (`alias`, `scopes`, `expires_in_ms`, the grant-age fields)
99
+ **plus live pool status whenever pool mode is active** — `util5h` / `util7d`
100
+ utilization with `last_observed_at` / `util_age_ms` (how old that reading is;
101
+ it does not tick while a seat is parked), `reset_at` / `reset_in_ms` (when
102
+ the window it was measured against rolls for a `rejected` seat, when the
103
+ rejection lifts), representative `claim` (e.g. `five_hour`), routing
104
+ `status`, `request_count` (requests served), `rejected_count` /
105
+ `last_rejected_at` (429s answered — a 429 serves nothing, so it is not a
106
+ request), and `consecutive_auth_failures`. What each `status` means and what
107
+ to do about it: [Reading a seat's `status`](./multi-account-pool.md#reading-a-seats-status).
108
+ It's the admin-token-gated equivalent of the proxy-key-gated `GET /accounts`
109
+ pool view; a headless operator needs only the admin token to watch headroom.
110
+ Completing a login for an alias that is already in the pool re-grants it: the
111
+ seat starts fresh — no carried-over rejection, cool-down or identity.
104
112
 
105
113
  ## Pinning a request to one seat
106
114
 
@@ -24,8 +24,8 @@ services:
24
24
  expose: ["3456"]
25
25
 
26
26
  cordon:
27
- # cordon does not publish an image yet; build it from the repo.
28
- build: https://github.com/askalf/cordon.git
27
+ # multi-arch, Sigstore-attested: gh attestation verify oci://ghcr.io/askalf/cordon:v0.2.0 --repo askalf/cordon
28
+ image: ghcr.io/askalf/cordon:v0.2.0
29
29
  environment:
30
30
  ANTHROPIC_BASE: http://dario:3456
31
31
  OPENAI_BASE: http://dario:3456
@@ -91,6 +91,19 @@ curl http://localhost:3456/accounts # per-account utilization, claim, sticky
91
91
  curl http://localhost:3456/analytics # per-account / per-model stats, burn rate, exhaustion predictions
92
92
  ```
93
93
 
94
+ ## Reading a seat's `status`
95
+
96
+ `GET /accounts` (and the admin API's `GET /admin/accounts`, in snake_case) report one `status` per seat. It is the routing verdict, and every value comes with the fields that explain it.
97
+
98
+ | `status` | What it means | What to do |
99
+ |---|---|---|
100
+ | `allowed` | The seat's last response was a 200 with headroom. `util5h` / `util7d` are that response's reading — a ratio against 1.0, so `0.42` is 42% — `lastObservedAt` / `utilAgeMs` say how old it is, `resetAt` / `resetInMs` when its representative window rolls. | Nothing. |
101
+ | `rejected` | The seat's last response was a 429: the organization behind its token is over the window named by `claim` (`five_hour`, `seven_day`, …). `util5h: 1.04` is 104% of the five-hour window, not 1%. `rejectedCount` / `lastRejectedAt` say the seat was tried — a 429 serves nothing, so `requestCount` does not move — and `resetInMs` says how long it stays parked. Requests route around it; it returns on its own when the window rolls. | Nothing — the window clears itself. If the reading surprises you (your usage page for that account says 0%), the token belongs to a different organization than the page you are looking at, or to the same organization as another seat: the reading is Anthropic's own, taken on that token. `dario accounts check <alias>` asks the seat directly. |
102
+ | `unknown` | No current observation: a seat that has served nothing yet, or a rejection whose window has rolled (`resetInMs: 0`) and that nothing has measured since. | Nothing; the next request measures it. |
103
+ | `auth-cooldown` | Upstream answered 401/403 or `invalid_grant`. `consecutiveAuthFailures` tells a blip (1) from a dead refresh token (a streak); the cool-down doubles with the streak, from 1 minute to 30. | A streak means re-grant the seat — `dario accounts remove` + `add`, or the admin login flow under the same alias. A new grant starts the seat fresh: no carried-over cool-down, rejection or identity. See [Refresh-token grant age](#refresh-token-grant-age) for the 28-day wall behind most streaks. |
104
+
105
+ The proxy logs every parking as it happens, once per window: `rate limited (429) on account "spare": 5h 104%, 7d 25%, claim five_hour, resets in 37m — parked until the window rolls`. The re-probes the all-exhausted fallback makes of an already-parked seat are logged only under `-v`.
106
+
94
107
  Every request carries a `billingBucket` field (`subscription` / `subscription_fallback` / `extra_usage` / `api` / `unknown`) so you can see which bucket each request billed against and a `subscriptionPercent` headline number tells you at a glance whether dario is actually routing through your subscription or silently falling to API overage.
95
108
 
96
109
  ## Refresh-token grant age
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.31",
3
+ "version": "6.0.33",
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": {
@@ -40,7 +40,9 @@
40
40
  "check:overage": "node scripts/check-overage-live.mjs",
41
41
  "cch:calibrate": "node scripts/cch-calibrate.mjs",
42
42
  "fix:pkg": "node -e \"const fs=require('fs');fs.writeFileSync('package.json',JSON.stringify(JSON.parse(fs.readFileSync('package.json','utf-8')),null,2)+'\\n')\"",
43
- "audit:tui": "node tools/tui-audit/audit.mjs"
43
+ "audit:tui": "node tools/tui-audit/audit.mjs",
44
+ "readme:assets": "node scripts/readme/hero.mjs && node scripts/readme/terminal.mjs && node scripts/readme/tui.mjs",
45
+ "check:readme": "node scripts/check-readme-line-count.mjs && node scripts/check-readme-links.mjs"
44
46
  },
45
47
  "keywords": [
46
48
  "llm",