@askalf/dario 6.0.37 → 6.0.39

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/proxy.js CHANGED
@@ -12,7 +12,8 @@ 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, rateLimitWindow, describeRateLimitSnapshot, windowPeers, distinctWindows, accountAction } from './pool.js';
15
+ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail } from './pool.js';
16
+ import { backfillIdentity } from './accounts.js';
16
17
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
17
18
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
18
19
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
@@ -23,7 +24,8 @@ import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncL
23
24
  import { handleAdminRequest } from './admin-api.js';
24
25
  import { createTokenBucket } from './rate-limit.js';
25
26
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
26
- import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
27
+ import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
28
+ import { effortForCodex } from './effort.js';
27
29
  import { isClaudeServableModel } from './claude-model.js';
28
30
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
29
31
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
@@ -1329,22 +1331,23 @@ export async function startProxy(opts = {}) {
1329
1331
  const accountsList = await loadAllAccounts();
1330
1332
  const poolStrategy = resolvePoolStrategy(opts.poolStrategy);
1331
1333
  const pool = new AccountPool(poolStrategy);
1332
- // Two aliases reporting one window are one subscription counted twice
1333
- // (dario#1244). Said once per pair, when the second reading arrives; the
1334
- // listings carry it permanently as `sharesWindowWith`.
1335
- const announcedWindowPairs = new Set();
1336
- const announceWindowPeers = (alias) => {
1337
- const now = Date.now();
1338
- const seat = pool.get(alias);
1339
- if (!seat)
1340
- return;
1341
- for (const peer of windowPeers(pool.all(), now).get(alias) ?? []) {
1342
- const pair = [alias, peer].sort().join('|');
1343
- if (announcedWindowPairs.has(pair))
1344
- continue;
1345
- announcedWindowPairs.add(pair);
1346
- const windows = distinctWindows(pool.all(), now);
1347
- console.error(`[dario] seats "${alias}" and "${peer}" report the same ${seat.rateLimit.claim} window (resets ${new Date(seat.rateLimit.reset * 1000).toISOString()}) — one subscription under two aliases; the pool has ${windows} distinct window${windows === 1 ? '' : 's'} across ${pool.size} seats`);
1334
+ // Two aliases that are one account (same OAuth account uuid) are one
1335
+ // subscription counted twice (dario#1244). Said once per pair, from what the
1336
+ // records know at load and after any reconcile; the listings carry it
1337
+ // permanently as `sameAccountAs` / `sharesWindowWith`. Identity, not the
1338
+ // reset-second inference this replaced (dario#1263).
1339
+ const announcedAccountPairs = new Set();
1340
+ const announceSameAccounts = () => {
1341
+ const peers = accountPeers(pool.all());
1342
+ for (const seat of pool.all()) {
1343
+ for (const peer of peers.get(seat.alias) ?? []) {
1344
+ const pair = [seat.alias, peer].sort().join('|');
1345
+ if (announcedAccountPairs.has(pair))
1346
+ continue;
1347
+ announcedAccountPairs.add(pair);
1348
+ const who = seat.accountEmail ? ` (${maskEmail(seat.accountEmail)})` : '';
1349
+ console.error(`[dario] seats "${seat.alias}" and "${peer}" are the same account${who} — one subscription under two aliases; ${distinctAccounts(pool.all())} distinct accounts across ${pool.size} seats`);
1350
+ }
1348
1351
  }
1349
1352
  };
1350
1353
  // Shared pool state across instances (docs/multi-instance.md): opt-in,
@@ -1436,8 +1439,13 @@ export async function startProxy(opts = {}) {
1436
1439
  accountUuid: acc.accountUuid,
1437
1440
  grantedAt: acc.grantedAt,
1438
1441
  organizationId: acc.organizationId,
1442
+ accountId: acc.accountId,
1443
+ accountEmail: acc.accountEmail,
1444
+ rateLimitTier: acc.rateLimitTier,
1445
+ seatTier: acc.seatTier,
1439
1446
  });
1440
1447
  }
1448
+ announceSameAccounts();
1441
1449
  // Startup self-heal (dario#790): eagerly refresh any account whose access
1442
1450
  // token is already expired or within the 45-min refresh window BEFORE the
1443
1451
  // proxy starts serving. On a container recreate after >8h uptime the
@@ -1460,6 +1468,22 @@ export async function startProxy(opts = {}) {
1460
1468
  // observed on (dario#1244) — the one write that touches the record.
1461
1469
  const refreshed = await refreshAccountToken(withObservedOrganization(saved, acc.organizationId));
1462
1470
  pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
1471
+ // Identity back-fill (dario#1263): a record from before accountId learns
1472
+ // who it is on its first refresh under this release — one GET through
1473
+ // this proxy's fetch, never fatal — and the running seat is reconciled so
1474
+ // the listings and the same-account line know at once.
1475
+ if (!refreshed.accountId) {
1476
+ const identified = await backfillIdentity(refreshed, fetch).catch(() => null);
1477
+ if (identified?.accountId) {
1478
+ pool.add(acc.alias, {
1479
+ accessToken: identified.accessToken, refreshToken: identified.refreshToken, expiresAt: identified.expiresAt,
1480
+ deviceId: identified.deviceId, accountUuid: identified.accountUuid, grantedAt: identified.grantedAt,
1481
+ organizationId: identified.organizationId, accountId: identified.accountId, accountEmail: identified.accountEmail,
1482
+ rateLimitTier: identified.rateLimitTier, seatTier: identified.seatTier,
1483
+ });
1484
+ announceSameAccounts();
1485
+ }
1486
+ }
1463
1487
  // Mirror a refreshed `login` token back to credentials.json so the
1464
1488
  // legacy file (and `dario doctor`) tracks the pool store (#808).
1465
1489
  await mirrorLoginToCredentials(refreshed).catch((err) => {
@@ -1514,6 +1538,22 @@ export async function startProxy(opts = {}) {
1514
1538
  // observed on (dario#1244) — the one write that touches the record.
1515
1539
  const refreshed = await refreshAccountToken(withObservedOrganization(saved, acc.organizationId));
1516
1540
  pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
1541
+ // Identity back-fill (dario#1263): a record from before accountId learns
1542
+ // who it is on its first refresh under this release — one GET through
1543
+ // this proxy's fetch, never fatal — and the running seat is reconciled so
1544
+ // the listings and the same-account line know at once.
1545
+ if (!refreshed.accountId) {
1546
+ const identified = await backfillIdentity(refreshed, fetch).catch(() => null);
1547
+ if (identified?.accountId) {
1548
+ pool.add(acc.alias, {
1549
+ accessToken: identified.accessToken, refreshToken: identified.refreshToken, expiresAt: identified.expiresAt,
1550
+ deviceId: identified.deviceId, accountUuid: identified.accountUuid, grantedAt: identified.grantedAt,
1551
+ organizationId: identified.organizationId, accountId: identified.accountId, accountEmail: identified.accountEmail,
1552
+ rateLimitTier: identified.rateLimitTier, seatTier: identified.seatTier,
1553
+ });
1554
+ announceSameAccounts();
1555
+ }
1556
+ }
1517
1557
  // Mirror a refreshed `login` token back to credentials.json so the
1518
1558
  // legacy file (and `dario doctor`) tracks the pool store (#808).
1519
1559
  await mirrorLoginToCredentials(refreshed).catch((err) => {
@@ -1558,7 +1598,12 @@ export async function startProxy(opts = {}) {
1558
1598
  accountUuid: acc.accountUuid,
1559
1599
  grantedAt: acc.grantedAt,
1560
1600
  organizationId: acc.organizationId,
1601
+ accountId: acc.accountId,
1602
+ accountEmail: acc.accountEmail,
1603
+ rateLimitTier: acc.rateLimitTier,
1604
+ seatTier: acc.seatTier,
1561
1605
  });
1606
+ announceSameAccounts();
1562
1607
  }
1563
1608
  }
1564
1609
  }
@@ -1945,9 +1990,10 @@ export async function startProxy(opts = {}) {
1945
1990
  return false;
1946
1991
  }
1947
1992
  const slugs = await getCodexModelSlugs(creds).catch(() => []);
1948
- const fallbackModel = pickCodexFallback(fallbackModels, slugs);
1949
- if (!fallbackModel)
1993
+ const fallbackPick = pickCodexFallback(fallbackModels, slugs);
1994
+ if (!fallbackPick)
1950
1995
  return false;
1996
+ const fallbackModel = fallbackPick.model;
1951
1997
  const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
1952
1998
  if (!fallbackBody)
1953
1999
  return false;
@@ -1969,7 +2015,12 @@ export async function startProxy(opts = {}) {
1969
2015
  // an outage, not quota — cooling it would park a provider that may be
1970
2016
  // back on the next request, which is the opposite of the fix.
1971
2017
  (d) => { if (d.status === 429)
1972
- providerCooldowns.note('codex', d.retryAfterMs); });
2018
+ providerCooldowns.note('codex', d.retryAfterMs); },
2019
+ // The mirror of the Claude side (dario#1161): an operator who writes
2020
+ // `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
2021
+ // failover runs at, so the entry's own suffix reaches the request rather
2022
+ // than the failover quietly running at the backend default.
2023
+ effortForCodex(fallbackPick.effort));
1973
2024
  if (served)
1974
2025
  providerCooldowns.clear('codex');
1975
2026
  return served;
@@ -2140,7 +2191,7 @@ export async function startProxy(opts = {}) {
2140
2191
  // just persisted metadata — the same snapshot GET /accounts exposes.
2141
2192
  poolStatus: () => {
2142
2193
  const snapNow = Date.now();
2143
- const peers = windowPeers(pool.all(), snapNow);
2194
+ const peers = accountPeers(pool.all());
2144
2195
  const snap = new Map();
2145
2196
  for (const a of pool.all()) {
2146
2197
  snap.set(a.alias, {
@@ -2159,6 +2210,11 @@ export async function startProxy(opts = {}) {
2159
2210
  lastRejectedAt: a.lastRejectedAt ?? null,
2160
2211
  organizationId: a.organizationId ?? null,
2161
2212
  sharesWindowWith: peers.get(a.alias) ?? [],
2213
+ sameAccountAs: peers.get(a.alias) ?? [],
2214
+ accountId: a.accountId ?? null,
2215
+ accountEmail: maskEmail(a.accountEmail),
2216
+ rateLimitTier: a.rateLimitTier ?? null,
2217
+ seatTier: a.seatTier ?? null,
2162
2218
  readingFrom: a.adoptedFrom ?? null,
2163
2219
  // Raw streak, not just the cooldown boolean: a single 401 also
2164
2220
  // shows `auth-cooldown` for 60s, indistinguishable from a
@@ -2231,7 +2287,7 @@ export async function startProxy(opts = {}) {
2231
2287
  // the `dario accounts` CLI, not HTTP.
2232
2288
  if (urlPath === '/accounts' && req.method === 'GET') {
2233
2289
  const now = Date.now();
2234
- const peers = windowPeers(pool.all(), now);
2290
+ const peers = accountPeers(pool.all());
2235
2291
  const accounts = pool.all().map(a => {
2236
2292
  const inCooldown = isInAuthCooldown(a, now);
2237
2293
  const cooldownMs = inCooldown && a.lastAuthFailureAt
@@ -2269,11 +2325,17 @@ export async function startProxy(opts = {}) {
2269
2325
  // parked seat no longer reads as one that was never called.
2270
2326
  rejectedCount: a.rejectedCount,
2271
2327
  lastRejectedAt: a.lastRejectedAt ?? null,
2272
- // Which organization the token belongs to, and which other seats
2273
- // report the same live window one subscription under several
2274
- // aliases (dario#1244).
2328
+ // Which organization the token belongs to, who the token IS (OAuth
2329
+ // account uuid, masked email), and which other seats are the same
2330
+ // account — one subscription under several aliases (dario#1244,
2331
+ // #1263: identity, not a shared reset second).
2275
2332
  organizationId: a.organizationId ?? null,
2276
2333
  sharesWindowWith: peers.get(a.alias) ?? [],
2334
+ sameAccountAs: peers.get(a.alias) ?? [],
2335
+ accountId: a.accountId ?? null,
2336
+ accountEmail: maskEmail(a.accountEmail),
2337
+ rateLimitTier: a.rateLimitTier ?? null,
2338
+ seatTier: a.seatTier ?? null,
2277
2339
  // Whose reading this is: a peer instance's id (shared pool state)
2278
2340
  // or null for this instance's own.
2279
2341
  readingFrom: a.adoptedFrom ?? null,
@@ -2299,9 +2361,11 @@ export async function startProxy(opts = {}) {
2299
2361
  mode: 'pool',
2300
2362
  ...pool.status(),
2301
2363
  stickyBindings: pool.stickyCount(),
2302
- // Windows the pool really has: each measured window once, each
2303
- // unmeasured seat as its own.
2304
- distinctWindows: distinctWindows(pool.all(), now),
2364
+ // Accounts the pool really has: each identified account once, each
2365
+ // not-yet-identified seat as its own. `distinctWindows` keeps its name
2366
+ // for readers of the older payload; an account is its windows.
2367
+ distinctWindows: distinctAccounts(pool.all()),
2368
+ distinctAccounts: distinctAccounts(pool.all()),
2305
2369
  // Shared pool state (pool-sync.ts) — null when off.
2306
2370
  sharedState: poolSync ? poolSync.status() : null,
2307
2371
  accounts,
@@ -3066,9 +3130,9 @@ export async function startProxy(opts = {}) {
3066
3130
  if (body.length > 0) {
3067
3131
  try {
3068
3132
  const peek = (parsedBody ?? {}); // parsed once by the invalid-body guard; `body` is re-serialized from it
3069
- const rawModel = (peek.model || '').toString();
3070
- const requestPoolFallbackModels = selectPoolFallbackModels(poolFallbackSpec, rawModel);
3071
- const requestPoolFallbackModel = requestPoolFallbackModels[0] ?? null;
3133
+ // Reassignable: the codex effort-suffix strip below rewrites it, and
3134
+ // every routing decision after that point must see the stripped name.
3135
+ let rawModel = (peek.model || '').toString();
3072
3136
  // Credentials are re-read per request (not cached at startup) because
3073
3137
  // a refresh rotates them on disk; getFreshCodexAccount refreshes when
3074
3138
  // inside the expiry buffer, collapsing concurrent refreshes per alias.
@@ -3105,6 +3169,43 @@ export async function startProxy(opts = {}) {
3105
3169
  }
3106
3170
  }
3107
3171
  }
3172
+ // dario#1260 — an effort suffix on a CODEX model (`gpt-5.6-terra:high`).
3173
+ // The two strip sites further up deliberately leave OpenAI-shaped names
3174
+ // alone: an openai-compat backend may serve a model whose real id ends
3175
+ // in `-high`/`-low`, and stripping there would rewrite a legitimate
3176
+ // name against a catalog this proxy cannot see. Codex is the one
3177
+ // provider that publishes its routable set, so here — and only here —
3178
+ // the ambiguity is decidable: strip when the name as written matches no
3179
+ // slug and the stripped name matches one.
3180
+ //
3181
+ // That guard is what makes this strictly additive. The only requests
3182
+ // whose routing changes are the ones that answer 400 `model_unroutable`
3183
+ // today, because a name that already routes is never re-read.
3184
+ //
3185
+ // Placed HERE rather than beside its siblings because `codexModels` is
3186
+ // THIS request's account's list, resolved just above; the suffix cannot
3187
+ // be told apart from a real id without it.
3188
+ if (rawModel && codexModels.length > 0 && !isCodexModel(rawModel, codexModels)) {
3189
+ const eff = parseEffortSuffix(rawModel);
3190
+ if (eff.effort && isCodexModel(eff.model, codexModels)) {
3191
+ if (verbose)
3192
+ console.log(`[dario] effort suffix: ${rawModel} → model ${eff.model} (codex, effort: ${eff.effort})`);
3193
+ requestEffort = eff.effort;
3194
+ rawModel = eff.model;
3195
+ // The suffix must not survive into the outbound body: the backend
3196
+ // 400s on a slug it does not list, which is the very failure this
3197
+ // fixes. `body` is re-serialized from `parsedBody` exactly as the
3198
+ // alias and provider-prefix blocks above do.
3199
+ peek.model = eff.model;
3200
+ body = Buffer.from(JSON.stringify(parsedBody));
3201
+ }
3202
+ }
3203
+ // Chosen AFTER the strip above, not before it: a per-model fallback
3204
+ // spec is keyed on the model being routed, and keying it on a name
3205
+ // still carrying a dario-side effort suffix would miss the operator's
3206
+ // own entry for that model and fall back to the unscoped chain.
3207
+ const requestPoolFallbackModels = selectPoolFallbackModels(poolFallbackSpec, rawModel);
3208
+ const requestPoolFallbackModel = requestPoolFallbackModels[0] ?? null;
3108
3209
  const decision = routeProvider({
3109
3210
  isOpenAIPath: isOpenAI,
3110
3211
  model: rawModel,
@@ -3205,7 +3306,11 @@ export async function startProxy(opts = {}) {
3205
3306
  // is an outage, and parking a provider for that would keep it out
3206
3307
  // of the chain while it was already coming back.
3207
3308
  (d) => { if (d.status === 429)
3208
- providerCooldowns.note('codex', d.retryAfterMs); });
3309
+ providerCooldowns.note('codex', d.retryAfterMs); },
3310
+ // dario#1260 — the effort named by the model-name suffix stripped
3311
+ // above. Undefined for every request that did not name one, which
3312
+ // leaves the outbound body exactly as it was.
3313
+ effortForCodex(requestEffort));
3209
3314
  if (served) {
3210
3315
  // A provider that just served is not rate-limited.
3211
3316
  providerCooldowns.clear('codex');
@@ -3872,7 +3977,9 @@ export async function startProxy(opts = {}) {
3872
3977
  // returns status='rejected' on 429, which makes the next `select()` call
3873
3978
  // route traffic away from this account until it resets.
3874
3979
  if (poolAccount) {
3875
- const snapshot = parseRateLimits(upstream.headers);
3980
+ // The family is what lets a bucket the wire does not name by family
3981
+ // (`7d_oi`) be learned as binding THIS request's model (dario#1262).
3982
+ const snapshot = parseRateLimits(upstream.headers, modelFamily(requestModel));
3876
3983
  if (upstream.status === 429) {
3877
3984
  // Say so the moment a seat leaves rotation. With a peer to fail
3878
3985
  // over to the client sees 200, and nothing else named the seat,
@@ -3881,7 +3988,7 @@ export async function startProxy(opts = {}) {
3881
3988
  // those repeats are verbose-only.
3882
3989
  const parked = pool.markRejected(poolAccount.alias, snapshot);
3883
3990
  if (parked || verbose) {
3884
- console.error(`[dario] #${requestCount} rate limited (429) on account "${poolAccount.alias}": ${describeRateLimitSnapshot(snapshot)} — parked until the window rolls`);
3991
+ console.error(`[dario] #${requestCount} rate limited (429) on account "${poolAccount.alias}": ${describeRejection(pool.get(poolAccount.alias)?.rateLimit ?? snapshot)}`);
3885
3992
  }
3886
3993
  }
3887
3994
  else {
@@ -3893,7 +4000,6 @@ export async function startProxy(opts = {}) {
3893
4000
  const organizationId = upstream.headers.get('anthropic-organization-id');
3894
4001
  if (organizationId)
3895
4002
  pool.noteOrganization(poolAccount.alias, organizationId);
3896
- announceWindowPeers(poolAccount.alias);
3897
4003
  poolSync?.reportSeat(poolAccount.alias);
3898
4004
  // First-sight detector for per-model rate-limit buckets. Anthropic
3899
4005
  // ships these unannounced — e.g. `7d_sonnet-utilization` appeared
package/docs/admin-api.md CHANGED
@@ -104,9 +104,7 @@ rejection lifts), representative `claim` (e.g. `five_hour`), routing
104
104
  `status`, `request_count` (requests served), `rejected_count` /
105
105
  `last_rejected_at` (429s answered — a 429 serves nothing, so it is not a
106
106
  request), `organization_id` (the organization the token belongs to, learned
107
- from its responses and written to the record with its next refresh), `shares_window_with` (aliases whose last
108
- reading names the same live window — one subscription under several aliases,
109
- see [One subscription under two aliases](./multi-account-pool.md#one-subscription-under-two-aliases)),
107
+ from its responses and written to the record with its next refresh), `account_id` / `account_email` (who the token is, from its OAuth profile at grant time; the email masked), `same_account_as` (aliases that are the same account — one subscription under several aliases; `shares_window_with` is the same list under its older name, see [One subscription under two aliases](./multi-account-pool.md#one-subscription-under-two-aliases)), `rate_limit_tier` / `seat_tier` when the profile stated them,
110
108
  and `consecutive_auth_failures`. What each `status` means and what
111
109
  to do about it: [Reading a seat's `status`](./multi-account-pool.md#reading-a-seats-status).
112
110
  It's the admin-token-gated equivalent of the proxy-key-gated `GET /accounts`
package/docs/commands.md CHANGED
@@ -16,6 +16,7 @@ This page is the per-flag reference. For environment variables grouped by task
16
16
  | `dario refresh` | Force an immediate Claude token refresh |
17
17
  | `dario logout` | Delete stored Claude credentials |
18
18
  | `dario accounts check <alias> [--models=a,b]` | Read-only, in-place seat probe: one tiny request per model, pinned to that seat through the running proxy (`x-dario-account` + `x-dario-admin-token`, needs `DARIO_ADMIN=1`). A pinned request never fails over, so the upstream status is the seat's own answer. |
19
+ | `dario accounts identity [--fresh <alias>...\|--all]` | Which client identity each seat presents in `metadata.user_id`, where it came from, and which seats share one across different accounts; `--fresh` gives the named seats their own (the running proxy presents it on the next request). See [Client identity](./multi-account-pool.md#client-identity-what-a-seat-presents-as). |
19
20
  | `dario accounts list` / `add <alias>` / `remove <alias>` | Multi-account pool management. `add <alias>` on a fresh pool auto back-fills your existing `dario login` credentials as `login`, so your first `add` trips the 2+ pool threshold on its own — see [Multi-account pool mode](./multi-account-pool.md). |
20
21
  | `dario backend list` / `add <name> --key=<key> [--base-url=<url>]` / `remove <name>` | OpenAI-compat backend management |
21
22
  | `dario subagent install` / `remove` / `status` | CC sub-agent lifecycle. See [sub-agent hook](./sub-agent.md). |
@@ -74,6 +74,39 @@ Off by default, each one a deliberate divergence from what real CC sends.
74
74
  | `DARIO_EFFORT` | `--effort=` | Forces a reasoning-effort level. Can flip requests to overage billing — watch `-v` logs for representative-claim changes ([`#87`](https://github.com/askalf/dario/issues/87)). |
75
75
  | `DARIO_MAX_TOKENS` | `--max-tokens=` | Anthropic enforces the per-model ceiling server-side, so too-high values return a clean 400 ([`#88`](https://github.com/askalf/dario/issues/88)). |
76
76
 
77
+ ### Per-request effort, by model name
78
+
79
+ `DARIO_EFFORT` is process-wide: it applies to every caller, Claude and Codex
80
+ alike. A client that has no way to set `output_config.effort` can instead name
81
+ the level in the model itself, and only that request changes:
82
+
83
+ ```
84
+ claude-opus-4-8:high colon form
85
+ claude-opus-4-8-high hyphen form, for Cursor, which rewrites colons
86
+ gpt-5.6-terra:high a Codex model, same two spellings
87
+ ```
88
+
89
+ Levels a suffix may name: `low`, `medium`, `high`, `xhigh`, `max`, and dario's
90
+ own `ultracode`, which reaches a Codex backend as `max`. On the Codex path the
91
+ level is sent as `reasoning.effort`; on the Claude path as
92
+ `output_config.effort`.
93
+
94
+ `client` is a valid `DARIO_EFFORT` value but deliberately **not** a suffix. It
95
+ means "leave the client's own choice alone", so naming it in a model would be a
96
+ no-op, and accepting it would let a model genuinely named `...-client` be
97
+ stripped to a name the backend does not list.
98
+
99
+ The suffix is only read when the name as written matches no model the provider
100
+ lists, so a real model id that happens to end in an effort word is routed
101
+ exactly as written and never re-read. A `--pool-fallback` chain entry may carry
102
+ one too, which sets the effort the failover request runs at.
103
+
104
+ Effort is not free. Measured through dario on one prompt with only the level
105
+ changing: `low` returned 1,982 output tokens in 18s, `high` 3,947 in 35s, and
106
+ `max` 11,160 in 100s. Reasoning is billed as output, so `max` is a real cost
107
+ increase on every request that names it, and on a long agent loop it can push a
108
+ run into its own timeout.
109
+
77
110
  ## Pacing
78
111
 
79
112
  Only meaningful with stealth, and all default to 0 (off) except the cap. See [`wire-fidelity.md`](./wire-fidelity.md).
@@ -98,10 +98,12 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
98
98
  | `status` | What it means | What to do |
99
99
  |---|---|---|
100
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. |
101
+ | `rejected` | The seat's last response was a 429 **that named an exhausted window**: `claim` says which (`five_hour`, `seven_day`, …) and the reading is at or past the 1.0 threshold — `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
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
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
104
 
105
+ **A 429 that names no exhausted window is not a parking.** A rejection whose headers show no claim, or a claim with utilization nowhere near 1.0 (`5h 0%, 7d 0%, claim unknown`), is a refusal of some other kind — concurrency, an account-level lock, a monthly credit — and the `reset` it states is not this seat's window rolling. Until 6.0.39 the status code alone decided, and a seat on the fleet box was parked for 546 hours on exactly that reading. Such a seat now cools for the response's own `retry-after` (or one minute) and stays probeable; the log says so: `429 without an exhausted window (5h 0%, 7d 0%, claim unknown; stated reset in 546h 49m not honoured) — cooling 1m, seat stays probeable`.
106
+
105
107
  **When every seat is parked.** A pool whose seats are all `rejected` inside live windows does not probe them again: dario answers the request itself with `429`, `retry-after` set to the earliest reset, `x-dario-upstream-rejection: pool_parked`, and nothing sent upstream. One log line marks the transition (`pool parked: all 6 seats are over their rate-limit windows, earliest resets in 21m`). Before 6.0.35 every such request re-probed the earliest-reset seat, so `rejectedCount` on that seat grew by one per request — a seat reading `rejected_count: 500` next to `request_count: 1` was that, not a seat that needed a re-login. With a `--pool-fallback` armed, the request goes to the fallback instead, as before.
106
108
 
107
109
  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`.
@@ -110,16 +112,33 @@ The proxy logs every parking as it happens, once per window: `rate limited (429)
110
112
 
111
113
  ## One subscription under two aliases
112
114
 
113
- A pool of six is only six windows if the six tokens belong to six subscriptions. Two aliases granted from the same account or from two accounts on one organization that share a plan share one five-hour and one seven-day window, and the pool counts that window twice: both seats look like headroom, the busier one fills the window for both, and the other 429s on its first request with the same reading (the #1244 report).
115
+ A pool of six is only six accounts if the six tokens belong to six accounts. Two aliases granted from the same account share one set of windows and one set of limits the pool routes on real headroom either way, and the duplicate simply parks on the first 429 until the window rolls, but the operator should know.
116
+
117
+ dario knows it from the token itself. At grant time (`dario accounts add`, the admin login, the keychain import, the `login` back-fill) it reads the token's OAuth profile and records the **account uuid** — plus the masked email and the organization's tier fields — on the seat's record. A record written before this exists is filled in on its next token refresh.
118
+
119
+ - **`accountId`** / **`accountEmail`** — who the token is. Two seats with the same `accountId` are the same account, full stop.
120
+ - **`sameAccountAs`** (and `sharesWindowWith`, the same list under its original name) — the other aliases that are this account.
121
+ - **`organizationId`** — the `anthropic-organization-id` the seat's responses carry. Several accounts can share an organization (a Team) and still have their own windows: *organization* is not *account*.
122
+
123
+ Until 6.0.38, `sharesWindowWith` was **inferred**: two seats whose last readings named the same window (`claim@reset`) were called one subscription, on the assumption that independent windows never share a reset second. They do — Anthropic aligns the five-hour reset to a 20-minute grid, so a window has 15 possible reset seconds and a pool of 18 seats collides by pigeonhole. That inference told an operator seven independent colleagues were one subscription (dario#1263). It is gone; nothing is claimed that the token did not say.
124
+
125
+ Both facts are on `GET /accounts` (`distinctAccounts` — and `distinctWindows`, kept for readers of the older payload — count the accounts the pool really has), on `GET /admin/accounts` as `account_id` / `account_email` / `same_account_as`, in `dario accounts list --live`, and on the `Accounts` row of `dario doctor`. The proxy also says it once per pair at start-up: `seats "busy" and "twin" are the same account (ma***@example.com)`.
114
126
 
115
- Two facts make this visible:
127
+ ## Client identity: what a seat presents as
116
128
 
117
- - **`organizationId`** the `anthropic-organization-id` every response carries, learned the first time a seat serves and written to its record with the seat's next token refresh. It is what to compare with the organization behind the usage page you are looking at: a reading that surprises you is usually a token on a different organization.
118
- - **`sharesWindowWith`** — the other aliases whose last reading names the same live window (same representative claim, same reset second). Two independent windows all but never share a reset second; two readings of one window always do. This is the fact that matters for headroom, and it is deliberately not derived from the organization: seats on one organization can still have their own windows.
129
+ Every request carries `metadata.user_id` a client identity (`device_id`, `account_uuid`) that Claude Code derives from its install, and that Anthropic ties the bearer token to. dario stores one per seat.
119
130
 
120
- Both are on `GET /accounts` (`distinctWindows` at the top counts the windows the pool really has), on `GET /admin/accounts` as `organization_id` / `shares_window_with`, in `dario accounts list --live`, and in `dario doctor` (the `Organizations` row, from the ids on the records — so up to one refresh behind the running proxy). The proxy also says it once, when the second reading arrives: `seats "twin" and "busy" report the same five_hour window (resets 2026-09-07T13:12:00.000Z) one subscription under two aliases; the pool has 2 distinct windows across 3 seats`.
131
+ Before 6.0.39 every add path copied the **machine's** Claude Code identity into every alias when Claude Code was installed. On a machine running a pool of colleagues' tokens that meant eighteen different accounts all presenting one identity (dario#1244). Whether that alone changes what Anthropic counts is not established it is hygiene, not a diagnosisbut a seat should present the account it belongs to.
132
+
133
+ Now a new alias takes the local Claude Code identity only when no other alias holds it, or when the holder is proven (same `accountId`) to be the same account; otherwise the alias gets its own, exactly as a machine without Claude Code always did. Existing seats are not rewritten behind your back. To see what each seat presents, and which seats share one identity across different accounts:
134
+
135
+ ```
136
+ dario accounts identity # per-seat report
137
+ dario accounts identity --fresh <alias>... # give these seats their own
138
+ dario accounts identity --fresh --all
139
+ ```
121
140
 
122
- What to do about it: nothing is broken — the pool routes on real headroom either way, and the duplicate seat simply parks on the first 429 until the window rolls. If the second alias was meant to be a second subscription, re-grant it while signed in to the right account.
141
+ The running proxy presents a rewritten identity on the seat's next request; no restart. `dario doctor` warns on the `Client identity` row when seats share one across different accounts.
123
142
 
124
143
  ## Consumers: who a request is for
125
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.37",
3
+ "version": "6.0.39",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {