@askalf/dario 4.8.153 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/dist/cc-template-data.json +22 -18
- package/dist/cc-template.d.ts +0 -2
- package/dist/cc-template.js +0 -2
- package/dist/cli.js +50 -124
- package/dist/config-report.js +16 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +17 -7
- package/dist/live-fingerprint.d.ts +8 -8
- package/dist/live-fingerprint.js +6 -6
- package/dist/proxy.d.ts +2 -11
- package/dist/proxy.js +247 -256
- package/dist/runtime-fingerprint.d.ts +5 -11
- package/dist/runtime-fingerprint.js +7 -13
- package/dist/session-rotation.d.ts +22 -10
- package/dist/session-rotation.js +30 -13
- package/package.json +3 -3
- package/dist/shim/host.d.ts +0 -86
- package/dist/shim/host.js +0 -196
- package/dist/shim/runtime.cjs +0 -327
package/dist/proxy.js
CHANGED
|
@@ -140,23 +140,27 @@ function extractFirstUserMessage(body) {
|
|
|
140
140
|
}
|
|
141
141
|
return '';
|
|
142
142
|
}
|
|
143
|
-
// Session ID behavior
|
|
143
|
+
// Session ID behavior:
|
|
144
144
|
// v3.18 rotated per request — which was itself a fingerprint. Real CC
|
|
145
145
|
// rotates roughly once per conversation, not per call. A user who has
|
|
146
146
|
// distinct session-ids for every request looks nothing like a CC user.
|
|
147
147
|
//
|
|
148
148
|
// v3.19 keeps the id stable through a conversation window and rotates
|
|
149
149
|
// only after an idle gap long enough to credibly indicate a new
|
|
150
|
-
// conversation.
|
|
151
|
-
// (stable across the account's lifetime).
|
|
150
|
+
// conversation.
|
|
152
151
|
//
|
|
153
152
|
// v3.28 generalises the single hardcoded 15-min window into a tunable
|
|
154
153
|
// registry (see src/session-rotation.ts) with optional jitter, max-age,
|
|
155
|
-
// and per-client keying.
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
154
|
+
// and per-client keying.
|
|
155
|
+
//
|
|
156
|
+
// v5.0 (pool-as-primitive) makes this registry the ONE session-id
|
|
157
|
+
// mechanism: it's keyed by the selected pool account so each account keeps
|
|
158
|
+
// its own idle/jitter/max-age lifecycle, seeded with the account's minted
|
|
159
|
+
// identity.sessionId so an account's first request is byte-identical to the
|
|
160
|
+
// pre-v5 stable id. SESSION_ID below is kept only as a mirror of the most
|
|
161
|
+
// recently assigned id so out-of-band consumers (presence ping, diagnostic
|
|
162
|
+
// logs) can read it without going through the registry. It's refreshed
|
|
163
|
+
// after every dispatch-path call that assigns a new id.
|
|
160
164
|
let SESSION_ID = randomUUID();
|
|
161
165
|
const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
|
|
162
166
|
// Claude Code device identity — required for Max plan billing classification.
|
|
@@ -774,19 +778,8 @@ export function upstreamAuthHeaders(upstreamApiKey, accessToken) {
|
|
|
774
778
|
: { 'Authorization': `Bearer ${accessToken}` };
|
|
775
779
|
}
|
|
776
780
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* `dario accounts add` bootstraps a servable proxy with no `dario login`
|
|
780
|
-
* step — and admin mode always does (#599), where the pool may start empty
|
|
781
|
-
* and be populated over HTTP. Login-only setups (no accounts/ entries) keep
|
|
782
|
-
* the single-account credentials.json path. Pure + exported for unit
|
|
783
|
-
* testing.
|
|
784
|
-
*/
|
|
785
|
-
export function shouldUsePool(accountCount, adminEnabled) {
|
|
786
|
-
return accountCount >= 1 || adminEnabled;
|
|
787
|
-
}
|
|
788
|
-
/**
|
|
789
|
-
* Resolve the single-account startup auth outcome, refreshing an expired-but-
|
|
781
|
+
* Resolve the startup auth outcome when the pool is empty and there's a
|
|
782
|
+
* `dario login` credentials.json to fall back on — refreshing an expired-but-
|
|
790
783
|
* refreshable access token before giving up.
|
|
791
784
|
*
|
|
792
785
|
* The classic single-account startup used to read getStatus() once and, on any
|
|
@@ -877,7 +870,7 @@ export async function startProxy(opts = {}) {
|
|
|
877
870
|
// Bun/BoringSSL shape. `--strict-tls` turns this silent divergence into
|
|
878
871
|
// a startup refusal. Doctor + the always-on banner below surface the
|
|
879
872
|
// same information without aborting, for users who know they're fine
|
|
880
|
-
// (API-key billing, single-call invocations,
|
|
873
|
+
// (API-key billing, single-call invocations, etc.).
|
|
881
874
|
const { detectRuntimeFingerprint } = await import('./runtime-fingerprint.js');
|
|
882
875
|
const runtimeFp = detectRuntimeFingerprint();
|
|
883
876
|
if (opts.strictTls && runtimeFp.status !== 'bun-match') {
|
|
@@ -995,47 +988,44 @@ export async function startProxy(opts = {}) {
|
|
|
995
988
|
if (openaiBackend) {
|
|
996
989
|
console.log(` OpenAI-compat backend: ${openaiBackend.name} → ${openaiBackend.baseUrl}`);
|
|
997
990
|
}
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
991
|
+
// Pool-as-primitive (v5.0). The account pool is the one credential model:
|
|
992
|
+
// a plain `dario login` is a pool of one under the reserved `login` alias,
|
|
993
|
+
// and a pool of many is the same path with more members. There is no
|
|
994
|
+
// separate single-account code path.
|
|
1001
995
|
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
996
|
+
// Back-fill the login credentials into accounts/login.json unconditionally
|
|
997
|
+
// (pre-v5 this ran only on the first `dario accounts add` or under admin
|
|
998
|
+
// mode) so the request path is always the pool. No-op when accounts/ already
|
|
999
|
+
// has entries or no login credentials are reachable — see
|
|
1000
|
+
// ensureLoginCredentialsInPool.
|
|
1001
|
+
try {
|
|
1002
|
+
await ensureLoginCredentialsInPool();
|
|
1003
|
+
}
|
|
1004
|
+
catch { /* non-fatal — pool may start empty (admin bootstrap / api-key mode) */ }
|
|
1005
|
+
// Re-sync the back-filled `login` snapshot if credentials.json refreshed out
|
|
1006
|
+
// of band — e.g. a `dario refresh`/`dario status` in another process rotated
|
|
1007
|
+
// the shared token lineage (dario#235). Runs after the back-fill so a freshly
|
|
1008
|
+
// created login.json is seen as in-sync rather than triggering a redundant
|
|
1009
|
+
// rewrite.
|
|
1007
1010
|
const resyncResult = await resyncLoginFromCredentialsIfStale();
|
|
1008
1011
|
if (resyncResult === 'resynced') {
|
|
1009
1012
|
console.log('[dario] re-synced pool `login` account from current credentials.json (was stale; dario#235)');
|
|
1010
1013
|
}
|
|
1011
|
-
// Admin mode (#599) manages the
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
// generic upstream error), and accounts added via POST /admin/login/* take
|
|
1015
|
-
// effect immediately — no proxy restart (see onAccountsChanged below).
|
|
1016
|
-
//
|
|
1017
|
-
// Back-fill first: an operator who enables admin mode on top of an existing
|
|
1018
|
-
// single-account `dario login` setup keeps that account (ensureLogin… is a
|
|
1019
|
-
// no-op when accounts/ is already populated or no credentials are reachable).
|
|
1014
|
+
// Admin mode (#599) manages the pool over HTTP: it may legitimately start
|
|
1015
|
+
// with zero accounts and returns a clean 503 until one is added via
|
|
1016
|
+
// POST /admin/login/*, taking effect with no restart (see onAccountsChanged).
|
|
1020
1017
|
const adminEnabled = process.env.DARIO_ADMIN === '1';
|
|
1021
|
-
if (adminEnabled) {
|
|
1022
|
-
try {
|
|
1023
|
-
await ensureLoginCredentialsInPool();
|
|
1024
|
-
}
|
|
1025
|
-
catch { /* non-fatal — pool just starts empty */ }
|
|
1026
|
-
}
|
|
1027
1018
|
const accountsList = await loadAllAccounts();
|
|
1028
|
-
const pool =
|
|
1019
|
+
const pool = new AccountPool();
|
|
1029
1020
|
// Per-model rate-limit bucket families seen during this proxy run. First-
|
|
1030
1021
|
// sight is logged once when verbose so a new Anthropic bucket (e.g. an
|
|
1031
1022
|
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
1032
1023
|
// routing already handles unknown families generically.
|
|
1033
1024
|
const seenPerModelBuckets = new Set();
|
|
1034
1025
|
// v4 promotion: analytics is always-on so the TUI's Analytics + Hits
|
|
1035
|
-
// tabs work
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
// single-account users too.
|
|
1026
|
+
// tabs work for every install. Pre-v4 this was `pool ? new Analytics()
|
|
1027
|
+
// : null` — that gated the /analytics endpoint, but burn-rate /
|
|
1028
|
+
// per-request visibility is useful for a pool of one too.
|
|
1039
1029
|
const analytics = new Analytics();
|
|
1040
1030
|
// Overage-guard (v4.1, dario#288). Resolved from opts with built-in
|
|
1041
1031
|
// defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
|
|
@@ -1070,67 +1060,87 @@ export async function startProxy(opts = {}) {
|
|
|
1070
1060
|
console.error(`[dario] overage-guard resumed (${info.reason}). Normal request handling restored.`);
|
|
1071
1061
|
});
|
|
1072
1062
|
let status;
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
console.error(`[dario] Background refresh failed for ${acc.alias}: ${err instanceof Error ? err.message : err}`);
|
|
1096
|
-
}
|
|
1063
|
+
for (const acc of accountsList) {
|
|
1064
|
+
pool.add(acc.alias, {
|
|
1065
|
+
accessToken: acc.accessToken,
|
|
1066
|
+
refreshToken: acc.refreshToken,
|
|
1067
|
+
expiresAt: acc.expiresAt,
|
|
1068
|
+
deviceId: acc.deviceId,
|
|
1069
|
+
accountUuid: acc.accountUuid,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
// Background refresh — keep every account's token fresh without blocking requests
|
|
1073
|
+
const refreshInterval = setInterval(async () => {
|
|
1074
|
+
for (const acc of pool.all()) {
|
|
1075
|
+
if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
|
|
1076
|
+
try {
|
|
1077
|
+
const saved = await loadAccount(acc.alias);
|
|
1078
|
+
if (!saved)
|
|
1079
|
+
continue;
|
|
1080
|
+
const refreshed = await refreshAccountToken(saved);
|
|
1081
|
+
pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
console.error(`[dario] Background refresh failed for ${acc.alias}: ${err instanceof Error ? err.message : err}`);
|
|
1097
1085
|
}
|
|
1098
1086
|
}
|
|
1099
|
-
}, 15 * 60 * 1000);
|
|
1100
|
-
refreshInterval.unref();
|
|
1101
|
-
// Pool mode doesn't check single-account status — compute a placeholder
|
|
1102
|
-
// for the startup banner using the pool's earliest expiry. An admin-mode
|
|
1103
|
-
// pool can legitimately start empty (#599): report that plainly instead of
|
|
1104
|
-
// Math.min([]) === Infinity, and tell the operator how to bootstrap.
|
|
1105
|
-
if (pool.size === 0) {
|
|
1106
|
-
console.warn('[dario] Starting with no accounts (admin mode). Add one via POST /admin/login/start; LLM requests return 503 until then.');
|
|
1107
|
-
status = { authenticated: false, status: 'none', expiresAt: 0, expiresIn: 'no accounts yet' };
|
|
1108
1087
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
// An expired-but-refreshable access token here is self-healing: attempt a
|
|
1126
|
-
// refresh before giving up so a container (re)started shortly after a normal
|
|
1127
|
-
// token expiry recovers instead of crash-looping on exit(1) (gap #1). Only a
|
|
1128
|
-
// dead refresh token or no credentials at all still exits.
|
|
1129
|
-
status = await resolveSingleAccountStartupStatus();
|
|
1130
|
-
if (!status.authenticated) {
|
|
1088
|
+
}, 15 * 60 * 1000);
|
|
1089
|
+
refreshInterval.unref();
|
|
1090
|
+
// Auth gate. The pool is the one credential model, so "authenticated" means
|
|
1091
|
+
// the pool has at least one account. An empty pool is legitimate only for:
|
|
1092
|
+
// - admin bootstrap (#599): starts empty, returns a clean 503 until an
|
|
1093
|
+
// account is added over the admin API;
|
|
1094
|
+
// - upstream-api-key mode: OAuth + pool are bypassed, requests carry
|
|
1095
|
+
// x-api-key, so an empty pool is expected.
|
|
1096
|
+
// Otherwise an empty pool means the login back-fill found no credentials —
|
|
1097
|
+
// the user hasn't logged in. Preserve the single-account self-heal there: a
|
|
1098
|
+
// dead-but-refreshable token (a container restarted right after a normal
|
|
1099
|
+
// expiry, gap #1) is refreshed, then back-filled so the recovered login
|
|
1100
|
+
// becomes the pool-of-one it should be — rather than crash-looping on exit(1).
|
|
1101
|
+
if (pool.size === 0 && !adminEnabled && !upstreamApiKey) {
|
|
1102
|
+
const single = await resolveSingleAccountStartupStatus();
|
|
1103
|
+
if (!single.authenticated) {
|
|
1131
1104
|
console.error('[dario] Not authenticated. Run `dario login` first.');
|
|
1132
1105
|
process.exit(1);
|
|
1133
1106
|
}
|
|
1107
|
+
const recovered = await ensureLoginCredentialsInPool();
|
|
1108
|
+
if (recovered) {
|
|
1109
|
+
const acc = await loadAccount(recovered);
|
|
1110
|
+
if (acc) {
|
|
1111
|
+
pool.add(acc.alias, {
|
|
1112
|
+
accessToken: acc.accessToken,
|
|
1113
|
+
refreshToken: acc.refreshToken,
|
|
1114
|
+
expiresAt: acc.expiresAt,
|
|
1115
|
+
deviceId: acc.deviceId,
|
|
1116
|
+
accountUuid: acc.accountUuid,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (pool.size === 0) {
|
|
1122
|
+
// Reached only under admin bootstrap or api-key mode (the not-authenticated
|
|
1123
|
+
// case exited above). Report the empty pool plainly instead of
|
|
1124
|
+
// Math.min([]) === Infinity.
|
|
1125
|
+
if (adminEnabled) {
|
|
1126
|
+
console.warn('[dario] Starting with no accounts (admin mode). Add one via POST /admin/login/start; LLM requests return 503 until then.');
|
|
1127
|
+
}
|
|
1128
|
+
status = {
|
|
1129
|
+
authenticated: false,
|
|
1130
|
+
status: 'none',
|
|
1131
|
+
expiresAt: 0,
|
|
1132
|
+
expiresIn: upstreamApiKey ? 'api-key mode' : 'no accounts yet',
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
const earliest = Math.min(...pool.all().map(a => a.expiresAt));
|
|
1137
|
+
const msLeft = Math.max(0, earliest - Date.now());
|
|
1138
|
+
status = {
|
|
1139
|
+
authenticated: true,
|
|
1140
|
+
status: 'healthy',
|
|
1141
|
+
expiresAt: earliest,
|
|
1142
|
+
expiresIn: `${Math.floor(msLeft / 3600000)}h ${Math.floor((msLeft % 3600000) / 60000)}m`,
|
|
1143
|
+
};
|
|
1134
1144
|
}
|
|
1135
1145
|
const cliVersion = detectCliVersion();
|
|
1136
1146
|
// Parse --model once at startup. Supports `<provider>:<model>` to force
|
|
@@ -1194,12 +1204,12 @@ export async function startProxy(opts = {}) {
|
|
|
1194
1204
|
maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
|
|
1195
1205
|
queueTimeoutMs: opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
|
|
1196
1206
|
});
|
|
1197
|
-
// Cache context-1m beta availability. Set false once per account
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
// retry loop was firing on every POST with hybrid-tools + OC.
|
|
1207
|
+
// Cache context-1m beta availability. Set false once per account after the
|
|
1208
|
+
// first "long context" rejection, so we skip sending context-1m on every
|
|
1209
|
+
// subsequent request instead of paying the round-trip + retry cost each time.
|
|
1210
|
+
// Keyed by pool-account alias; `ACCOUNT_KEY_APIKEY` is the slot used in
|
|
1211
|
+
// upstream-api-key mode (no pool account). Reported by @boeingchoco in
|
|
1212
|
+
// dario#36 — the retry loop was firing on every POST with hybrid-tools + OC.
|
|
1203
1213
|
const context1mUnavailable = new Set();
|
|
1204
1214
|
// Per-account cache of anthropic-beta flags the upstream has rejected as
|
|
1205
1215
|
// "Unexpected value(s)". The live-captured template lifts whatever CC emits
|
|
@@ -1207,9 +1217,13 @@ export async function startProxy(opts = {}) {
|
|
|
1207
1217
|
// `afk-mode-2026-01-31` is rejected on Max 5x as of 2026-04-17). On the
|
|
1208
1218
|
// first rejection we parse the flag out of the error message, strip it,
|
|
1209
1219
|
// retry once, and cache it so subsequent requests on the same account don't
|
|
1210
|
-
// re-pay the 400 round-trip. Keyed by account alias
|
|
1220
|
+
// re-pay the 400 round-trip. Keyed by pool-account alias.
|
|
1211
1221
|
const unavailableBetas = new Map();
|
|
1212
|
-
|
|
1222
|
+
// Synthetic account key for upstream-api-key mode — the one request path with
|
|
1223
|
+
// no pool account (v5.0: every OAuth request has a pool account, so this is
|
|
1224
|
+
// the sole `poolAccount?.alias ?? …` fallback that survives). Keys the
|
|
1225
|
+
// per-account caches and the analytics `account` field for api-key traffic.
|
|
1226
|
+
const ACCOUNT_KEY_APIKEY = '__apikey__';
|
|
1213
1227
|
// Per-model effort capability cache — same pay-the-round-trip-once pattern
|
|
1214
1228
|
// as context1mUnavailable, but keyed by WIRE MODEL id: effort support is a
|
|
1215
1229
|
// model property, not an account property. Populated from upstream's
|
|
@@ -1223,10 +1237,9 @@ export async function startProxy(opts = {}) {
|
|
|
1223
1237
|
// re-pay the rejection.
|
|
1224
1238
|
const maxTokensCapByModel = new Map();
|
|
1225
1239
|
// Beta flag set — sourced from the live template when the capture recorded
|
|
1226
|
-
// one (schema v2+), else falls back to the v2.1.104 bundled default.
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
// function of the loaded template, not of the request.
|
|
1240
|
+
// one (schema v2+), else falls back to the v2.1.104 bundled default.
|
|
1241
|
+
// Computed once per proxy because it's a function of the loaded template,
|
|
1242
|
+
// not of the request.
|
|
1230
1243
|
const BETA_FALLBACK = 'claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,effort-2025-11-24';
|
|
1231
1244
|
let betaBase = CC_TEMPLATE.anthropic_beta || BETA_FALLBACK;
|
|
1232
1245
|
// `oauth-2025-04-20` is CC's OAuth-enablement beta flag. It is NOT present in
|
|
@@ -1314,6 +1327,18 @@ export async function startProxy(opts = {}) {
|
|
|
1314
1327
|
const maxAge = sessionCfg.maxAgeMs !== undefined ? `${sessionCfg.maxAgeMs}ms` : 'off';
|
|
1315
1328
|
console.log(`[dario] session: idle=${sessionCfg.idleRotateMs}ms jitter=${sessionCfg.jitterMs}ms maxAge=${maxAge} perClient=${sessionCfg.perClient}`);
|
|
1316
1329
|
}
|
|
1330
|
+
// The one session-id mechanism (v5.0). Pre-v5 the proxy forked: single-account
|
|
1331
|
+
// rotated its outbound session id via this registry, while pool accounts each
|
|
1332
|
+
// carried a stable identity.sessionId for the proxy's lifetime. Now that a
|
|
1333
|
+
// plain `dario login` is a pool of one, both collapse here: the session id is
|
|
1334
|
+
// resolved through the registry keyed by the selected account's alias (so each
|
|
1335
|
+
// account keeps its own idle/jitter/max-age lifecycle), seeded with that
|
|
1336
|
+
// account's minted identity.sessionId. The seed makes an account's FIRST
|
|
1337
|
+
// request byte-identical to pre-v5 (the minted id), and only idle/age rotation
|
|
1338
|
+
// mints a fresh one — so a pool of one rotates exactly as the old single path
|
|
1339
|
+
// did, and busy multi-account pools keep their per-account ids until they idle.
|
|
1340
|
+
// `account === null` is upstream-api-key mode; it keys on ACCOUNT_KEY_APIKEY.
|
|
1341
|
+
const resolveOutboundSession = (account, clientKey) => sessionRegistry.getOrCreate(account?.alias ?? ACCOUNT_KEY_APIKEY, clientKey, Date.now(), account?.identity.sessionId);
|
|
1317
1342
|
// Optional proxy authentication — pre-encode key buffer for performance
|
|
1318
1343
|
const apiKey = process.env.DARIO_API_KEY;
|
|
1319
1344
|
const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
|
|
@@ -1368,14 +1393,13 @@ export async function startProxy(opts = {}) {
|
|
|
1368
1393
|
...SECURITY_HEADERS,
|
|
1369
1394
|
};
|
|
1370
1395
|
const JSON_HEADERS = { 'Content-Type': 'application/json', ...SECURITY_HEADERS };
|
|
1371
|
-
// Pool-
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1396
|
+
// Pool-derived status for /status + /health (#636). The pool is the one
|
|
1397
|
+
// credential model, so status is computed from its accounts' expiry +
|
|
1398
|
+
// cool-down — a login-less pool (admin bootstrap / api-key mode) reports
|
|
1399
|
+
// truthfully instead of the pre-v5 single-account getStatus() reading a
|
|
1400
|
+
// credentials.json that may not exist (which broke docker healthchecks and
|
|
1401
|
+
// made the TUI claim the proxy was down).
|
|
1376
1402
|
async function currentStatus() {
|
|
1377
|
-
if (!pool)
|
|
1378
|
-
return getStatus();
|
|
1379
1403
|
const now = Date.now();
|
|
1380
1404
|
return derivePoolStatus(pool.all().map((a) => ({ expiresAt: a.expiresAt, inAuthCooldown: isInAuthCooldown(a, now) })), now, adminEnabled);
|
|
1381
1405
|
}
|
|
@@ -1388,22 +1412,20 @@ export async function startProxy(opts = {}) {
|
|
|
1388
1412
|
// getModelCatalog falls back to the baked list, so the route always 200s.
|
|
1389
1413
|
const catalogDeps = {
|
|
1390
1414
|
upstreamApiKey: upstreamApiKey || undefined,
|
|
1391
|
-
getToken:
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
: 'pool has no accounts yet — run `dario accounts add <alias>`');
|
|
1399
|
-
}
|
|
1400
|
-
const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
|
|
1401
|
-
return (usable[0] ?? accounts[0]).accessToken;
|
|
1415
|
+
getToken: async () => {
|
|
1416
|
+
const now = Date.now();
|
|
1417
|
+
const accounts = pool.all();
|
|
1418
|
+
if (accounts.length === 0) {
|
|
1419
|
+
throw new Error(adminEnabled
|
|
1420
|
+
? 'pool has no accounts yet — add one via POST /admin/login/start'
|
|
1421
|
+
: 'pool has no accounts yet — run `dario login` (or `dario accounts add <alias>`)');
|
|
1402
1422
|
}
|
|
1403
|
-
|
|
1423
|
+
const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
|
|
1424
|
+
return (usable[0] ?? accounts[0]).accessToken;
|
|
1425
|
+
},
|
|
1404
1426
|
log: verbose ? (m) => console.log(m) : undefined,
|
|
1405
1427
|
};
|
|
1406
|
-
if (pool
|
|
1428
|
+
if (pool.size === 0) {
|
|
1407
1429
|
// Empty admin pool: skip the prewarm instead of logging a guaranteed
|
|
1408
1430
|
// failure at startup. The catalog refetches when the first account is
|
|
1409
1431
|
// hot-added (onAccountsChanged below).
|
|
@@ -1463,8 +1485,6 @@ export async function startProxy(opts = {}) {
|
|
|
1463
1485
|
// the admin API take effect immediately — no proxy restart (#599).
|
|
1464
1486
|
// Awaited by handleAdminRequest before it responds, so the account is
|
|
1465
1487
|
// already routable by the time the client sees the 200.
|
|
1466
|
-
if (!pool)
|
|
1467
|
-
return;
|
|
1468
1488
|
try {
|
|
1469
1489
|
const size = reconcilePoolAccounts(pool, await loadAllAccounts());
|
|
1470
1490
|
if (verbose)
|
|
@@ -1483,8 +1503,6 @@ export async function startProxy(opts = {}) {
|
|
|
1483
1503
|
// Live per-account status so GET /admin/accounts reports headroom, not
|
|
1484
1504
|
// just persisted metadata — the same snapshot GET /accounts exposes.
|
|
1485
1505
|
poolStatus: () => {
|
|
1486
|
-
if (!pool)
|
|
1487
|
-
return null;
|
|
1488
1506
|
const snapNow = Date.now();
|
|
1489
1507
|
const snap = new Map();
|
|
1490
1508
|
for (const a of pool.all()) {
|
|
@@ -1559,11 +1577,6 @@ export async function startProxy(opts = {}) {
|
|
|
1559
1577
|
// account that would be selected next. Read-only; mutation flows through
|
|
1560
1578
|
// the `dario accounts` CLI, not HTTP.
|
|
1561
1579
|
if (urlPath === '/accounts' && req.method === 'GET') {
|
|
1562
|
-
if (!pool) {
|
|
1563
|
-
res.writeHead(200, JSON_HEADERS);
|
|
1564
|
-
res.end(JSON.stringify({ mode: 'single-account', accounts: 0 }));
|
|
1565
|
-
return;
|
|
1566
|
-
}
|
|
1567
1580
|
const now = Date.now();
|
|
1568
1581
|
const accounts = pool.all().map(a => {
|
|
1569
1582
|
const inCooldown = isInAuthCooldown(a, now);
|
|
@@ -1608,9 +1621,9 @@ export async function startProxy(opts = {}) {
|
|
|
1608
1621
|
// backlog of the most-recent 50 records on connect so a freshly-
|
|
1609
1622
|
// attached subscriber sees state immediately, then live-tails.
|
|
1610
1623
|
//
|
|
1611
|
-
// Auth: same as /analytics — no auth
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1624
|
+
// Auth: same as /analytics — no auth by default; the proxy listens on
|
|
1625
|
+
// loopback. DARIO_API_KEY users get rejected by the earlier auth gate
|
|
1626
|
+
// up the handler chain.
|
|
1614
1627
|
//
|
|
1615
1628
|
// Disconnect handling: the 'close' event on `req` removes our
|
|
1616
1629
|
// listener from the Analytics EventEmitter so we don't leak.
|
|
@@ -1818,21 +1831,22 @@ export async function startProxy(opts = {}) {
|
|
|
1818
1831
|
let detectedClientForLog;
|
|
1819
1832
|
let preserveToolsEffective = Boolean(opts.preserveTools);
|
|
1820
1833
|
try {
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1826
|
-
// requests, not within a single 429 retry.
|
|
1834
|
+
// Select an account by headroom (v5.0: the pool is the one credential
|
|
1835
|
+
// model, so every OAuth request selects from it — a plain `dario login`
|
|
1836
|
+
// is a pool of one). Upstream-api-key mode is the sole path with no pool
|
|
1837
|
+
// account. Inside-request 429/auth failover retries the next-best account
|
|
1838
|
+
// before surfacing an error to the client (see the dispatch loop below).
|
|
1827
1839
|
let poolAccount = null;
|
|
1828
1840
|
let accessToken;
|
|
1829
1841
|
if (upstreamApiKey) {
|
|
1830
|
-
// Per-token API-key mode: no OAuth, no pool. `poolAccount`
|
|
1831
|
-
// so every pool-failover retry below is skipped; the
|
|
1832
|
-
// on the outbound headers instead of
|
|
1842
|
+
// Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
|
|
1843
|
+
// stays null, so every pool-failover retry below is skipped; the
|
|
1844
|
+
// x-api-key is set on the outbound headers instead of a Bearer.
|
|
1833
1845
|
accessToken = '';
|
|
1834
1846
|
}
|
|
1835
|
-
else
|
|
1847
|
+
else {
|
|
1848
|
+
// Pool is the one credential model (v5.0): a plain `dario login` is a
|
|
1849
|
+
// pool of one, so every OAuth request selects from the pool.
|
|
1836
1850
|
poolAccount = pool.select();
|
|
1837
1851
|
if (!poolAccount) {
|
|
1838
1852
|
// Two distinct empty-selection cases (#599): the pool has no accounts
|
|
@@ -1856,9 +1870,11 @@ export async function startProxy(opts = {}) {
|
|
|
1856
1870
|
}
|
|
1857
1871
|
accessToken = poolAccount.accessToken;
|
|
1858
1872
|
}
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1873
|
+
// Client-side session key (constant per request) for the rotation registry
|
|
1874
|
+
// — consulted at body-build, at the outbound header, and on each mid-request
|
|
1875
|
+
// failover rewrite so all three agree on the selected account's session.
|
|
1876
|
+
const clientSessionKey = req.headers['x-session-id']
|
|
1877
|
+
?? req.headers['x-client-session-id'];
|
|
1862
1878
|
// Read request body with size limit and timeout (prevents slow-loris)
|
|
1863
1879
|
const chunks = [];
|
|
1864
1880
|
let totalBytes = 0;
|
|
@@ -2065,7 +2081,7 @@ export async function startProxy(opts = {}) {
|
|
|
2065
2081
|
// that already has the Anthropic prompt cache warmed for it.
|
|
2066
2082
|
// Rotating off mid-session costs cache-create on every turn.
|
|
2067
2083
|
stickyKey = computeStickyKey(userMsg);
|
|
2068
|
-
if (
|
|
2084
|
+
if (stickyKey) {
|
|
2069
2085
|
const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
|
|
2070
2086
|
if (preferred && preferred.alias !== poolAccount?.alias) {
|
|
2071
2087
|
poolAccount = preferred;
|
|
@@ -2080,23 +2096,21 @@ export async function startProxy(opts = {}) {
|
|
|
2080
2096
|
// header both use the same value. v3.27 consulted SESSION_ID twice
|
|
2081
2097
|
// with rotation between the reads, so on rotation events body and
|
|
2082
2098
|
// header disagreed — harmless for plain operation but a fingerprint
|
|
2083
|
-
// in its own right.
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
else {
|
|
2088
|
-
const clientKey = req.headers['x-session-id']
|
|
2089
|
-
?? req.headers['x-client-session-id'];
|
|
2090
|
-
const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
|
|
2099
|
+
// in its own right. One mechanism now (v5.0): the rotation registry
|
|
2100
|
+
// keyed by the selected account (see resolveOutboundSession).
|
|
2101
|
+
{
|
|
2102
|
+
const assigned = resolveOutboundSession(poolAccount, clientSessionKey);
|
|
2091
2103
|
preBodySessionId = assigned.sessionId;
|
|
2092
2104
|
SESSION_ID = assigned.sessionId;
|
|
2093
2105
|
if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
|
|
2094
|
-
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
|
|
2106
|
+
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})${poolAccount ? ` [${poolAccount.alias}]` : ''}`);
|
|
2095
2107
|
}
|
|
2096
2108
|
}
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2109
|
+
// deviceId/accountUuid come from the selected account (or the local
|
|
2110
|
+
// Claude identity in api-key mode); sessionId is the registry value
|
|
2111
|
+
// resolved just above so body and header agree.
|
|
2112
|
+
const idSource = poolAccount ? poolAccount.identity : identity;
|
|
2113
|
+
const bodyIdentity = { deviceId: idSource.deviceId, accountUuid: idSource.accountUuid, sessionId: preBodySessionId };
|
|
2100
2114
|
const { body: ccBody, toolMap, detectedClient, unmappedTools, genuineCC } = buildCCRequest(r, billingTag, CACHE_EPHEMERAL, bodyIdentity, {
|
|
2101
2115
|
preserveTools: opts.preserveTools ?? false,
|
|
2102
2116
|
hybridTools: opts.hybridTools ?? false,
|
|
@@ -2280,11 +2294,11 @@ export async function startProxy(opts = {}) {
|
|
|
2280
2294
|
else {
|
|
2281
2295
|
// Beta set sourced from the live template (schema v2). Bundled
|
|
2282
2296
|
// snapshots predating v3.19 leave anthropic_beta undefined, so fall
|
|
2283
|
-
// back to the v2.1.104 flag set
|
|
2297
|
+
// back to the v2.1.104 flag set.
|
|
2284
2298
|
// context-1m requires Extra Usage — if it 400s, we auto-retry without
|
|
2285
2299
|
// it, and cache the rejection so subsequent requests on this account
|
|
2286
2300
|
// skip context-1m entirely (dario#36).
|
|
2287
|
-
const acctKey = poolAccount?.alias ??
|
|
2301
|
+
const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
|
|
2288
2302
|
const skipContext1m = context1mUnavailable.has(acctKey);
|
|
2289
2303
|
// Model-conditional betas (fable fallback-credit; [1m] context-1m),
|
|
2290
2304
|
// mirroring real CC — see betaForModel. betaWithoutContext1m still
|
|
@@ -2345,29 +2359,23 @@ export async function startProxy(opts = {}) {
|
|
|
2345
2359
|
await new Promise(r => setTimeout(r, totalDelay));
|
|
2346
2360
|
}
|
|
2347
2361
|
lastRequestTime = Date.now();
|
|
2348
|
-
// Session ID:
|
|
2349
|
-
//
|
|
2350
|
-
//
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2353
|
-
//
|
|
2354
|
-
// the template-build value; in passthrough mode (no template build)
|
|
2362
|
+
// Session ID: resolved through the rotation registry keyed by the selected
|
|
2363
|
+
// account (src/session-rotation.ts), applying the configured idle / jitter
|
|
2364
|
+
// / max-age / per-client policy. The template-build path resolves it
|
|
2365
|
+
// earlier so the CC body's metadata.session_id and this outbound
|
|
2366
|
+
// x-claude-code-session-id header agree; preBodySessionId holds that value.
|
|
2367
|
+
// In passthrough mode (no template build) preBodySessionId is unset, so
|
|
2355
2368
|
// the registry is consulted here instead.
|
|
2356
2369
|
let outboundSessionId;
|
|
2357
|
-
if (
|
|
2358
|
-
outboundSessionId = poolAccount.identity.sessionId;
|
|
2359
|
-
}
|
|
2360
|
-
else if (preBodySessionId !== undefined) {
|
|
2370
|
+
if (preBodySessionId !== undefined) {
|
|
2361
2371
|
outboundSessionId = preBodySessionId;
|
|
2362
2372
|
}
|
|
2363
2373
|
else {
|
|
2364
|
-
const
|
|
2365
|
-
?? req.headers['x-client-session-id'];
|
|
2366
|
-
const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
|
|
2374
|
+
const assigned = resolveOutboundSession(poolAccount, clientSessionKey);
|
|
2367
2375
|
outboundSessionId = assigned.sessionId;
|
|
2368
2376
|
SESSION_ID = assigned.sessionId;
|
|
2369
2377
|
if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
|
|
2370
|
-
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
|
|
2378
|
+
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})${poolAccount ? ` [${poolAccount.alias}]` : ''}`);
|
|
2371
2379
|
}
|
|
2372
2380
|
}
|
|
2373
2381
|
const headers = {
|
|
@@ -2437,7 +2445,7 @@ export async function startProxy(opts = {}) {
|
|
|
2437
2445
|
// Pool mode: capture rate-limit snapshot from the response. parseRateLimits
|
|
2438
2446
|
// returns status='rejected' on 429, which makes the next `select()` call
|
|
2439
2447
|
// route traffic away from this account until it resets.
|
|
2440
|
-
if (
|
|
2448
|
+
if (poolAccount) {
|
|
2441
2449
|
const snapshot = parseRateLimits(upstream.headers);
|
|
2442
2450
|
if (upstream.status === 429) {
|
|
2443
2451
|
pool.markRejected(poolAccount.alias, snapshot);
|
|
@@ -2487,7 +2495,7 @@ export async function startProxy(opts = {}) {
|
|
|
2487
2495
|
}
|
|
2488
2496
|
}
|
|
2489
2497
|
if (betaRejectedFlags.length > 0) {
|
|
2490
|
-
const acctKey = poolAccount?.alias ??
|
|
2498
|
+
const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
|
|
2491
2499
|
let set = unavailableBetas.get(acctKey);
|
|
2492
2500
|
if (!set) {
|
|
2493
2501
|
set = new Set();
|
|
@@ -2512,7 +2520,7 @@ export async function startProxy(opts = {}) {
|
|
|
2512
2520
|
});
|
|
2513
2521
|
upstream = retry;
|
|
2514
2522
|
peekedBody = null;
|
|
2515
|
-
if (
|
|
2523
|
+
if (poolAccount) {
|
|
2516
2524
|
const retrySnapshot = parseRateLimits(upstream.headers);
|
|
2517
2525
|
if (upstream.status === 429) {
|
|
2518
2526
|
pool.markRejected(poolAccount.alias, retrySnapshot);
|
|
@@ -2552,7 +2560,7 @@ export async function startProxy(opts = {}) {
|
|
|
2552
2560
|
upstream = retry;
|
|
2553
2561
|
peekedBody = null;
|
|
2554
2562
|
retried = true;
|
|
2555
|
-
if (
|
|
2563
|
+
if (poolAccount) {
|
|
2556
2564
|
const retrySnapshot = parseRateLimits(upstream.headers);
|
|
2557
2565
|
if (upstream.status === 429) {
|
|
2558
2566
|
pool.markRejected(poolAccount.alias, retrySnapshot);
|
|
@@ -2614,7 +2622,7 @@ export async function startProxy(opts = {}) {
|
|
|
2614
2622
|
upstream = retry;
|
|
2615
2623
|
peekedBody = null;
|
|
2616
2624
|
retried = true;
|
|
2617
|
-
if (
|
|
2625
|
+
if (poolAccount) {
|
|
2618
2626
|
const retrySnapshot = parseRateLimits(upstream.headers);
|
|
2619
2627
|
if (upstream.status === 429) {
|
|
2620
2628
|
pool.markRejected(poolAccount.alias, retrySnapshot);
|
|
@@ -2670,7 +2678,7 @@ export async function startProxy(opts = {}) {
|
|
|
2670
2678
|
upstream = retry;
|
|
2671
2679
|
peekedBody = null;
|
|
2672
2680
|
retried = true;
|
|
2673
|
-
if (
|
|
2681
|
+
if (poolAccount) {
|
|
2674
2682
|
const retrySnapshot = parseRateLimits(upstream.headers);
|
|
2675
2683
|
if (upstream.status === 429) {
|
|
2676
2684
|
pool.markRejected(poolAccount.alias, retrySnapshot);
|
|
@@ -2702,7 +2710,7 @@ export async function startProxy(opts = {}) {
|
|
|
2702
2710
|
else if (isLongContextError) {
|
|
2703
2711
|
// Cache the rejection so future requests on this account skip
|
|
2704
2712
|
// context-1m up front instead of re-paying the 400/429 round-trip.
|
|
2705
|
-
const acctKey = poolAccount?.alias ??
|
|
2713
|
+
const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
|
|
2706
2714
|
const firstRejection = !context1mUnavailable.has(acctKey);
|
|
2707
2715
|
context1mUnavailable.add(acctKey);
|
|
2708
2716
|
if (verbose && firstRejection)
|
|
@@ -2723,7 +2731,7 @@ export async function startProxy(opts = {}) {
|
|
|
2723
2731
|
upstream = retry;
|
|
2724
2732
|
peekedBody = null;
|
|
2725
2733
|
// Pool mode: re-capture after the context-1m retry as the snapshot may have changed.
|
|
2726
|
-
if (
|
|
2734
|
+
if (poolAccount) {
|
|
2727
2735
|
const retrySnapshot = parseRateLimits(upstream.headers);
|
|
2728
2736
|
if (upstream.status === 429) {
|
|
2729
2737
|
pool.markRejected(poolAccount.alias, retrySnapshot);
|
|
@@ -2735,14 +2743,14 @@ export async function startProxy(opts = {}) {
|
|
|
2735
2743
|
}
|
|
2736
2744
|
else if (upstream.status === 429) {
|
|
2737
2745
|
// Not a context-1m issue — try pool failover before surfacing to client
|
|
2738
|
-
if (
|
|
2746
|
+
if (poolAccount) {
|
|
2739
2747
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
2740
2748
|
if (nextAccount) {
|
|
2741
2749
|
triedAliases.add(nextAccount.alias);
|
|
2742
2750
|
poolAccount = nextAccount;
|
|
2743
2751
|
accessToken = nextAccount.accessToken;
|
|
2744
2752
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
2745
|
-
headers['x-claude-code-session-id'] = nextAccount.
|
|
2753
|
+
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
2746
2754
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
2747
2755
|
peekedBody = null;
|
|
2748
2756
|
continue dispatchLoop;
|
|
@@ -2760,16 +2768,16 @@ export async function startProxy(opts = {}) {
|
|
|
2760
2768
|
}
|
|
2761
2769
|
}
|
|
2762
2770
|
requestCount++;
|
|
2763
|
-
// v4: analytics is always-on.
|
|
2771
|
+
// v4: analytics is always-on. A pool account supplies the rate-limit
|
|
2764
2772
|
// snapshot from `poolAccount.rateLimit` (already authoritative);
|
|
2765
|
-
//
|
|
2773
|
+
// api-key mode (no pool account) parses it from the upstream response
|
|
2766
2774
|
// headers on the spot so the TUI's Hits feed shows the same
|
|
2767
|
-
// bucket / utilization fields
|
|
2775
|
+
// bucket / utilization fields either way.
|
|
2768
2776
|
{
|
|
2769
2777
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
2770
2778
|
analytics.record({
|
|
2771
2779
|
timestamp: Date.now(),
|
|
2772
|
-
account: poolAccount?.alias ??
|
|
2780
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
2773
2781
|
model: requestModel,
|
|
2774
2782
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
2775
2783
|
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
@@ -2805,7 +2813,7 @@ export async function startProxy(opts = {}) {
|
|
|
2805
2813
|
// headers, so headroom math sees a healthy idle account. Mark the
|
|
2806
2814
|
// cool-down here, try the next-best account, fall through to the
|
|
2807
2815
|
// normal forwarding only if no peer is available.
|
|
2808
|
-
if (
|
|
2816
|
+
if (poolAccount && (upstream.status === 401 || upstream.status === 403)) {
|
|
2809
2817
|
pool.markAuthFailure(poolAccount.alias);
|
|
2810
2818
|
if (verbose) {
|
|
2811
2819
|
console.error(`[dario] auth failure (${upstream.status}) on account "${poolAccount.alias}" — placing in cool-down and attempting failover`);
|
|
@@ -2816,7 +2824,7 @@ export async function startProxy(opts = {}) {
|
|
|
2816
2824
|
poolAccount = nextAccount;
|
|
2817
2825
|
accessToken = nextAccount.accessToken;
|
|
2818
2826
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
2819
|
-
headers['x-claude-code-session-id'] = nextAccount.
|
|
2827
|
+
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
2820
2828
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
2821
2829
|
continue dispatchLoop;
|
|
2822
2830
|
}
|
|
@@ -2826,14 +2834,14 @@ export async function startProxy(opts = {}) {
|
|
|
2826
2834
|
// Enrich 429 errors with rate limit details from headers (Anthropic only returns "Error")
|
|
2827
2835
|
if (upstream.status === 429) {
|
|
2828
2836
|
// Try pool failover before surfacing to client
|
|
2829
|
-
if (
|
|
2837
|
+
if (poolAccount) {
|
|
2830
2838
|
const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
|
|
2831
2839
|
if (nextAccount) {
|
|
2832
2840
|
triedAliases.add(nextAccount.alias);
|
|
2833
2841
|
poolAccount = nextAccount;
|
|
2834
2842
|
accessToken = nextAccount.accessToken;
|
|
2835
2843
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
2836
|
-
headers['x-claude-code-session-id'] = nextAccount.
|
|
2844
|
+
headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
|
|
2837
2845
|
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
2838
2846
|
continue dispatchLoop;
|
|
2839
2847
|
}
|
|
@@ -2855,7 +2863,7 @@ export async function startProxy(opts = {}) {
|
|
|
2855
2863
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
2856
2864
|
analytics.record({
|
|
2857
2865
|
timestamp: Date.now(),
|
|
2858
|
-
account: poolAccount?.alias ??
|
|
2866
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
2859
2867
|
model: requestModel,
|
|
2860
2868
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
2861
2869
|
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
@@ -2870,7 +2878,7 @@ export async function startProxy(opts = {}) {
|
|
|
2870
2878
|
// Clear the auth-failure cool-down on the responding account if
|
|
2871
2879
|
// the upstream returned a 2xx — this account is healthy again,
|
|
2872
2880
|
// so its consecutive-failure counter resets. dario#234.
|
|
2873
|
-
if (
|
|
2881
|
+
if (poolAccount && upstream.status >= 200 && upstream.status < 300) {
|
|
2874
2882
|
pool.clearAuthFailure(poolAccount.alias);
|
|
2875
2883
|
}
|
|
2876
2884
|
break;
|
|
@@ -2936,12 +2944,11 @@ export async function startProxy(opts = {}) {
|
|
|
2936
2944
|
if (isStream && upstream.body) {
|
|
2937
2945
|
// Analytics accumulators for streaming responses — filled by parsing
|
|
2938
2946
|
// message_start / message_delta / content_block_delta SSE events as
|
|
2939
|
-
// they flow through. Token capture must run regardless of
|
|
2940
|
-
// gating on `poolAccount` (
|
|
2941
|
-
// skipped the parser entirely
|
|
2942
|
-
//
|
|
2943
|
-
//
|
|
2944
|
-
// token usage invisible in /analytics until this fix.
|
|
2947
|
+
// they flow through. Token capture must run regardless of whether a
|
|
2948
|
+
// pool account is present: gating on `poolAccount` (null in api-key
|
|
2949
|
+
// mode) once skipped the parser entirely, so the analytics.record()
|
|
2950
|
+
// call below persisted zeros for input/output tokens. SDK streaming
|
|
2951
|
+
// clients had their token usage invisible in /analytics until the fix.
|
|
2945
2952
|
let streamInputTokens = 0;
|
|
2946
2953
|
let streamOutputTokens = 0;
|
|
2947
2954
|
let streamCacheReadTokens = 0;
|
|
@@ -3112,7 +3119,7 @@ export async function startProxy(opts = {}) {
|
|
|
3112
3119
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
3113
3120
|
analytics.record({
|
|
3114
3121
|
timestamp: Date.now(),
|
|
3115
|
-
account: poolAccount?.alias ??
|
|
3122
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
3116
3123
|
model: requestModel,
|
|
3117
3124
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
3118
3125
|
cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
|
|
@@ -3173,7 +3180,7 @@ export async function startProxy(opts = {}) {
|
|
|
3173
3180
|
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
3174
3181
|
analytics.record({
|
|
3175
3182
|
timestamp: Date.now(),
|
|
3176
|
-
account: poolAccount?.alias ??
|
|
3183
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
|
|
3177
3184
|
model: bufferedUsage.model || requestModel,
|
|
3178
3185
|
inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
|
|
3179
3186
|
cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
|
|
@@ -3358,14 +3365,14 @@ export async function startProxy(opts = {}) {
|
|
|
3358
3365
|
? 'Mode: passthrough (OAuth swap only, no injection)'
|
|
3359
3366
|
: `OAuth: ${status.status} (expires in ${status.expiresIn})`;
|
|
3360
3367
|
const modelLine = modelOverride ? `Model: ${modelOverride} (all requests)` : 'Model: passthrough (client decides)';
|
|
3361
|
-
// Pool line surfaces the
|
|
3362
|
-
//
|
|
3363
|
-
//
|
|
3364
|
-
const poolLine = pool
|
|
3365
|
-
?
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3368
|
+
// Pool line surfaces the account state on every startup. The pool is the
|
|
3369
|
+
// one credential model now (v5.0): a plain `dario login` shows as a pool of
|
|
3370
|
+
// one. An empty pool is only reachable in admin-bootstrap / api-key mode.
|
|
3371
|
+
const poolLine = pool.size === 0
|
|
3372
|
+
? 'Pool: empty (admin bootstrap / api-key mode) — no accounts loaded'
|
|
3373
|
+
: pool.size === 1
|
|
3374
|
+
? 'Pool: 1 account (a pool of one) — add more with `dario accounts add <alias>` to load-balance'
|
|
3375
|
+
: `Pool: ${pool.size} accounts — headroom-routed, sticky for multi-turn`;
|
|
3369
3376
|
// Display URL uses `localhost` for loopback binds and the literal host
|
|
3370
3377
|
// for exposed binds, so the printed URL is the one a client would
|
|
3371
3378
|
// actually use to reach the proxy.
|
|
@@ -3407,15 +3414,13 @@ export async function startProxy(opts = {}) {
|
|
|
3407
3414
|
return;
|
|
3408
3415
|
lastPresencePulse = now;
|
|
3409
3416
|
try {
|
|
3410
|
-
//
|
|
3411
|
-
//
|
|
3412
|
-
//
|
|
3413
|
-
//
|
|
3414
|
-
//
|
|
3415
|
-
//
|
|
3416
|
-
|
|
3417
|
-
// fresh; never refresh from this side in pool mode.
|
|
3418
|
-
const token = pool ? (pool.all()[0]?.accessToken ?? '') : await getAccessToken();
|
|
3417
|
+
// The pool refresh loop (above) is the SOLE refresher of every account's
|
|
3418
|
+
// token lineage. Pulse with a token the pool already keeps fresh; never
|
|
3419
|
+
// refresh from this side — doing so would race the pool refreshing the
|
|
3420
|
+
// same lineage and trip Anthropic's refresh-token reuse-detection (the
|
|
3421
|
+
// 2026-06-23 fleet outage, dario#641-audit). Empty in api-key mode (no
|
|
3422
|
+
// pool account); presence is an OAuth-session concept and is skipped there.
|
|
3423
|
+
const token = pool.all()[0]?.accessToken ?? '';
|
|
3419
3424
|
if (!token)
|
|
3420
3425
|
return;
|
|
3421
3426
|
const presenceUrl = `${ANTHROPIC_API}/v1/code/sessions/${SESSION_ID}/client/presence`;
|
|
@@ -3433,25 +3438,11 @@ export async function startProxy(opts = {}) {
|
|
|
3433
3438
|
}
|
|
3434
3439
|
catch { /* presence is best-effort */ }
|
|
3435
3440
|
}, 5000);
|
|
3436
|
-
//
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
// detection. Skip; the pool is the sole refresher.
|
|
3442
|
-
if (pool)
|
|
3443
|
-
return;
|
|
3444
|
-
try {
|
|
3445
|
-
const s = await getStatus();
|
|
3446
|
-
if (s.status === 'expiring' || s.status === 'expired') {
|
|
3447
|
-
console.log('[dario] Token expiring, refreshing...');
|
|
3448
|
-
await getAccessToken(); // triggers refresh
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
catch (err) {
|
|
3452
|
-
console.error('[dario] Background refresh error:', err instanceof Error ? err.message : err);
|
|
3453
|
-
}
|
|
3454
|
-
}, 15 * 60 * 1000);
|
|
3441
|
+
// Token refresh is owned entirely by the pool's 15-min refresh loop (above),
|
|
3442
|
+
// the sole refresher of every account's token lineage. The pre-v5 single-
|
|
3443
|
+
// account periodic refresh that lived here is gone with the single-account
|
|
3444
|
+
// path — refreshing credentials.json alongside the pool would double-refresh
|
|
3445
|
+
// a shared lineage and trip reuse-detection (see the presence-loop note).
|
|
3455
3446
|
// Graceful shutdown
|
|
3456
3447
|
const shutdown = () => {
|
|
3457
3448
|
console.log('\n[dario] Shutting down...');
|