@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/accounts.d.ts +87 -0
- package/dist/accounts.js +162 -19
- package/dist/admin-api.d.ts +14 -1
- package/dist/admin-api.js +10 -0
- package/dist/anthropic-responses-translate.d.ts +20 -1
- package/dist/anthropic-responses-translate.js +15 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +80 -3
- package/dist/codex-backend.d.ts +16 -3
- package/dist/codex-backend.js +30 -15
- package/dist/doctor-core.d.ts +34 -0
- package/dist/doctor-core.js +91 -3
- package/dist/effort.d.ts +14 -0
- package/dist/effort.js +26 -0
- package/dist/pool.d.ts +146 -17
- package/dist/pool.js +264 -54
- package/dist/proxy.js +142 -36
- package/docs/admin-api.md +1 -3
- package/docs/commands.md +1 -0
- package/docs/configuration.md +33 -0
- package/docs/multi-account-pool.md +26 -7
- package/package.json +1 -1
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,
|
|
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
|
|
1333
|
-
// (dario#1244). Said once per pair,
|
|
1334
|
-
// listings carry it
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
1949
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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,
|
|
2273
|
-
//
|
|
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
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
|
|
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
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
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
|
-
|
|
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}": ${
|
|
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
|
|
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). |
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
127
|
+
## Client identity: what a seat presents as
|
|
116
128
|
|
|
117
|
-
|
|
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
|
-
|
|
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 diagnosis — but 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
|
-
|
|
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.
|
|
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": {
|