opencode-auth-load-balancer 0.2.2 → 0.3.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/dist/index.js CHANGED
@@ -12341,6 +12341,7 @@ import { dirname as dirname2 } from "node:path";
12341
12341
  function emptyUsage() {
12342
12342
  return { hourly: null, weekly: null, capturedAt: 0 };
12343
12343
  }
12344
+ var MANUAL_DISABLED_REASON = "manually disabled";
12344
12345
 
12345
12346
  // src/providers/http-timeouts.ts
12346
12347
  var OAUTH_HTTP_TIMEOUT_MS = 30000;
@@ -12699,6 +12700,14 @@ function normalizeLastSelected(rows) {
12699
12700
  }
12700
12701
  return rows;
12701
12702
  }
12703
+ function normalizeRelogin(value, now) {
12704
+ if (!isPlainObject2(value))
12705
+ return;
12706
+ const intent = value;
12707
+ if (typeof intent.accountId !== "string" || typeof intent.providerID !== "string" || !Number.isFinite(intent.expiresAt) || intent.expiresAt <= now)
12708
+ return;
12709
+ return intent;
12710
+ }
12702
12711
  async function readRaw() {
12703
12712
  let text;
12704
12713
  try {
@@ -12716,6 +12725,7 @@ async function readRaw() {
12716
12725
  return {
12717
12726
  version: 1,
12718
12727
  accounts: normalizeAccounts(parsed.accounts),
12728
+ relogin: normalizeRelogin(parsed.relogin, Date.now()),
12719
12729
  lastSelected: isPlainObject2(parsed.lastSelected) ? normalizeLastSelected(parsed.lastSelected) : {},
12720
12730
  sessions: isPlainObject2(parsed.sessions) ? normalizeSessions(parsed.sessions) : {}
12721
12731
  };
@@ -12788,19 +12798,27 @@ function makeAccount(providerID, label, tokens) {
12788
12798
  disabledReason: null
12789
12799
  };
12790
12800
  }
12801
+ function applyTokens(row, tokens) {
12802
+ if (tokens.refresh)
12803
+ row.refresh = tokens.refresh;
12804
+ row.access = tokens.access;
12805
+ row.expires = tokens.expires;
12806
+ row.disabledReason = null;
12807
+ if (tokens.accountId)
12808
+ row.accountId = tokens.accountId;
12809
+ return row;
12810
+ }
12791
12811
  async function addAccount(providerID, tokens, label) {
12792
12812
  return mutatePool((pool) => {
12813
+ const intent = pool.relogin?.providerID === providerID ? pool.relogin : undefined;
12814
+ if (intent)
12815
+ delete pool.relogin;
12793
12816
  const existing = pool.accounts.find((a) => a.providerID === providerID && (tokens.refresh && a.refresh === tokens.refresh || tokens.accountId && a.accountId === tokens.accountId));
12794
- if (existing) {
12795
- if (tokens.refresh)
12796
- existing.refresh = tokens.refresh;
12797
- existing.access = tokens.access;
12798
- existing.expires = tokens.expires;
12799
- existing.disabledReason = null;
12800
- if (tokens.accountId)
12801
- existing.accountId = tokens.accountId;
12802
- return existing;
12803
- }
12817
+ if (existing)
12818
+ return applyTokens(existing, tokens);
12819
+ const target = intent ? pool.accounts.find((account2) => account2.id === intent.accountId) : undefined;
12820
+ if (target)
12821
+ return applyTokens(target, tokens);
12804
12822
  const used = new Set(pool.accounts.map((a) => a.label));
12805
12823
  let n = 1;
12806
12824
  while (used.has(`${providerID}-${n}`))
@@ -13153,26 +13171,31 @@ function loadConfig(env = process.env) {
13153
13171
  // src/scheduler/select.ts
13154
13172
  var IMMINENT_EXHAUSTION_BAND = 0.01;
13155
13173
  var PROACTIVE_MIGRATE_MIN_DELTA = 0.02;
13174
+ var INCUMBENT_HYSTERESIS = 0.2;
13156
13175
  function weeklyUtil(account) {
13157
13176
  return account.usage.weekly?.utilization ?? 0;
13158
13177
  }
13159
13178
  var NO_EXCLUSIONS = new Set;
13160
- function selectAccount(accounts, providerID, now, cfg = DEFAULT_CONFIG, exclude = NO_EXCLUSIONS) {
13161
- let sawAvailable = false;
13179
+ function selectAccount(accounts, providerID, now, cfg = DEFAULT_CONFIG, exclude = NO_EXCLUSIONS, incumbentId = null) {
13162
13180
  let bestAvail = null;
13163
13181
  let bestAvailScore = Number.NEGATIVE_INFINITY;
13164
13182
  let bestFallback = null;
13165
13183
  let lowestWeeklyUtil = Number.POSITIVE_INFINITY;
13184
+ let incumbentAccount = null;
13185
+ let incumbentScore = Number.NEGATIVE_INFINITY;
13166
13186
  for (const account of accounts) {
13167
13187
  if (account.providerID !== providerID || account.disabledReason || exclude.has(account.id))
13168
13188
  continue;
13169
13189
  if (isAvailable(account, cfg, now)) {
13170
- sawAvailable = true;
13171
13190
  const score = scoreAccount(account, cfg, now);
13172
13191
  if (score > bestAvailScore) {
13173
13192
  bestAvailScore = score;
13174
13193
  bestAvail = account;
13175
13194
  }
13195
+ if (account.id === incumbentId) {
13196
+ incumbentAccount = account;
13197
+ incumbentScore = score;
13198
+ }
13176
13199
  } else {
13177
13200
  const util = weeklyUtil(account);
13178
13201
  if (util < lowestWeeklyUtil) {
@@ -13181,8 +13204,11 @@ function selectAccount(accounts, providerID, now, cfg = DEFAULT_CONFIG, exclude
13181
13204
  }
13182
13205
  }
13183
13206
  }
13184
- if (sawAvailable)
13185
- return bestAvail ? { account: bestAvail, degraded: false, sticky: false } : null;
13207
+ if (bestAvail) {
13208
+ if (incumbentAccount && incumbentScore >= bestAvailScore * (1 - INCUMBENT_HYSTERESIS))
13209
+ return { account: incumbentAccount, degraded: false, sticky: false };
13210
+ return { account: bestAvail, degraded: false, sticky: false };
13211
+ }
13186
13212
  return bestFallback ? { account: bestFallback, degraded: true, sticky: false } : null;
13187
13213
  }
13188
13214
  function findPinned(pool, providerID, sessionKey, exclude) {
@@ -13206,9 +13232,9 @@ function isCheapMoment(requestBytes, cfg) {
13206
13232
  function selectForSession(pool, providerID, sessionKey, now, cfg = DEFAULT_CONFIG, exclude = NO_EXCLUSIONS, requestBytes = 0) {
13207
13233
  const pinned = sessionKey ? findPinned(pool, providerID, sessionKey, exclude) : null;
13208
13234
  if (!pinned)
13209
- return selectAccount(pool.accounts, providerID, now, cfg, exclude);
13235
+ return selectAccount(pool.accounts, providerID, now, cfg, exclude, pool.lastSelected[providerID] ?? null);
13210
13236
  if (!isAvailable(pinned, cfg, now))
13211
- return selectAccount(pool.accounts, providerID, now, cfg, exclude);
13237
+ return selectAccount(pool.accounts, providerID, now, cfg, exclude, pool.lastSelected[providerID] ?? null);
13212
13238
  const weeklyUtilPinned = utilOf(pinned.usage.weekly, now);
13213
13239
  const hourlyUtilPinned = utilOf(pinned.usage.hourly, now);
13214
13240
  const pinnedOverSoft = weeklyUtilPinned >= cfg.weeklyDrainTarget || hourlyUtilPinned >= cfg.migrateAt;
@@ -13217,11 +13243,11 @@ function selectForSession(pool, providerID, sessionKey, now, cfg = DEFAULT_CONFI
13217
13243
  const pinnedImminent = pinnedOverSoft && pinnedUtil >= cfg.exhaustedAt - IMMINENT_EXHAUSTION_BAND;
13218
13244
  const cheap = (!pinnedImminent || cfg.drainMigrate) && isCheapMoment(typeof requestBytes === "function" ? requestBytes() : requestBytes, cfg);
13219
13245
  if (pinnedOverSoft && (pinnedImminent || cheap) || cfg.drainMigrate && cheap) {
13220
- const alt = selectAccount(pool.accounts, providerID, now, cfg, withExcluded(exclude, pinned.id));
13246
+ const alt = selectAccount(pool.accounts, providerID, now, cfg, withExcluded(exclude, pinned.id), null);
13221
13247
  if (alt && !alt.degraded) {
13222
13248
  if (pinnedOverSoft) {
13223
13249
  const altUtil = maxUtil(alt.account, now);
13224
- const proactiveBetter = pinnedImminent ? altUtil < pinnedUtil : pinnedUtil - altUtil >= PROACTIVE_MIGRATE_MIN_DELTA;
13250
+ const proactiveBetter = pinnedImminent ? altUtil < pinnedUtil : pinnedUtil - altUtil >= PROACTIVE_MIGRATE_MIN_DELTA && weeklyUrgency(alt.account, cfg, now) >= weeklyUrgency(pinned, cfg, now);
13225
13251
  if (proactiveBetter) {
13226
13252
  return { account: alt.account, degraded: false, sticky: false };
13227
13253
  }
@@ -13295,804 +13321,306 @@ function preserveWeeklyAnchor(incoming, stored, now) {
13295
13321
  return { utilization: incoming.utilization, resetAt: anchor };
13296
13322
  }
13297
13323
 
13298
- // src/usage-refresh.ts
13299
- var SEED_TTL_MS = 5 * 60 * 1000;
13300
- var lastPoll = new Map;
13301
- async function refreshUsageInBackground(adapter, now, poolSnapshot) {
13302
- const pool = poolSnapshot ?? await readPool();
13303
- const aliveIds = lastPoll.size > 0 ? new Set : undefined;
13304
- let stale;
13305
- for (const account of pool.accounts) {
13306
- aliveIds?.add(account.id);
13307
- if (account.providerID !== adapter.id || account.disabledReason)
13308
- continue;
13309
- if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt <= SEED_TTL_MS)
13310
- continue;
13311
- if ((lastPoll.get(account.id) ?? 0) > now - SEED_TTL_MS)
13312
- continue;
13313
- lastPoll.set(account.id, now);
13314
- stale ??= [];
13315
- stale.push(account);
13316
- }
13317
- if (aliveIds) {
13318
- for (const id of lastPoll.keys())
13319
- if (!aliveIds.has(id))
13320
- lastPoll.delete(id);
13324
+ // src/providers/types.ts
13325
+ function classifyHttpStatus(status) {
13326
+ if (status === 429 || status === 402)
13327
+ return "account";
13328
+ if (status === 401 || status === 403)
13329
+ return "auth";
13330
+ if (status >= 500)
13331
+ return "service";
13332
+ return "ok";
13333
+ }
13334
+ function urlFromInput(input) {
13335
+ try {
13336
+ return new URL(input instanceof Request ? input.url : input);
13337
+ } catch {
13338
+ return null;
13321
13339
  }
13322
- if (!stale)
13323
- return;
13324
- await Promise.all(stale.map(async (account) => {
13325
- try {
13326
- await ensureAccessToken(adapter, account, now);
13327
- const snapshot = await adapter.fetchUsage(account, now);
13328
- if (snapshot) {
13329
- await mutatePool((p) => {
13330
- const stored = findAccount(p, account.id);
13331
- if (stored)
13332
- stored.usage = {
13333
- hourly: snapshot.hourly ?? stored.usage.hourly,
13334
- weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
13335
- capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
13336
- };
13337
- });
13338
- }
13339
- } catch {}
13340
- }));
13341
13340
  }
13342
13341
 
13343
- // src/fetch.ts
13344
- var ACCOUNT_COOLDOWN_MS = 5 * 60 * 1000;
13345
- var AUTH_COOLDOWN_MS = 2 * 60 * 1000;
13346
- var DEBUG = process.env.OPENCODE_AUTH_LB_DEBUG === "1" || process.env.OPENCODE_AUTH_LB_DEBUG === "true";
13347
- function log(message) {
13348
- if (DEBUG)
13349
- console.error(`[auth-lb] ${message}`);
13342
+ // src/providers/anthropic/fallback.ts
13343
+ var TIER_RESET_FALLBACK_MS = 60 * 60 * 1000;
13344
+ var TIER_RESET_MAX_MS = MAX_QUOTA_RESET_BOUND_MS;
13345
+ var ALPHA_SEGMENT_RE = /^[a-z]+$/;
13346
+ var DIGIT_SEGMENT_RE = /^\d+$/;
13347
+ var DATE_SEGMENT_RE = /^\d{8}$/;
13348
+ function modelFamily(model) {
13349
+ const lower = model.toLowerCase();
13350
+ if (!lower.startsWith("claude"))
13351
+ return null;
13352
+ for (const segment of lower.split("-")) {
13353
+ if (segment !== "claude" && ALPHA_SEGMENT_RE.test(segment))
13354
+ return segment;
13355
+ }
13356
+ return null;
13350
13357
  }
13351
- async function bestEffort(what, op) {
13358
+ function requestModelTier(body) {
13359
+ let parsed;
13352
13360
  try {
13353
- await op();
13354
- } catch (error45) {
13355
- if (error45 instanceof LockTimeoutError || error45 instanceof PoolReadError || error45 instanceof PoolWriteError) {
13356
- log(`bookkeeping skipped (${what}): ${error45.message}`);
13357
- return;
13358
- }
13359
- throw error45;
13361
+ parsed = JSON.parse(body);
13362
+ } catch {
13363
+ return null;
13360
13364
  }
13365
+ const model = parsed.model;
13366
+ return typeof model === "string" ? modelFamily(model) : null;
13361
13367
  }
13362
- function applyUsagePartial(account, partial2, now) {
13363
- if (partial2.hourly !== undefined)
13364
- account.usage.hourly = partial2.hourly;
13365
- if (partial2.weekly !== undefined) {
13366
- account.usage.weekly = preserveWeeklyAnchor(partial2.weekly, account.usage.weekly, now);
13367
- account.usage.capturedAt = now;
13368
+ function versionVector(model) {
13369
+ const version2 = [];
13370
+ let date5 = -1;
13371
+ for (const segment of model.split("-")) {
13372
+ if (DATE_SEGMENT_RE.test(segment))
13373
+ date5 = Number(segment);
13374
+ else if (DIGIT_SEGMENT_RE.test(segment))
13375
+ version2.push(Number(segment));
13368
13376
  }
13377
+ return { version: version2, date: date5 };
13369
13378
  }
13370
- var RETRY_AFTER_MAX_MS = MAX_QUOTA_RESET_BOUND_MS;
13371
- function cooldownUntilFrom(res, fallbackMs, now) {
13372
- let until = now + fallbackMs;
13373
- const retryAfter = res?.headers.get("retry-after");
13374
- if (retryAfter) {
13375
- const secs = Number(retryAfter);
13376
- if (Number.isFinite(secs) && secs > 0) {
13377
- const delta = secs * 1000;
13378
- if (Number.isFinite(delta) && delta <= RETRY_AFTER_MAX_MS)
13379
- until = now + delta;
13380
- } else {
13381
- const httpDate = Date.parse(retryAfter);
13382
- if (Number.isFinite(httpDate) && httpDate > now && httpDate - now <= RETRY_AFTER_MAX_MS)
13383
- until = httpDate;
13384
- }
13379
+ function compareVersions(a, b) {
13380
+ const len = Math.max(a.version.length, b.version.length);
13381
+ for (let i = 0;i < len; i++) {
13382
+ const d = (a.version[i] ?? -1) - (b.version[i] ?? -1);
13383
+ if (d !== 0)
13384
+ return d;
13385
13385
  }
13386
- return until;
13387
- }
13388
- async function applyCooldown(accountId, fallbackMs) {
13389
- const until = cooldownUntilFrom(null, fallbackMs, Date.now());
13390
- await bestEffort("cooldown", () => mutatePool((pool) => {
13391
- const account = findAccount(pool, accountId);
13392
- if (account)
13393
- account.cooldownUntil = Math.max(account.cooldownUntil, until);
13394
- }));
13395
- }
13396
- async function recordRotation(adapter, accountId, res, fallbackMs, now) {
13397
- const partial2 = adapter.parseUsageHeaders(res.headers);
13398
- const until = cooldownUntilFrom(res, fallbackMs, now);
13399
- await bestEffort("rotation", () => mutatePool((pool) => {
13400
- const account = findAccount(pool, accountId);
13401
- if (!account)
13402
- return;
13403
- if (partial2)
13404
- applyUsagePartial(account, partial2, now);
13405
- account.cooldownUntil = Math.max(account.cooldownUntil, until);
13406
- }));
13407
- }
13408
- async function recordModelCooldown(accountId, tier, until, partial2, now) {
13409
- await bestEffort("model-cooldown", () => mutatePool((pool) => {
13410
- const account = findAccount(pool, accountId);
13411
- if (!account)
13412
- return;
13413
- if (partial2)
13414
- applyUsagePartial(account, partial2, now);
13415
- const map2 = account.modelCooldownsUntil ??= {};
13416
- map2[tier] = Math.max(map2[tier] ?? 0, until);
13417
- }));
13386
+ return a.date - b.date;
13418
13387
  }
13419
- async function recordSuccess(adapter, accountId, partial2, sessionKey, now, ttlMs) {
13420
- await bestEffort("record-success", () => mutatePool((pool) => {
13421
- const account = findAccount(pool, accountId);
13422
- if (account) {
13423
- if (partial2)
13424
- applyUsagePartial(account, partial2, now);
13425
- pool.lastSelected[adapter.id] = accountId;
13426
- }
13427
- if (sessionKey) {
13428
- pool.sessions[sessionKey] = { accountId, updatedAt: now };
13429
- for (const k of Object.keys(pool.sessions)) {
13430
- const pin = pool.sessions[k];
13431
- if (pin && now - pin.updatedAt > ttlMs)
13432
- delete pool.sessions[k];
13433
- }
13388
+ function bestOfFamily(models, family) {
13389
+ let best = null;
13390
+ let bestVersion = {
13391
+ version: [],
13392
+ date: -1
13393
+ };
13394
+ for (const model of models) {
13395
+ if (modelFamily(model) !== family)
13396
+ continue;
13397
+ const version2 = versionVector(model);
13398
+ if (best === null || compareVersions(version2, bestVersion) > 0) {
13399
+ best = model;
13400
+ bestVersion = version2;
13434
13401
  }
13435
- }));
13402
+ }
13403
+ return best;
13436
13404
  }
13437
- function noUsableAccountResponse(providerID) {
13438
- const message = `No usable ${providerID} account in the auth-load-balancer pool. Run "opencode auth login" to add or re-login an account.`;
13439
- const error45 = { type: "authentication_error", message };
13440
- const body = providerID === PROVIDER_ID ? { type: "error", error: error45 } : { error: error45 };
13441
- return new Response(JSON.stringify(body), {
13442
- status: 401,
13443
- headers: { "content-type": "application/json" }
13444
- });
13405
+ var computeFamilyOrder = memoOne((raw) => {
13406
+ const parsed = (raw ?? "").split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
13407
+ return parsed.length > 0 ? parsed : DEFAULT_FAMILY_ORDER;
13408
+ });
13409
+ function resolveFamilyOrder() {
13410
+ return computeFamilyOrder(process.env.OPENCODE_AUTH_LB_ANTHROPIC_FAMILY_ORDER);
13445
13411
  }
13446
- function soonestCooldownUntil(accounts, now, accountIds) {
13447
- if (accountIds.size === 0)
13448
- return null;
13449
- let soonest = Number.POSITIVE_INFINITY;
13450
- for (const account of accounts) {
13451
- if (account.disabledReason)
13452
- continue;
13453
- if (!accountIds.has(account.id))
13412
+ var computeFallbackSetting = memoOne((raw) => {
13413
+ const trimmed = raw?.trim();
13414
+ return raw === undefined ? { kind: "ladder" } : trimmed ? { kind: "pinned", model: trimmed } : { kind: "disabled" };
13415
+ });
13416
+ function resolveFallbackSetting() {
13417
+ return computeFallbackSetting(process.env.OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL);
13418
+ }
13419
+ function ladderTargetForFamily(fromFamily, models) {
13420
+ const order = resolveFamilyOrder();
13421
+ const start = order.indexOf(fromFamily) + 1;
13422
+ for (let i = start;i < order.length; i++) {
13423
+ const family = order[i];
13424
+ if (family === undefined)
13454
13425
  continue;
13455
- const until = account.cooldownUntil > now ? account.cooldownUntil : now;
13456
- if (until < soonest)
13457
- soonest = until;
13426
+ const best = bestOfFamily(models, family);
13427
+ if (best !== null)
13428
+ return best;
13458
13429
  }
13459
- return Number.isFinite(soonest) ? soonest : null;
13430
+ return null;
13460
13431
  }
13461
- function createLoadBalancedFetch(adapter, hooks = {}, models = []) {
13462
- const cfg = loadConfig();
13463
- return async (input, init) => {
13464
- const bodyStr = typeof init?.body === "string" ? init.body : undefined;
13465
- const baseHeaders = mergeHeaders(input, init);
13466
- const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
13467
- const baseKey = deriveSessionKey(baseHeaders, bodyStr);
13468
- const sessionKey = baseKey ? `${adapter.id}:${baseKey}` : null;
13469
- baseHeaders.delete(SESSION_HEADER);
13470
- const tried = new Set;
13471
- let waitableCooldownIds = null;
13472
- let lastError = null;
13473
- let cachedRequestBytes = -1;
13474
- const requestBytes = cfg.cheapSwitchMaxBytes > 0 && sessionKey !== null && bodyStr ? () => {
13475
- if (cachedRequestBytes < 0)
13476
- cachedRequestBytes = Buffer.byteLength(bodyStr, "utf8");
13477
- return cachedRequestBytes;
13478
- } : 0;
13479
- const transformedBody = bodyStr !== undefined ? adapter.transformBody(bodyStr) : init?.body;
13480
- const transformedUrl = adapter.transformUrl(input);
13481
- let currentBody = transformedBody;
13482
- let currentTier = typeof bodyStr === "string" ? adapter.requestModelTier?.(bodyStr) ?? null : null;
13483
- let fallbackPlan = null;
13484
- let fallbackPlanFor = null;
13485
- const planDowngrade = () => {
13486
- if (typeof currentBody === "string" && fallbackPlanFor !== currentBody) {
13487
- fallbackPlanFor = currentBody;
13488
- fallbackPlan = adapter.planModelFallback?.(currentBody, models) ?? null;
13489
- }
13490
- return fallbackPlan;
13491
- };
13492
- let fallbackInfo = null;
13493
- let pendingTierFallback = null;
13494
- let tierSkipped = null;
13495
- let usageSeeded = false;
13496
- const waitDeadline = Date.now() + cfg.maxWaitMs;
13497
- for (;; ) {
13498
- const pool = await readPool();
13499
- const now = Date.now();
13500
- if (!usageSeeded) {
13501
- usageSeeded = true;
13502
- refreshUsageInBackground(adapter, now, pool).catch(ignore);
13503
- }
13504
- const selection = selectForSession(pool, adapter.id, sessionKey, now, cfg, tried, requestBytes);
13505
- if (!selection) {
13506
- if (tried.size === 0)
13507
- return noUsableAccountResponse(adapter.id);
13508
- if (pendingTierFallback !== null) {
13509
- const adopted = pendingTierFallback;
13510
- pendingTierFallback = null;
13511
- currentBody = adopted.body;
13512
- currentTier = adapter.requestModelTier?.(adopted.body) ?? null;
13513
- fallbackInfo = fallbackInfo === null ? adopted : { ...adopted, fromModel: fallbackInfo.fromModel };
13514
- if (tierSkipped !== null)
13515
- for (const id of tierSkipped)
13516
- tried.delete(id);
13517
- tierSkipped = null;
13518
- if (DEBUG)
13519
- log(`~~ every ${adapter.id} account is tier-limited for ${adopted.fromModel} -> downgraded to ${adopted.toModel}`);
13520
- continue;
13521
- }
13522
- if (cfg.maxWaitMs > 0 && waitableCooldownIds) {
13523
- const resumeAt = soonestCooldownUntil(pool.accounts, now, waitableCooldownIds);
13524
- if (resumeAt !== null && resumeAt <= waitDeadline) {
13525
- let remaining = resumeAt - Date.now();
13526
- while (remaining > 0) {
13527
- await sleepAbortable(remaining, signal);
13528
- remaining = resumeAt - Date.now();
13529
- }
13530
- tried.clear();
13531
- waitableCooldownIds.clear();
13532
- continue;
13533
- }
13534
- }
13535
- break;
13536
- }
13537
- const { account, degraded, sticky } = selection;
13538
- tried.add(account.id);
13539
- if (currentTier !== null && (account.modelCooldownsUntil?.[currentTier] ?? 0) > now) {
13540
- const plan = planDowngrade();
13541
- if (plan !== null) {
13542
- pendingTierFallback = plan;
13543
- tierSkipped ??= new Set;
13544
- tierSkipped.add(account.id);
13545
- if (DEBUG)
13546
- log(`~~ ${account.label} ${currentTier} tier exhausted -> skipped for ${currentTier}`);
13547
- continue;
13548
- }
13549
- }
13550
- try {
13551
- await ensureAccessToken(adapter, account, now);
13552
- const headers = new Headers(baseHeaders);
13553
- adapter.applyAuth(headers, account);
13554
- if (DEBUG)
13555
- log(`-> ${adapter.id} via ${account.label}${sticky ? " (sticky)" : ""}${degraded ? " (degraded)" : ""}`);
13556
- const res = await fetch(transformedUrl, {
13557
- ...init,
13558
- body: currentBody,
13559
- headers
13560
- });
13561
- const cls = adapter.classifyError(res.status);
13562
- if (cls === "account" || cls === "auth") {
13563
- await res.body?.cancel().catch(ignore);
13564
- if (cls === "account" && typeof currentBody === "string") {
13565
- const reactiveNow = Date.now();
13566
- const plan = adapter.planReactiveFallback?.(res, currentBody, reactiveNow, models);
13567
- if (plan) {
13568
- await recordModelCooldown(account.id, plan.tier, plan.resetAt, adapter.parseUsageHeaders(res.headers), reactiveNow);
13569
- const local = account.modelCooldownsUntil ??= {};
13570
- local[plan.tier] = Math.max(local[plan.tier] ?? 0, plan.resetAt);
13571
- pendingTierFallback = plan.fallback;
13572
- tierSkipped ??= new Set;
13573
- tierSkipped.add(account.id);
13574
- lastError = new Error(`${adapter.id} account "${account.label}" hit its "${plan.tier}" model-tier cap`);
13575
- if (DEBUG)
13576
- log(`~~ ${account.label} ${plan.tier} tier limited -> rotating (fallback ${plan.fallback.toModel} pending)`);
13577
- continue;
13578
- }
13579
- }
13580
- const ms = cls === "auth" ? AUTH_COOLDOWN_MS : ACCOUNT_COOLDOWN_MS;
13581
- await recordRotation(adapter, account.id, res, ms, Date.now());
13582
- if (cls === "account") {
13583
- waitableCooldownIds ??= new Set;
13584
- waitableCooldownIds.add(account.id);
13585
- }
13586
- lastError = new Error(`${adapter.id} account "${account.label}" returned ${res.status}`);
13587
- if (DEBUG)
13588
- log(`!! ${account.label} ${res.status} (${cls}) -> rotating`);
13589
- continue;
13590
- }
13591
- const successNow = Date.now();
13592
- const partial2 = adapter.parseUsageHeaders(res.headers);
13593
- if (partial2)
13594
- applyUsagePartial(account, partial2, successNow);
13595
- if (fallbackInfo)
13596
- hooks.onModelFallback?.(adapter.id, account, fallbackInfo.fromModel, fallbackInfo.toModel, fallbackInfo.fromTier);
13597
- hooks.onUse?.(adapter.id, account);
13598
- await recordSuccess(adapter, account.id, partial2, sessionKey, successNow, cfg.sessionTtlMs);
13599
- return adapter.transformResponse(res);
13600
- } catch (error45) {
13601
- lastError = error45;
13602
- const aborted2 = signal?.aborted === true || error45 instanceof Error && error45.name === "AbortError";
13603
- if (aborted2)
13604
- throw error45;
13605
- if (!account.disabledReason)
13606
- await applyCooldown(account.id, AUTH_COOLDOWN_MS);
13607
- if (DEBUG)
13608
- log(`!! ${account.label} threw: ${error45 instanceof Error ? error45.message : String(error45)}`);
13609
- continue;
13610
- }
13611
- }
13612
- throw lastError ?? new Error(`${adapter.id}: no usable account in the load-balancer pool`);
13432
+ function downgradeModel(body, models) {
13433
+ const setting = resolveFallbackSetting();
13434
+ if (setting.kind === "disabled")
13435
+ return null;
13436
+ let parsed;
13437
+ try {
13438
+ parsed = JSON.parse(body);
13439
+ } catch {
13440
+ return null;
13441
+ }
13442
+ const from = parsed.model;
13443
+ if (typeof from !== "string")
13444
+ return null;
13445
+ const fromFamily = modelFamily(from);
13446
+ if (fromFamily === null)
13447
+ return null;
13448
+ const target = setting.kind === "pinned" ? setting.model : ladderTargetForFamily(fromFamily, models) ?? DEFAULT_OPUS_FALLBACK_MODEL;
13449
+ if (modelFamily(target) === fromFamily)
13450
+ return null;
13451
+ parsed.model = target;
13452
+ return {
13453
+ body: JSON.stringify(parsed),
13454
+ fromModel: from,
13455
+ toModel: target,
13456
+ fromTier: fromFamily
13613
13457
  };
13614
13458
  }
13615
-
13616
- // src/status.ts
13617
- var PROVIDER_NAMES = {
13618
- anthropic: "Claude",
13619
- openai: "Codex"
13620
- };
13621
- function compareAscii(a, b) {
13622
- return a < b ? -1 : a > b ? 1 : 0;
13623
- }
13624
- function byProviderId(a, b) {
13625
- return compareAscii(a[0], b[0]);
13459
+ function parseTierReset(res, now) {
13460
+ const ms = secondsToMs(Number(res.headers.get(UNIFIED_RESET_HEADER)));
13461
+ if (ms > now && ms - now <= TIER_RESET_MAX_MS)
13462
+ return ms;
13463
+ return now + TIER_RESET_FALLBACK_MS;
13626
13464
  }
13627
- function isExpired(window, now) {
13628
- return !!window && window.resetAt > 0 && window.resetAt <= now;
13465
+ function planReactiveFallback(res, body, now, models) {
13466
+ const claim = res.headers.get(REPRESENTATIVE_CLAIM_HEADER);
13467
+ if (!claim || !MODEL_TIER_CLAIM_RE.test(claim))
13468
+ return null;
13469
+ const fallback = downgradeModel(body, models);
13470
+ if (!fallback)
13471
+ return null;
13472
+ return {
13473
+ tier: fallback.fromTier,
13474
+ resetAt: parseTierReset(res, now),
13475
+ fallback
13476
+ };
13629
13477
  }
13630
- function toStatus(account, now, current, available, rank) {
13631
- const weekly = account.usage.weekly;
13632
- const weeklyExpired = isExpired(weekly, now);
13478
+
13479
+ // src/providers/oauth-callback.ts
13480
+ function toTokenSet(json2, previousRefresh) {
13633
13481
  return {
13634
- id: account.id,
13635
- label: account.label,
13636
- weeklyUtil: displayUtil(weekly, now),
13637
- hourlyUtil: displayUtil(account.usage.hourly, now),
13638
- weeklyResetAt: weekly && !weeklyExpired ? weekly.resetAt : 0,
13639
- available,
13640
- cooldownUntil: account.cooldownUntil,
13641
- modelCooldownsUntil: account.modelCooldownsUntil ?? {},
13642
- disabledReason: account.disabledReason,
13643
- current,
13644
- rank
13482
+ access: json2.access_token,
13483
+ refresh: json2.refresh_token || previousRefresh,
13484
+ expires: Date.now() + json2.expires_in * 1000
13645
13485
  };
13646
13486
  }
13647
- function buildStatus(pool, now, cfg = DEFAULT_CONFIG) {
13648
- const byProvider = new Map;
13649
- for (const a of pool.accounts) {
13650
- const list = byProvider.get(a.providerID);
13651
- if (list)
13652
- list.push(a);
13653
- else
13654
- byProvider.set(a.providerID, [a]);
13487
+ async function readTokenResponse(res) {
13488
+ const json2 = await res.json().catch(() => null);
13489
+ if (!json2 || typeof json2.access_token !== "string" || json2.access_token === "" || !Number.isFinite(json2.expires_in) || json2.expires_in <= 0 || !Number.isFinite(json2.expires_in * 1000) || json2.refresh_token !== undefined && json2.refresh_token !== null && typeof json2.refresh_token !== "string")
13490
+ return null;
13491
+ return json2;
13492
+ }
13493
+ async function readExchangeResponse(res) {
13494
+ if (!res.ok) {
13495
+ await res.body?.cancel().catch(ignore);
13496
+ return null;
13655
13497
  }
13656
- const sorted = [...byProvider.entries()].sort(byProviderId);
13657
- return sorted.map(([providerID, providerAccounts]) => {
13658
- const currentAccountId = pool.lastSelected[providerID] ?? null;
13659
- const ranked = providerAccounts.map((a) => {
13660
- const available = isAvailable(a, cfg, now);
13661
- return {
13662
- a,
13663
- available,
13664
- score: available ? scoreAccount(a, cfg, now) : Number.NEGATIVE_INFINITY,
13665
- weeklyUtil: a.usage.weekly?.utilization ?? 0
13666
- };
13667
- }).sort(compareRanked);
13668
- const accounts = ranked.map(({ a, available }, i) => toStatus(a, now, a.id === currentAccountId, available, i + 1));
13669
- return { providerID, accounts };
13670
- });
13498
+ return readTokenResponse(res);
13671
13499
  }
13672
- async function readStatus(now = Date.now(), cfg = loadConfig()) {
13673
- return buildStatus(await readPool(), now, cfg);
13500
+ async function readRefreshResponse(res) {
13501
+ if (!res.ok)
13502
+ throw new Error(`Token refresh failed: ${res.status} — ${await res.text()}`);
13503
+ const json2 = await readTokenResponse(res);
13504
+ if (!json2)
13505
+ throw new Error(`Token refresh failed: ${res.status} — malformed token response body`);
13506
+ return json2;
13674
13507
  }
13675
- function pct(u) {
13676
- return typeof u === "number" ? `${Math.round(u * 100)}%` : "-";
13508
+ function generateState() {
13509
+ return crypto.randomUUID().replaceAll("-", "");
13677
13510
  }
13678
- function displayWidth(s) {
13679
- let width = 0;
13680
- for (const ch of s) {
13681
- const cp = ch.codePointAt(0) ?? 0;
13682
- const wide = cp >= 4352 && cp <= 4447 || cp >= 11904 && cp <= 42191 || cp >= 44032 && cp <= 55203 || cp >= 63744 && cp <= 64255 || cp >= 65280 && cp <= 65376 || cp >= 65504 && cp <= 65510 || cp >= 131072 && cp <= 262141;
13683
- width += wide ? 2 : 1;
13684
- }
13685
- return width;
13686
- }
13687
- function padDisplayEnd(s, width) {
13688
- const pad = width - displayWidth(s);
13689
- return pad > 0 ? s + " ".repeat(pad) : s;
13690
- }
13691
- function relTime(at, now) {
13692
- if (at <= now)
13693
- return "-";
13694
- const mins = Math.max(1, Math.round((at - now) / 60000));
13695
- if (mins < 60)
13696
- return `${mins}m`;
13697
- const hrs = Math.floor(mins / 60);
13698
- if (hrs < 24)
13699
- return `${hrs}h${mins % 60}m`;
13700
- return `${Math.floor(hrs / 24)}d${hrs % 24}h`;
13701
- }
13702
- function stateOf(a, now) {
13703
- if (a.disabledReason)
13704
- return "re-login";
13705
- if (a.cooldownUntil > now)
13706
- return `cooldown ${relTime(a.cooldownUntil, now)}`;
13707
- if (!a.available)
13708
- return "exhausted";
13709
- const base = a.current ? "in use" : "ready";
13710
- const tiers = Object.entries(a.modelCooldownsUntil).filter(([, until]) => until > now).sort(([x], [y]) => compareAscii(x, y)).map(([tier, until]) => `${tier} ${relTime(until, now)}`);
13711
- if (tiers.length > 0)
13712
- return `${base} · ${tiers.join(" · ")}`;
13713
- return base;
13714
- }
13715
- function providerName(providerID) {
13716
- return PROVIDER_NAMES[providerID] ?? providerID;
13717
- }
13718
- function renderStatus(providers, now = Date.now()) {
13719
- if (providers.length === 0)
13720
- return "auth-load-balancer: no accounts registered.";
13721
- const lines = [];
13722
- for (const p of providers) {
13723
- const current = p.accounts.find((a) => a.current);
13724
- lines.push(`${providerName(p.providerID)} — in use: ${current ? current.label : "(none yet)"}`);
13725
- const width = p.accounts.reduce((w, a) => Math.max(w, displayWidth(a.label)), 16);
13726
- lines.push(` # ${"account".padEnd(width + 3)}weekly 5h resets state`);
13727
- for (const a of p.accounts) {
13728
- const mark = a.current ? "▶" : " ";
13729
- lines.push(` ${String(a.rank).padEnd(2)} ${mark} ${padDisplayEnd(a.label, width)} ${pct(a.weeklyUtil).padStart(5)} ${pct(a.hourlyUtil).padStart(5)} ${relTime(a.weeklyResetAt, now).padStart(7)} ${stateOf(a, now)}`);
13511
+ function parseCallbackInput(input, options = {}) {
13512
+ const trimmed = input.trim();
13513
+ let isUrl = false;
13514
+ try {
13515
+ const url2 = new URL(trimmed);
13516
+ isUrl = true;
13517
+ const code2 = url2.searchParams.get("code");
13518
+ const state2 = url2.searchParams.get("state");
13519
+ if (code2 && state2)
13520
+ return { code: code2, state: state2 };
13521
+ } catch {}
13522
+ if (options.allowHashFormat && !isUrl) {
13523
+ const hashSplits = trimmed.split("#");
13524
+ if (hashSplits.length === 2 && hashSplits[0] && hashSplits[1]) {
13525
+ return { code: hashSplits[0], state: hashSplits[1] };
13730
13526
  }
13731
- lines.push("");
13732
13527
  }
13733
- return lines.join(`
13734
- `).trimEnd();
13528
+ const params = new URLSearchParams(trimmed);
13529
+ const code = params.get("code");
13530
+ const state = params.get("state");
13531
+ if (code && state)
13532
+ return { code, state };
13533
+ return null;
13735
13534
  }
13736
13535
 
13737
- // src/notify.ts
13738
- async function postToast(client, body) {
13739
- await client.tui.showToast({ body }).catch(ignore);
13740
- }
13741
- var lastToasted = new Map;
13742
- async function notifyOnSwitch(client, providerID, account) {
13743
- if (lastToasted.get(providerID) === account.id)
13744
- return;
13745
- lastToasted.set(providerID, account.id);
13746
- const now = Date.now();
13747
- const message = `▶ ${account.label} · weekly ${pct(displayUtil(account.usage.weekly, now))} · 5h ${pct(displayUtil(account.usage.hourly, now))}`;
13748
- await postToast(client, {
13749
- title: `${providerName(providerID)} account`,
13750
- message,
13751
- variant: "info",
13752
- duration: 4000
13753
- });
13536
+ // src/providers/pkce.ts
13537
+ function base64UrlEncode(bytes) {
13538
+ return Buffer.from(bytes).toString("base64url");
13754
13539
  }
13755
- var lastFallbackToasted = new Map;
13756
- var LAST_FALLBACK_TOASTED_MAX = 256;
13757
- async function notifyModelFallback(client, providerID, account, fromModel, toModel, fromTier) {
13758
- const key = `${providerID}:${account.id}`;
13759
- const now = Date.now();
13760
- const tiers = account.modelCooldownsUntil;
13761
- let latestWindow = 0;
13762
- const triggeringWindow = fromTier ? tiers?.[fromTier] : undefined;
13763
- if (triggeringWindow !== undefined && triggeringWindow > now) {
13764
- latestWindow = triggeringWindow;
13765
- } else if (tiers) {
13766
- for (const until of Object.values(tiers)) {
13767
- if (until > now && until > latestWindow)
13768
- latestWindow = until;
13769
- }
13770
- }
13771
- const window = `${fromModel}@${latestWindow}`;
13772
- if (lastFallbackToasted.get(key) === window)
13773
- return;
13774
- setBounded(lastFallbackToasted, key, window, LAST_FALLBACK_TOASTED_MAX);
13775
- await postToast(client, {
13776
- title: `${providerName(providerID)} model fallback`,
13777
- message: `▶ ${account.label} · ${fromModel} → ${toModel} (model-tier weekly limit)`,
13778
- variant: "warning",
13779
- duration: 6000
13780
- });
13540
+ async function generatePKCE() {
13541
+ const buf = new Uint8Array(64);
13542
+ crypto.getRandomValues(buf);
13543
+ const verifier = base64UrlEncode(buf);
13544
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
13545
+ return {
13546
+ verifier,
13547
+ challenge: base64UrlEncode(new Uint8Array(digest))
13548
+ };
13781
13549
  }
13782
13550
 
13783
- // src/prime.ts
13784
- async function primeInUse(providerID, now) {
13785
- const snapshot = await readPool();
13786
- if (!snapshot.accounts.some((a) => a.providerID === providerID))
13787
- return;
13788
- const cfg = loadConfig();
13789
- const preview = selectAccount(snapshot.accounts, providerID, now, cfg);
13790
- if (!preview || snapshot.lastSelected[providerID] === preview.account.id)
13791
- return;
13792
- await mutatePool((pool) => {
13793
- const selection = selectAccount(pool.accounts, providerID, now, cfg);
13794
- if (selection)
13795
- pool.lastSelected[providerID] = selection.account.id;
13551
+ // src/providers/anthropic/oauth.ts
13552
+ async function postToken(body) {
13553
+ return fetch(TOKEN_URL, {
13554
+ method: "POST",
13555
+ headers: {
13556
+ "Content-Type": "application/json",
13557
+ Accept: "application/json, text/plain, */*",
13558
+ "User-Agent": "axios/1.13.6"
13559
+ },
13560
+ body: JSON.stringify(body),
13561
+ signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS)
13796
13562
  });
13797
13563
  }
13798
-
13799
- // src/providers/types.ts
13800
- function classifyHttpStatus(status) {
13801
- if (status === 429 || status === 402)
13802
- return "account";
13803
- if (status === 401 || status === 403)
13804
- return "auth";
13805
- if (status >= 500)
13806
- return "service";
13807
- return "ok";
13564
+ async function authorize() {
13565
+ const pkce = await generatePKCE();
13566
+ const state = generateState();
13567
+ const url2 = new URL(AUTHORIZE_URL);
13568
+ url2.searchParams.set("code", "true");
13569
+ url2.searchParams.set("client_id", CLIENT_ID);
13570
+ url2.searchParams.set("response_type", "code");
13571
+ url2.searchParams.set("redirect_uri", CODE_CALLBACK_URL);
13572
+ url2.searchParams.set("scope", OAUTH_SCOPES.join(" "));
13573
+ url2.searchParams.set("code_challenge", pkce.challenge);
13574
+ url2.searchParams.set("code_challenge_method", "S256");
13575
+ url2.searchParams.set("state", state);
13576
+ return {
13577
+ url: url2.toString(),
13578
+ redirectUri: CODE_CALLBACK_URL,
13579
+ state,
13580
+ verifier: pkce.verifier
13581
+ };
13808
13582
  }
13809
- function urlFromInput(input) {
13810
- try {
13811
- return new URL(input instanceof Request ? input.url : input);
13812
- } catch {
13583
+ async function exchange(input, verifier, redirectUri, expectedState) {
13584
+ const callback = parseCallbackInput(input, { allowHashFormat: true });
13585
+ if (!callback)
13813
13586
  return null;
13814
- }
13815
- }
13816
-
13817
- // src/providers/anthropic/fallback.ts
13818
- var TIER_RESET_FALLBACK_MS = 60 * 60 * 1000;
13819
- var TIER_RESET_MAX_MS = MAX_QUOTA_RESET_BOUND_MS;
13820
- var ALPHA_SEGMENT_RE = /^[a-z]+$/;
13821
- var DIGIT_SEGMENT_RE = /^\d+$/;
13822
- var DATE_SEGMENT_RE = /^\d{8}$/;
13823
- function modelFamily(model) {
13824
- const lower = model.toLowerCase();
13825
- if (!lower.startsWith("claude"))
13587
+ if (expectedState && callback.state !== expectedState)
13826
13588
  return null;
13827
- for (const segment of lower.split("-")) {
13828
- if (segment !== "claude" && ALPHA_SEGMENT_RE.test(segment))
13829
- return segment;
13830
- }
13831
- return null;
13832
- }
13833
- function requestModelTier(body) {
13834
- let parsed;
13835
- try {
13836
- parsed = JSON.parse(body);
13837
- } catch {
13589
+ const result = await postToken({
13590
+ code: callback.code,
13591
+ state: callback.state,
13592
+ grant_type: "authorization_code",
13593
+ client_id: CLIENT_ID,
13594
+ redirect_uri: redirectUri,
13595
+ code_verifier: verifier
13596
+ });
13597
+ const json2 = await readExchangeResponse(result);
13598
+ if (!json2)
13838
13599
  return null;
13839
- }
13840
- const model = parsed.model;
13841
- return typeof model === "string" ? modelFamily(model) : null;
13600
+ return toTokenSet(json2, "");
13842
13601
  }
13843
- function versionVector(model) {
13844
- const version2 = [];
13845
- let date5 = -1;
13846
- for (const segment of model.split("-")) {
13847
- if (DATE_SEGMENT_RE.test(segment))
13848
- date5 = Number(segment);
13849
- else if (DIGIT_SEGMENT_RE.test(segment))
13850
- version2.push(Number(segment));
13851
- }
13852
- return { version: version2, date: date5 };
13602
+ async function refresh(refreshToken) {
13603
+ const response = await postToken({
13604
+ grant_type: "refresh_token",
13605
+ refresh_token: refreshToken,
13606
+ client_id: CLIENT_ID
13607
+ });
13608
+ const json2 = await readRefreshResponse(response);
13609
+ return toTokenSet(json2, refreshToken);
13853
13610
  }
13854
- function compareVersions(a, b) {
13855
- const len = Math.max(a.version.length, b.version.length);
13856
- for (let i = 0;i < len; i++) {
13857
- const d = (a.version[i] ?? -1) - (b.version[i] ?? -1);
13858
- if (d !== 0)
13859
- return d;
13611
+
13612
+ // src/providers/anthropic/cch.ts
13613
+ import { createHash as createHash2 } from "node:crypto";
13614
+ function messageText({ content }) {
13615
+ if (typeof content === "string")
13616
+ return content;
13617
+ if (Array.isArray(content)) {
13618
+ for (const block of content) {
13619
+ if (block?.type === "text")
13620
+ return typeof block.text === "string" ? block.text : "";
13621
+ }
13860
13622
  }
13861
- return a.date - b.date;
13862
- }
13863
- function bestOfFamily(models, family) {
13864
- let best = null;
13865
- let bestVersion = {
13866
- version: [],
13867
- date: -1
13868
- };
13869
- for (const model of models) {
13870
- if (modelFamily(model) !== family)
13871
- continue;
13872
- const version2 = versionVector(model);
13873
- if (best === null || compareVersions(version2, bestVersion) > 0) {
13874
- best = model;
13875
- bestVersion = version2;
13876
- }
13877
- }
13878
- return best;
13879
- }
13880
- var computeFamilyOrder = memoOne((raw) => {
13881
- const parsed = (raw ?? "").split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
13882
- return parsed.length > 0 ? parsed : DEFAULT_FAMILY_ORDER;
13883
- });
13884
- function resolveFamilyOrder() {
13885
- return computeFamilyOrder(process.env.OPENCODE_AUTH_LB_ANTHROPIC_FAMILY_ORDER);
13886
- }
13887
- var computeFallbackSetting = memoOne((raw) => {
13888
- const trimmed = raw?.trim();
13889
- return raw === undefined ? { kind: "ladder" } : trimmed ? { kind: "pinned", model: trimmed } : { kind: "disabled" };
13890
- });
13891
- function resolveFallbackSetting() {
13892
- return computeFallbackSetting(process.env.OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL);
13893
- }
13894
- function ladderTargetForFamily(fromFamily, models) {
13895
- const order = resolveFamilyOrder();
13896
- const start = order.indexOf(fromFamily) + 1;
13897
- for (let i = start;i < order.length; i++) {
13898
- const family = order[i];
13899
- if (family === undefined)
13900
- continue;
13901
- const best = bestOfFamily(models, family);
13902
- if (best !== null)
13903
- return best;
13904
- }
13905
- return null;
13906
- }
13907
- function downgradeModel(body, models) {
13908
- const setting = resolveFallbackSetting();
13909
- if (setting.kind === "disabled")
13910
- return null;
13911
- let parsed;
13912
- try {
13913
- parsed = JSON.parse(body);
13914
- } catch {
13915
- return null;
13916
- }
13917
- const from = parsed.model;
13918
- if (typeof from !== "string")
13919
- return null;
13920
- const fromFamily = modelFamily(from);
13921
- if (fromFamily === null)
13922
- return null;
13923
- const target = setting.kind === "pinned" ? setting.model : ladderTargetForFamily(fromFamily, models) ?? DEFAULT_OPUS_FALLBACK_MODEL;
13924
- if (modelFamily(target) === fromFamily)
13925
- return null;
13926
- parsed.model = target;
13927
- return {
13928
- body: JSON.stringify(parsed),
13929
- fromModel: from,
13930
- toModel: target,
13931
- fromTier: fromFamily
13932
- };
13933
- }
13934
- function parseTierReset(res, now) {
13935
- const ms = secondsToMs(Number(res.headers.get(UNIFIED_RESET_HEADER)));
13936
- if (ms > now && ms - now <= TIER_RESET_MAX_MS)
13937
- return ms;
13938
- return now + TIER_RESET_FALLBACK_MS;
13939
- }
13940
- function planReactiveFallback(res, body, now, models) {
13941
- const claim = res.headers.get(REPRESENTATIVE_CLAIM_HEADER);
13942
- const tier = claim ? MODEL_TIER_CLAIM_RE.exec(claim)?.[1] : undefined;
13943
- if (!tier)
13944
- return null;
13945
- const fallback = downgradeModel(body, models);
13946
- if (!fallback)
13947
- return null;
13948
- return { tier, resetAt: parseTierReset(res, now), fallback };
13949
- }
13950
-
13951
- // src/providers/oauth-callback.ts
13952
- function toTokenSet(json2, previousRefresh) {
13953
- return {
13954
- access: json2.access_token,
13955
- refresh: json2.refresh_token || previousRefresh,
13956
- expires: Date.now() + json2.expires_in * 1000
13957
- };
13958
- }
13959
- async function readTokenResponse(res) {
13960
- const json2 = await res.json().catch(() => null);
13961
- if (!json2 || typeof json2.access_token !== "string" || json2.access_token === "" || !Number.isFinite(json2.expires_in) || json2.expires_in <= 0 || !Number.isFinite(json2.expires_in * 1000) || json2.refresh_token !== undefined && json2.refresh_token !== null && typeof json2.refresh_token !== "string")
13962
- return null;
13963
- return json2;
13964
- }
13965
- async function readExchangeResponse(res) {
13966
- if (!res.ok) {
13967
- await res.body?.cancel().catch(ignore);
13968
- return null;
13969
- }
13970
- return readTokenResponse(res);
13971
- }
13972
- async function readRefreshResponse(res) {
13973
- if (!res.ok)
13974
- throw new Error(`Token refresh failed: ${res.status} — ${await res.text()}`);
13975
- const json2 = await readTokenResponse(res);
13976
- if (!json2)
13977
- throw new Error(`Token refresh failed: ${res.status} — malformed token response body`);
13978
- return json2;
13979
- }
13980
- function generateState() {
13981
- return crypto.randomUUID().replaceAll("-", "");
13982
- }
13983
- function parseCallbackInput(input, options = {}) {
13984
- const trimmed = input.trim();
13985
- let isUrl = false;
13986
- try {
13987
- const url2 = new URL(trimmed);
13988
- isUrl = true;
13989
- const code2 = url2.searchParams.get("code");
13990
- const state2 = url2.searchParams.get("state");
13991
- if (code2 && state2)
13992
- return { code: code2, state: state2 };
13993
- } catch {}
13994
- if (options.allowHashFormat && !isUrl) {
13995
- const hashSplits = trimmed.split("#");
13996
- if (hashSplits.length === 2 && hashSplits[0] && hashSplits[1]) {
13997
- return { code: hashSplits[0], state: hashSplits[1] };
13998
- }
13999
- }
14000
- const params = new URLSearchParams(trimmed);
14001
- const code = params.get("code");
14002
- const state = params.get("state");
14003
- if (code && state)
14004
- return { code, state };
14005
- return null;
14006
- }
14007
-
14008
- // src/providers/pkce.ts
14009
- function base64UrlEncode(bytes) {
14010
- return Buffer.from(bytes).toString("base64url");
14011
- }
14012
- async function generatePKCE() {
14013
- const buf = new Uint8Array(64);
14014
- crypto.getRandomValues(buf);
14015
- const verifier = base64UrlEncode(buf);
14016
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
14017
- return {
14018
- verifier,
14019
- challenge: base64UrlEncode(new Uint8Array(digest))
14020
- };
14021
- }
14022
-
14023
- // src/providers/anthropic/oauth.ts
14024
- async function postToken(body) {
14025
- return fetch(TOKEN_URL, {
14026
- method: "POST",
14027
- headers: {
14028
- "Content-Type": "application/json",
14029
- Accept: "application/json, text/plain, */*",
14030
- "User-Agent": "axios/1.13.6"
14031
- },
14032
- body: JSON.stringify(body),
14033
- signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS)
14034
- });
14035
- }
14036
- async function authorize() {
14037
- const pkce = await generatePKCE();
14038
- const state = generateState();
14039
- const url2 = new URL(AUTHORIZE_URL);
14040
- url2.searchParams.set("code", "true");
14041
- url2.searchParams.set("client_id", CLIENT_ID);
14042
- url2.searchParams.set("response_type", "code");
14043
- url2.searchParams.set("redirect_uri", CODE_CALLBACK_URL);
14044
- url2.searchParams.set("scope", OAUTH_SCOPES.join(" "));
14045
- url2.searchParams.set("code_challenge", pkce.challenge);
14046
- url2.searchParams.set("code_challenge_method", "S256");
14047
- url2.searchParams.set("state", state);
14048
- return {
14049
- url: url2.toString(),
14050
- redirectUri: CODE_CALLBACK_URL,
14051
- state,
14052
- verifier: pkce.verifier
14053
- };
14054
- }
14055
- async function exchange(input, verifier, redirectUri, expectedState) {
14056
- const callback = parseCallbackInput(input, { allowHashFormat: true });
14057
- if (!callback)
14058
- return null;
14059
- if (expectedState && callback.state !== expectedState)
14060
- return null;
14061
- const result = await postToken({
14062
- code: callback.code,
14063
- state: callback.state,
14064
- grant_type: "authorization_code",
14065
- client_id: CLIENT_ID,
14066
- redirect_uri: redirectUri,
14067
- code_verifier: verifier
14068
- });
14069
- const json2 = await readExchangeResponse(result);
14070
- if (!json2)
14071
- return null;
14072
- return toTokenSet(json2, "");
14073
- }
14074
- async function refresh(refreshToken) {
14075
- const response = await postToken({
14076
- grant_type: "refresh_token",
14077
- refresh_token: refreshToken,
14078
- client_id: CLIENT_ID
14079
- });
14080
- const json2 = await readRefreshResponse(response);
14081
- return toTokenSet(json2, refreshToken);
14082
- }
14083
-
14084
- // src/providers/anthropic/cch.ts
14085
- import { createHash as createHash2 } from "node:crypto";
14086
- function messageText({ content }) {
14087
- if (typeof content === "string")
14088
- return content;
14089
- if (Array.isArray(content)) {
14090
- for (const block of content) {
14091
- if (block?.type === "text")
14092
- return typeof block.text === "string" ? block.text : "";
14093
- }
14094
- }
14095
- return "";
13623
+ return "";
14096
13624
  }
14097
13625
  function computeCCH(messageText2) {
14098
13626
  return createHash2("sha256").update(messageText2).digest("hex").slice(0, 5);
@@ -14572,131 +14100,681 @@ async function exchange2(input, verifier, redirectUri, expectedState) {
14572
14100
  return null;
14573
14101
  return toTokenSet2(json2, "");
14574
14102
  }
14575
- async function refresh2(refreshToken) {
14576
- const body = new URLSearchParams({
14577
- grant_type: "refresh_token",
14578
- refresh_token: refreshToken,
14579
- client_id: CLIENT_ID2
14103
+ async function refresh2(refreshToken) {
14104
+ const body = new URLSearchParams({
14105
+ grant_type: "refresh_token",
14106
+ refresh_token: refreshToken,
14107
+ client_id: CLIENT_ID2
14108
+ });
14109
+ const json2 = await readRefreshResponse(await postToken2(body));
14110
+ return toTokenSet2(json2, refreshToken);
14111
+ }
14112
+
14113
+ // src/providers/openai/transform.ts
14114
+ function applyAuth(headers, account) {
14115
+ headers.delete("x-api-key");
14116
+ headers.set("authorization", `Bearer ${account.access}`);
14117
+ const accountId = resolveAccountId(account);
14118
+ if (accountId)
14119
+ headers.set("chatgpt-account-id", accountId);
14120
+ headers.set("openai-beta", OPENAI_BETA);
14121
+ headers.set("originator", ORIGINATOR);
14122
+ headers.set("user-agent", USER_AGENT2);
14123
+ headers.set("session-id", SESSION_ID);
14124
+ }
14125
+ function rewriteUrl2(input) {
14126
+ const url2 = urlFromInput(input);
14127
+ if (!url2)
14128
+ return input;
14129
+ if (!url2.pathname.includes("/responses"))
14130
+ return input;
14131
+ if (url2.href === CODEX_RESPONSES_URL)
14132
+ return input;
14133
+ return input instanceof Request ? new Request(CODEX_RESPONSES_URL, input) : CODEX_RESPONSES_URL;
14134
+ }
14135
+ function applyInstructions(obj) {
14136
+ let instructions = typeof obj.instructions === "string" ? obj.instructions : "";
14137
+ if (Array.isArray(obj.input)) {
14138
+ const input = obj.input;
14139
+ const systemTexts = [];
14140
+ let remaining = null;
14141
+ let i = 0;
14142
+ for (const raw of input) {
14143
+ if (raw && raw.type === "message" && raw.role === "system") {
14144
+ if (remaining === null)
14145
+ remaining = input.slice(0, i);
14146
+ const text = joinBlockTexts(raw.content, `
14147
+ `);
14148
+ if (text)
14149
+ systemTexts.push(text);
14150
+ } else if (remaining !== null) {
14151
+ remaining.push(raw);
14152
+ }
14153
+ i++;
14154
+ }
14155
+ if (systemTexts.length > 0) {
14156
+ instructions = [instructions, ...systemTexts].filter(Boolean).join(`
14157
+
14158
+ `);
14159
+ }
14160
+ if (remaining !== null)
14161
+ obj.input = remaining;
14162
+ }
14163
+ obj.instructions = instructions.trim() || "You are a helpful assistant.";
14164
+ }
14165
+ function rewriteRequestBody2(body) {
14166
+ try {
14167
+ const obj = JSON.parse(body);
14168
+ obj.store = false;
14169
+ const include = Array.isArray(obj.include) ? obj.include : [];
14170
+ obj.include = include.includes("reasoning.encrypted_content") && new Set(include).size === include.length ? include : [...new Set([...include, "reasoning.encrypted_content"])];
14171
+ applyInstructions(obj);
14172
+ delete obj.max_output_tokens;
14173
+ return JSON.stringify(obj);
14174
+ } catch {
14175
+ return body;
14176
+ }
14177
+ }
14178
+
14179
+ // src/providers/openai/usage.ts
14180
+ var WEEK_MINUTES = 7 * 24 * 60;
14181
+ function isWeeklyMinutes(minutes) {
14182
+ return minutes >= WEEK_MINUTES * 0.95 && minutes <= WEEK_MINUTES * 1.05;
14183
+ }
14184
+ var PRIMARY_WINDOW_MINUTES_HEADER = "x-codex-primary-window-minutes";
14185
+ var HEADER_SPEC2 = {
14186
+ hourlyUtil: "x-codex-primary-used-percent",
14187
+ hourlyReset: "x-codex-primary-reset-at",
14188
+ weeklyUtil: "x-codex-secondary-used-percent",
14189
+ weeklyReset: "x-codex-secondary-reset-at",
14190
+ divisor: 100
14191
+ };
14192
+ var WEEKLY_PRIMARY_HEADER_SPEC = {
14193
+ hourlyUtil: HEADER_SPEC2.weeklyUtil,
14194
+ hourlyReset: HEADER_SPEC2.weeklyReset,
14195
+ weeklyUtil: HEADER_SPEC2.hourlyUtil,
14196
+ weeklyReset: HEADER_SPEC2.hourlyReset,
14197
+ divisor: HEADER_SPEC2.divisor
14198
+ };
14199
+ function parseUsageHeaders2(headers) {
14200
+ const primaryWindowMinutes = Number(headers.get(PRIMARY_WINDOW_MINUTES_HEADER));
14201
+ const spec = isWeeklyMinutes(primaryWindowMinutes) ? WEEKLY_PRIMARY_HEADER_SPEC : HEADER_SPEC2;
14202
+ return parseWindowPairHeaders(headers, spec);
14203
+ }
14204
+ function endpointWindow2(w) {
14205
+ return endpointWindowFrom(w, (win) => win.used_percent, 100, (win) => secondsToMs(Number(win.reset_at ?? 0)));
14206
+ }
14207
+ async function fetchUsage2(account, now) {
14208
+ const headers = {
14209
+ authorization: `Bearer ${account.access}`,
14210
+ "user-agent": USAGE_USER_AGENT2,
14211
+ accept: "application/json"
14212
+ };
14213
+ const accountId = resolveAccountId(account);
14214
+ if (accountId)
14215
+ headers["chatgpt-account-id"] = accountId;
14216
+ const json2 = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
14217
+ const rateLimit = json2?.rate_limit;
14218
+ if (!isPlainObject2(rateLimit))
14219
+ return null;
14220
+ const { primary_window: primary, secondary_window: secondary } = rateLimit;
14221
+ const primaryIsWeekly = isWeeklyMinutes(Math.ceil(Number(primary?.limit_window_seconds) / 60));
14222
+ return {
14223
+ hourly: endpointWindow2(primaryIsWeekly ? secondary : primary),
14224
+ weekly: endpointWindow2(primaryIsWeekly ? primary : secondary),
14225
+ capturedAt: now
14226
+ };
14227
+ }
14228
+
14229
+ // src/providers/openai/adapter.ts
14230
+ var openaiAdapter = {
14231
+ id: PROVIDER_ID2,
14232
+ authorize: authorize2,
14233
+ exchange: exchange2,
14234
+ refresh: refresh2,
14235
+ applyAuth,
14236
+ transformUrl: rewriteUrl2,
14237
+ transformBody: rewriteRequestBody2,
14238
+ transformResponse(response) {
14239
+ return response;
14240
+ },
14241
+ parseUsageHeaders: parseUsageHeaders2,
14242
+ fetchUsage: fetchUsage2,
14243
+ classifyError: classifyHttpStatus
14244
+ };
14245
+
14246
+ // src/providers/registry.ts
14247
+ var ADAPTERS = [
14248
+ anthropicAdapter,
14249
+ openaiAdapter
14250
+ ];
14251
+ function adapterFor(adapters, providerID) {
14252
+ for (const adapter of adapters)
14253
+ if (adapter.id === providerID)
14254
+ return adapter;
14255
+ return;
14256
+ }
14257
+
14258
+ // src/usage-refresh.ts
14259
+ var SEED_TTL_MS = 5 * 60 * 1000;
14260
+ var MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
14261
+ var lastPoll = new Map;
14262
+ async function refreshUsage(adapters, now, poolSnapshot) {
14263
+ const pool = poolSnapshot ?? await readPool();
14264
+ const aliveIds = lastPoll.size > 0 ? new Set : undefined;
14265
+ let stale;
14266
+ for (const account of pool.accounts) {
14267
+ aliveIds?.add(account.id);
14268
+ if (account.disabledReason)
14269
+ continue;
14270
+ if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt <= SEED_TTL_MS)
14271
+ continue;
14272
+ const adapter = adapterFor(adapters, account.providerID);
14273
+ if (!adapter)
14274
+ continue;
14275
+ if ((lastPoll.get(account.id) ?? 0) > now - SEED_TTL_MS)
14276
+ continue;
14277
+ lastPoll.set(account.id, now);
14278
+ stale ??= [];
14279
+ stale.push({ adapter, account });
14280
+ }
14281
+ if (aliveIds) {
14282
+ for (const id of lastPoll.keys())
14283
+ if (!aliveIds.has(id))
14284
+ lastPoll.delete(id);
14285
+ }
14286
+ if (!stale)
14287
+ return;
14288
+ const scoreCfg = loadScoreConfig();
14289
+ await Promise.all(stale.map(async ({ adapter, account }) => {
14290
+ try {
14291
+ await ensureAccessToken(adapter, account, now);
14292
+ const snapshot = await adapter.fetchUsage(account, now);
14293
+ if (snapshot) {
14294
+ await mutatePool((p) => {
14295
+ const stored = findAccount(p, account.id);
14296
+ if (stored) {
14297
+ stored.usage = {
14298
+ hourly: snapshot.hourly ?? stored.usage.hourly,
14299
+ weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
14300
+ capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
14301
+ };
14302
+ if (snapshot.weekly !== null && stored.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(stored, scoreCfg, now))
14303
+ stored.cooldownUntil = 0;
14304
+ }
14305
+ });
14306
+ }
14307
+ } catch {}
14308
+ }));
14309
+ }
14310
+ function refreshUsageInBackground(adapter, now, poolSnapshot) {
14311
+ return refreshUsage([adapter], now, poolSnapshot);
14312
+ }
14313
+ function refreshAllUsageInBackground(now, poolSnapshot) {
14314
+ return refreshUsage(ADAPTERS, now, poolSnapshot);
14315
+ }
14316
+
14317
+ // src/fetch.ts
14318
+ var ACCOUNT_COOLDOWN_MS = 5 * 60 * 1000;
14319
+ var AUTH_COOLDOWN_MS = 2 * 60 * 1000;
14320
+ var DEBUG = process.env.OPENCODE_AUTH_LB_DEBUG === "1" || process.env.OPENCODE_AUTH_LB_DEBUG === "true";
14321
+ function log(message) {
14322
+ if (DEBUG)
14323
+ console.error(`[auth-lb] ${message}`);
14324
+ }
14325
+ async function bestEffort(what, op) {
14326
+ try {
14327
+ await op();
14328
+ } catch (error45) {
14329
+ if (error45 instanceof LockTimeoutError || error45 instanceof PoolReadError || error45 instanceof PoolWriteError) {
14330
+ log(`bookkeeping skipped (${what}): ${error45.message}`);
14331
+ return;
14332
+ }
14333
+ throw error45;
14334
+ }
14335
+ }
14336
+ function applyUsagePartial(account, partial2, now) {
14337
+ if (partial2.hourly !== undefined)
14338
+ account.usage.hourly = partial2.hourly;
14339
+ if (partial2.weekly !== undefined) {
14340
+ account.usage.weekly = preserveWeeklyAnchor(partial2.weekly, account.usage.weekly, now);
14341
+ account.usage.capturedAt = now;
14342
+ }
14343
+ }
14344
+ var RETRY_AFTER_MAX_MS = MAX_QUOTA_RESET_BOUND_MS;
14345
+ function cooldownUntilFrom(res, fallbackMs, now) {
14346
+ let until = now + fallbackMs;
14347
+ const retryAfter = res?.headers.get("retry-after");
14348
+ if (retryAfter) {
14349
+ const secs = Number(retryAfter);
14350
+ if (Number.isFinite(secs) && secs > 0) {
14351
+ const delta = secs * 1000;
14352
+ if (Number.isFinite(delta) && delta <= RETRY_AFTER_MAX_MS)
14353
+ until = now + delta;
14354
+ } else {
14355
+ const httpDate = Date.parse(retryAfter);
14356
+ if (Number.isFinite(httpDate) && httpDate > now && httpDate - now <= RETRY_AFTER_MAX_MS)
14357
+ until = httpDate;
14358
+ }
14359
+ }
14360
+ return until;
14361
+ }
14362
+ async function applyCooldown(accountId, fallbackMs) {
14363
+ const until = cooldownUntilFrom(null, fallbackMs, Date.now());
14364
+ await bestEffort("cooldown", () => mutatePool((pool) => {
14365
+ const account = findAccount(pool, accountId);
14366
+ if (account)
14367
+ account.cooldownUntil = Math.max(account.cooldownUntil, until);
14368
+ }));
14369
+ }
14370
+ async function recordRotation(adapter, accountId, res, fallbackMs, now) {
14371
+ const partial2 = adapter.parseUsageHeaders(res.headers);
14372
+ const until = cooldownUntilFrom(res, fallbackMs, now);
14373
+ await bestEffort("rotation", () => mutatePool((pool) => {
14374
+ const account = findAccount(pool, accountId);
14375
+ if (!account)
14376
+ return;
14377
+ if (partial2)
14378
+ applyUsagePartial(account, partial2, now);
14379
+ account.cooldownUntil = Math.max(account.cooldownUntil, until);
14380
+ }));
14381
+ }
14382
+ async function recordModelCooldown(accountId, tier, until, partial2, now) {
14383
+ await bestEffort("model-cooldown", () => mutatePool((pool) => {
14384
+ const account = findAccount(pool, accountId);
14385
+ if (!account)
14386
+ return;
14387
+ if (partial2)
14388
+ applyUsagePartial(account, partial2, now);
14389
+ const map2 = account.modelCooldownsUntil ??= {};
14390
+ map2[tier] = Math.max(map2[tier] ?? 0, until);
14391
+ }));
14392
+ }
14393
+ async function recordSuccess(adapter, accountId, partial2, sessionKey, now, cfg, fallback) {
14394
+ await bestEffort("record-success", () => mutatePool((pool) => {
14395
+ const account = findAccount(pool, accountId);
14396
+ if (account) {
14397
+ if (partial2)
14398
+ applyUsagePartial(account, partial2, now);
14399
+ if (partial2 && account.cooldownUntil > now + ACCOUNT_COOLDOWN_MS && !isExhausted(account, cfg, now))
14400
+ account.cooldownUntil = 0;
14401
+ pool.lastSelected[adapter.id] = accountId;
14402
+ }
14403
+ if (sessionKey) {
14404
+ pool.sessions[sessionKey] = fallback ? {
14405
+ accountId,
14406
+ updatedAt: now,
14407
+ fallback: { from: fallback.fromModel, to: fallback.toModel }
14408
+ } : { accountId, updatedAt: now };
14409
+ for (const k of Object.keys(pool.sessions)) {
14410
+ const pin = pool.sessions[k];
14411
+ if (pin && now - pin.updatedAt > cfg.sessionTtlMs)
14412
+ delete pool.sessions[k];
14413
+ }
14414
+ }
14415
+ }));
14416
+ }
14417
+ function noUsableAccountResponse(providerID) {
14418
+ const message = `No usable ${providerID} account in the auth-load-balancer pool. Run "opencode auth login" to add or re-login an account.`;
14419
+ const error45 = { type: "authentication_error", message };
14420
+ const body = providerID === PROVIDER_ID ? { type: "error", error: error45 } : { error: error45 };
14421
+ return new Response(JSON.stringify(body), {
14422
+ status: 401,
14423
+ headers: { "content-type": "application/json" }
14424
+ });
14425
+ }
14426
+ function soonestCooldownUntil(accounts, now, accountIds) {
14427
+ if (accountIds.size === 0)
14428
+ return null;
14429
+ let soonest = Number.POSITIVE_INFINITY;
14430
+ for (const account of accounts) {
14431
+ if (account.disabledReason)
14432
+ continue;
14433
+ if (!accountIds.has(account.id))
14434
+ continue;
14435
+ const until = account.cooldownUntil > now ? account.cooldownUntil : now;
14436
+ if (until < soonest)
14437
+ soonest = until;
14438
+ }
14439
+ return Number.isFinite(soonest) ? soonest : null;
14440
+ }
14441
+ function createLoadBalancedFetch(adapter, hooks = {}, models = []) {
14442
+ const cfg = loadConfig();
14443
+ return async (input, init) => {
14444
+ const bodyStr = typeof init?.body === "string" ? init.body : undefined;
14445
+ const baseHeaders = mergeHeaders(input, init);
14446
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
14447
+ const baseKey = deriveSessionKey(baseHeaders, bodyStr);
14448
+ const sessionKey = baseKey ? `${adapter.id}:${baseKey}` : null;
14449
+ baseHeaders.delete(SESSION_HEADER);
14450
+ const tried = new Set;
14451
+ let waitableCooldownIds = null;
14452
+ let lastError = null;
14453
+ let cachedRequestBytes = -1;
14454
+ const requestBytes = cfg.cheapSwitchMaxBytes > 0 && sessionKey !== null && bodyStr ? () => {
14455
+ if (cachedRequestBytes < 0)
14456
+ cachedRequestBytes = Buffer.byteLength(bodyStr, "utf8");
14457
+ return cachedRequestBytes;
14458
+ } : 0;
14459
+ const transformedBody = bodyStr !== undefined ? adapter.transformBody(bodyStr) : init?.body;
14460
+ const transformedUrl = adapter.transformUrl(input);
14461
+ let currentBody = transformedBody;
14462
+ let currentTier = typeof bodyStr === "string" ? adapter.requestModelTier?.(bodyStr) ?? null : null;
14463
+ let fallbackPlan = null;
14464
+ let fallbackPlanFor = null;
14465
+ const planDowngrade = () => {
14466
+ if (typeof currentBody === "string" && fallbackPlanFor !== currentBody) {
14467
+ fallbackPlanFor = currentBody;
14468
+ fallbackPlan = adapter.planModelFallback?.(currentBody, models) ?? null;
14469
+ }
14470
+ return fallbackPlan;
14471
+ };
14472
+ let fallbackInfo = null;
14473
+ let pendingTierFallback = null;
14474
+ let tierSkipped = null;
14475
+ let usageSeeded = false;
14476
+ const waitDeadline = Date.now() + cfg.maxWaitMs;
14477
+ for (;; ) {
14478
+ const pool = await readPool();
14479
+ const now = Date.now();
14480
+ if (!usageSeeded) {
14481
+ usageSeeded = true;
14482
+ refreshAllUsageInBackground(now, pool).catch(ignore);
14483
+ }
14484
+ const selection = selectForSession(pool, adapter.id, sessionKey, now, cfg, tried, requestBytes);
14485
+ if (!selection) {
14486
+ if (tried.size === 0)
14487
+ return noUsableAccountResponse(adapter.id);
14488
+ if (pendingTierFallback !== null) {
14489
+ const adopted = pendingTierFallback;
14490
+ pendingTierFallback = null;
14491
+ currentBody = adopted.body;
14492
+ currentTier = adapter.requestModelTier?.(adopted.body) ?? null;
14493
+ fallbackInfo = fallbackInfo === null ? adopted : { ...adopted, fromModel: fallbackInfo.fromModel };
14494
+ if (tierSkipped !== null)
14495
+ for (const id of tierSkipped)
14496
+ tried.delete(id);
14497
+ tierSkipped = null;
14498
+ if (DEBUG)
14499
+ log(`~~ every ${adapter.id} account is tier-limited for ${adopted.fromModel} -> downgraded to ${adopted.toModel}`);
14500
+ continue;
14501
+ }
14502
+ if (cfg.maxWaitMs > 0 && waitableCooldownIds) {
14503
+ const resumeAt = soonestCooldownUntil(pool.accounts, now, waitableCooldownIds);
14504
+ if (resumeAt !== null && resumeAt <= waitDeadline) {
14505
+ let remaining = resumeAt - Date.now();
14506
+ while (remaining > 0) {
14507
+ await sleepAbortable(remaining, signal);
14508
+ remaining = resumeAt - Date.now();
14509
+ }
14510
+ tried.clear();
14511
+ waitableCooldownIds.clear();
14512
+ continue;
14513
+ }
14514
+ }
14515
+ break;
14516
+ }
14517
+ const { account, degraded, sticky } = selection;
14518
+ tried.add(account.id);
14519
+ if (currentTier !== null && (account.modelCooldownsUntil?.[currentTier] ?? 0) > now) {
14520
+ const plan = planDowngrade();
14521
+ if (plan !== null) {
14522
+ pendingTierFallback = plan;
14523
+ tierSkipped ??= new Set;
14524
+ tierSkipped.add(account.id);
14525
+ if (DEBUG)
14526
+ log(`~~ ${account.label} ${currentTier} tier exhausted -> skipped for ${currentTier}`);
14527
+ continue;
14528
+ }
14529
+ }
14530
+ try {
14531
+ await ensureAccessToken(adapter, account, now);
14532
+ const headers = new Headers(baseHeaders);
14533
+ adapter.applyAuth(headers, account);
14534
+ if (DEBUG)
14535
+ log(`-> ${adapter.id} via ${account.label}${sticky ? " (sticky)" : ""}${degraded ? " (degraded)" : ""}`);
14536
+ const res = await fetch(transformedUrl, {
14537
+ ...init,
14538
+ body: currentBody,
14539
+ headers
14540
+ });
14541
+ const cls = adapter.classifyError(res.status);
14542
+ if (cls === "account" || cls === "auth") {
14543
+ await res.body?.cancel().catch(ignore);
14544
+ if (cls === "account" && typeof currentBody === "string") {
14545
+ const reactiveNow = Date.now();
14546
+ const plan = adapter.planReactiveFallback?.(res, currentBody, reactiveNow, models);
14547
+ if (plan) {
14548
+ await recordModelCooldown(account.id, plan.tier, plan.resetAt, adapter.parseUsageHeaders(res.headers), reactiveNow);
14549
+ const local = account.modelCooldownsUntil ??= {};
14550
+ local[plan.tier] = Math.max(local[plan.tier] ?? 0, plan.resetAt);
14551
+ pendingTierFallback = plan.fallback;
14552
+ tierSkipped ??= new Set;
14553
+ tierSkipped.add(account.id);
14554
+ lastError = new Error(`${adapter.id} account "${account.label}" hit its "${plan.tier}" model-tier cap`);
14555
+ if (DEBUG)
14556
+ log(`~~ ${account.label} ${plan.tier} tier limited -> rotating (fallback ${plan.fallback.toModel} pending)`);
14557
+ continue;
14558
+ }
14559
+ }
14560
+ const ms = cls === "auth" ? AUTH_COOLDOWN_MS : ACCOUNT_COOLDOWN_MS;
14561
+ await recordRotation(adapter, account.id, res, ms, Date.now());
14562
+ if (cls === "account") {
14563
+ waitableCooldownIds ??= new Set;
14564
+ waitableCooldownIds.add(account.id);
14565
+ }
14566
+ lastError = new Error(`${adapter.id} account "${account.label}" returned ${res.status}`);
14567
+ if (DEBUG)
14568
+ log(`!! ${account.label} ${res.status} (${cls}) -> rotating`);
14569
+ continue;
14570
+ }
14571
+ const successNow = Date.now();
14572
+ const partial2 = adapter.parseUsageHeaders(res.headers);
14573
+ if (partial2)
14574
+ applyUsagePartial(account, partial2, successNow);
14575
+ if (fallbackInfo)
14576
+ hooks.onModelFallback?.(adapter.id, account, fallbackInfo.fromModel, fallbackInfo.toModel, fallbackInfo.fromTier);
14577
+ hooks.onUse?.(adapter.id, account);
14578
+ await recordSuccess(adapter, account.id, partial2, sessionKey, successNow, cfg, fallbackInfo);
14579
+ return adapter.transformResponse(res);
14580
+ } catch (error45) {
14581
+ lastError = error45;
14582
+ const aborted2 = signal?.aborted === true || error45 instanceof Error && error45.name === "AbortError";
14583
+ if (aborted2)
14584
+ throw error45;
14585
+ if (!account.disabledReason)
14586
+ await applyCooldown(account.id, AUTH_COOLDOWN_MS);
14587
+ if (DEBUG)
14588
+ log(`!! ${account.label} threw: ${error45 instanceof Error ? error45.message : String(error45)}`);
14589
+ continue;
14590
+ }
14591
+ }
14592
+ throw lastError ?? new Error(`${adapter.id}: no usable account in the load-balancer pool`);
14593
+ };
14594
+ }
14595
+
14596
+ // src/status.ts
14597
+ var PROVIDER_NAMES = {
14598
+ anthropic: "Claude",
14599
+ openai: "Codex"
14600
+ };
14601
+ function compareAscii(a, b) {
14602
+ return a < b ? -1 : a > b ? 1 : 0;
14603
+ }
14604
+ function byProviderId(a, b) {
14605
+ return compareAscii(a[0], b[0]);
14606
+ }
14607
+ function isExpired(window, now) {
14608
+ return !!window && window.resetAt > 0 && window.resetAt <= now;
14609
+ }
14610
+ function toStatus(account, now, current, available, rank) {
14611
+ const weekly = account.usage.weekly;
14612
+ const weeklyExpired = isExpired(weekly, now);
14613
+ return {
14614
+ id: account.id,
14615
+ label: account.label,
14616
+ weeklyUtil: displayUtil(weekly, now),
14617
+ hourlyUtil: displayUtil(account.usage.hourly, now),
14618
+ weeklyResetAt: weekly && !weeklyExpired ? weekly.resetAt : 0,
14619
+ available,
14620
+ cooldownUntil: account.cooldownUntil,
14621
+ modelCooldownsUntil: account.modelCooldownsUntil ?? {},
14622
+ disabledReason: account.disabledReason,
14623
+ current,
14624
+ rank
14625
+ };
14626
+ }
14627
+ function buildStatus(pool, now, cfg = DEFAULT_CONFIG) {
14628
+ const byProvider = new Map;
14629
+ for (const a of pool.accounts) {
14630
+ const list = byProvider.get(a.providerID);
14631
+ if (list)
14632
+ list.push(a);
14633
+ else
14634
+ byProvider.set(a.providerID, [a]);
14635
+ }
14636
+ const sorted = [...byProvider.entries()].sort(byProviderId);
14637
+ return sorted.map(([providerID, providerAccounts]) => {
14638
+ const currentAccountId = pool.lastSelected[providerID] ?? null;
14639
+ const ranked = providerAccounts.map((a) => {
14640
+ const available = isAvailable(a, cfg, now);
14641
+ return {
14642
+ a,
14643
+ available,
14644
+ score: available ? scoreAccount(a, cfg, now) : Number.NEGATIVE_INFINITY,
14645
+ weeklyUtil: a.usage.weekly?.utilization ?? 0
14646
+ };
14647
+ }).sort(compareRanked);
14648
+ const accounts = ranked.map(({ a, available }, i) => toStatus(a, now, a.id === currentAccountId, available, i + 1));
14649
+ return { providerID, accounts };
14580
14650
  });
14581
- const json2 = await readRefreshResponse(await postToken2(body));
14582
- return toTokenSet2(json2, refreshToken);
14583
14651
  }
14584
-
14585
- // src/providers/openai/transform.ts
14586
- function applyAuth(headers, account) {
14587
- headers.delete("x-api-key");
14588
- headers.set("authorization", `Bearer ${account.access}`);
14589
- const accountId = resolveAccountId(account);
14590
- if (accountId)
14591
- headers.set("chatgpt-account-id", accountId);
14592
- headers.set("openai-beta", OPENAI_BETA);
14593
- headers.set("originator", ORIGINATOR);
14594
- headers.set("user-agent", USER_AGENT2);
14595
- headers.set("session-id", SESSION_ID);
14652
+ async function readStatus(now = Date.now(), cfg = loadConfig()) {
14653
+ return buildStatus(await readPool(), now, cfg);
14596
14654
  }
14597
- function rewriteUrl2(input) {
14598
- const url2 = urlFromInput(input);
14599
- if (!url2)
14600
- return input;
14601
- if (!url2.pathname.includes("/responses"))
14602
- return input;
14603
- if (url2.href === CODEX_RESPONSES_URL)
14604
- return input;
14605
- return input instanceof Request ? new Request(CODEX_RESPONSES_URL, input) : CODEX_RESPONSES_URL;
14655
+ function pct(u) {
14656
+ return typeof u === "number" ? `${Math.round(u * 100)}%` : "-";
14606
14657
  }
14607
- function applyInstructions(obj) {
14608
- let instructions = typeof obj.instructions === "string" ? obj.instructions : "";
14609
- if (Array.isArray(obj.input)) {
14610
- const input = obj.input;
14611
- const systemTexts = [];
14612
- let remaining = null;
14613
- let i = 0;
14614
- for (const raw of input) {
14615
- if (raw && raw.type === "message" && raw.role === "system") {
14616
- if (remaining === null)
14617
- remaining = input.slice(0, i);
14618
- const text = joinBlockTexts(raw.content, `
14619
- `);
14620
- if (text)
14621
- systemTexts.push(text);
14622
- } else if (remaining !== null) {
14623
- remaining.push(raw);
14624
- }
14625
- i++;
14626
- }
14627
- if (systemTexts.length > 0) {
14628
- instructions = [instructions, ...systemTexts].filter(Boolean).join(`
14629
-
14630
- `);
14631
- }
14632
- if (remaining !== null)
14633
- obj.input = remaining;
14658
+ function displayWidth(s) {
14659
+ let width = 0;
14660
+ for (const ch of s) {
14661
+ const cp = ch.codePointAt(0) ?? 0;
14662
+ const wide = cp >= 4352 && cp <= 4447 || cp >= 11904 && cp <= 42191 || cp >= 44032 && cp <= 55203 || cp >= 63744 && cp <= 64255 || cp >= 65280 && cp <= 65376 || cp >= 65504 && cp <= 65510 || cp >= 131072 && cp <= 262141;
14663
+ width += wide ? 2 : 1;
14634
14664
  }
14635
- obj.instructions = instructions.trim() || "You are a helpful assistant.";
14665
+ return width;
14636
14666
  }
14637
- function rewriteRequestBody2(body) {
14638
- try {
14639
- const obj = JSON.parse(body);
14640
- obj.store = false;
14641
- const include = Array.isArray(obj.include) ? obj.include : [];
14642
- obj.include = include.includes("reasoning.encrypted_content") && new Set(include).size === include.length ? include : [...new Set([...include, "reasoning.encrypted_content"])];
14643
- applyInstructions(obj);
14644
- delete obj.max_output_tokens;
14645
- return JSON.stringify(obj);
14646
- } catch {
14647
- return body;
14667
+ function padDisplayEnd(s, width) {
14668
+ const pad = width - displayWidth(s);
14669
+ return pad > 0 ? s + " ".repeat(pad) : s;
14670
+ }
14671
+ function relTime(at, now) {
14672
+ if (at <= now)
14673
+ return "-";
14674
+ const mins = Math.max(1, Math.round((at - now) / 60000));
14675
+ if (mins <= 120)
14676
+ return `${mins}m`;
14677
+ const hrs = Math.floor(mins / 60);
14678
+ if (mins <= 48 * 60)
14679
+ return `${hrs}h${mins % 60}m`;
14680
+ return `${Math.floor(hrs / 24)}d${hrs % 24}h`;
14681
+ }
14682
+ function stateOf(a, now) {
14683
+ if (a.disabledReason)
14684
+ return a.disabledReason === MANUAL_DISABLED_REASON ? "disabled" : "re-login";
14685
+ if (a.cooldownUntil > now)
14686
+ return `cooldown ${relTime(a.cooldownUntil, now)}`;
14687
+ if (!a.available)
14688
+ return "exhausted";
14689
+ const base = a.current ? "in use" : "ready";
14690
+ const tiers = Object.entries(a.modelCooldownsUntil).filter(([, until]) => until > now).sort(([x], [y]) => compareAscii(x, y)).map(([tier, until]) => `${tier} ${relTime(until, now)}`);
14691
+ if (tiers.length > 0)
14692
+ return `${base} · ${tiers.join(" · ")}`;
14693
+ return base;
14694
+ }
14695
+ function providerName(providerID) {
14696
+ return PROVIDER_NAMES[providerID] ?? providerID;
14697
+ }
14698
+ function renderStatus(providers, now = Date.now()) {
14699
+ if (providers.length === 0)
14700
+ return "auth-load-balancer: no accounts registered.";
14701
+ const lines = [];
14702
+ for (const p of providers) {
14703
+ const current = p.accounts.find((a) => a.current);
14704
+ lines.push(`${providerName(p.providerID)} — in use: ${current ? current.label : "(none yet)"}`);
14705
+ const width = p.accounts.reduce((w, a) => Math.max(w, displayWidth(a.label)), 16);
14706
+ lines.push(` # ${"account".padEnd(width + 3)}weekly 5h resets state`);
14707
+ for (const a of p.accounts) {
14708
+ const mark = a.current ? "▶" : " ";
14709
+ lines.push(` ${String(a.rank).padEnd(2)} ${mark} ${padDisplayEnd(a.label, width)} ${pct(a.weeklyUtil).padStart(5)} ${pct(a.hourlyUtil).padStart(5)} ${relTime(a.weeklyResetAt, now).padStart(7)} ${stateOf(a, now)}`);
14710
+ }
14711
+ lines.push("");
14648
14712
  }
14713
+ return lines.join(`
14714
+ `).trimEnd();
14649
14715
  }
14650
14716
 
14651
- // src/providers/openai/usage.ts
14652
- var HEADER_SPEC2 = {
14653
- hourlyUtil: "x-codex-primary-used-percent",
14654
- hourlyReset: "x-codex-primary-reset-at",
14655
- weeklyUtil: "x-codex-secondary-used-percent",
14656
- weeklyReset: "x-codex-secondary-reset-at",
14657
- divisor: 100
14658
- };
14659
- function parseUsageHeaders2(headers) {
14660
- return parseWindowPairHeaders(headers, HEADER_SPEC2);
14717
+ // src/notify.ts
14718
+ async function postToast(client, body) {
14719
+ await client.tui.showToast({ body }).catch(ignore);
14661
14720
  }
14662
- function endpointWindow2(w) {
14663
- return endpointWindowFrom(w, (win) => win.used_percent, 100, (win) => secondsToMs(Number(win.reset_at ?? 0)));
14721
+ var lastToasted = new Map;
14722
+ async function notifyOnSwitch(client, providerID, account) {
14723
+ if (lastToasted.get(providerID) === account.id)
14724
+ return;
14725
+ lastToasted.set(providerID, account.id);
14726
+ const now = Date.now();
14727
+ const message = `▶ ${account.label} · weekly ${pct(displayUtil(account.usage.weekly, now))} · 5h ${pct(displayUtil(account.usage.hourly, now))}`;
14728
+ await postToast(client, {
14729
+ title: `${providerName(providerID)} account`,
14730
+ message,
14731
+ variant: "info",
14732
+ duration: 4000
14733
+ });
14664
14734
  }
14665
- async function fetchUsage2(account, now) {
14666
- const headers = {
14667
- authorization: `Bearer ${account.access}`,
14668
- "user-agent": USAGE_USER_AGENT2,
14669
- accept: "application/json"
14670
- };
14671
- const accountId = resolveAccountId(account);
14672
- if (accountId)
14673
- headers["chatgpt-account-id"] = accountId;
14674
- const json2 = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
14675
- if (!json2?.rate_limit)
14676
- return null;
14677
- return {
14678
- hourly: endpointWindow2(json2.rate_limit.primary_window),
14679
- weekly: endpointWindow2(json2.rate_limit.secondary_window),
14680
- capturedAt: now
14681
- };
14735
+ var lastFallbackToasted = new Map;
14736
+ var LAST_FALLBACK_TOASTED_MAX = 256;
14737
+ async function notifyModelFallback(client, providerID, account, fromModel, toModel, fromTier) {
14738
+ const key = `${providerID}:${account.id}`;
14739
+ const now = Date.now();
14740
+ const tiers = account.modelCooldownsUntil;
14741
+ let latestWindow = 0;
14742
+ const triggeringWindow = fromTier ? tiers?.[fromTier] : undefined;
14743
+ if (triggeringWindow !== undefined && triggeringWindow > now) {
14744
+ latestWindow = triggeringWindow;
14745
+ } else if (tiers) {
14746
+ for (const until of Object.values(tiers)) {
14747
+ if (until > now && until > latestWindow)
14748
+ latestWindow = until;
14749
+ }
14750
+ }
14751
+ const window = `${fromModel}@${latestWindow}`;
14752
+ if (lastFallbackToasted.get(key) === window)
14753
+ return;
14754
+ setBounded(lastFallbackToasted, key, window, LAST_FALLBACK_TOASTED_MAX);
14755
+ await postToast(client, {
14756
+ title: `${providerName(providerID)} model fallback`,
14757
+ message: `▶ ${account.label} · ${fromModel} → ${toModel} (model-tier weekly limit)`,
14758
+ variant: "warning",
14759
+ duration: 6000
14760
+ });
14682
14761
  }
14683
14762
 
14684
- // src/providers/openai/adapter.ts
14685
- var openaiAdapter = {
14686
- id: PROVIDER_ID2,
14687
- authorize: authorize2,
14688
- exchange: exchange2,
14689
- refresh: refresh2,
14690
- applyAuth,
14691
- transformUrl: rewriteUrl2,
14692
- transformBody: rewriteRequestBody2,
14693
- transformResponse(response) {
14694
- return response;
14695
- },
14696
- parseUsageHeaders: parseUsageHeaders2,
14697
- fetchUsage: fetchUsage2,
14698
- classifyError: classifyHttpStatus
14699
- };
14763
+ // src/prime.ts
14764
+ async function primeInUse(providerID, now) {
14765
+ const snapshot = await readPool();
14766
+ if (!snapshot.accounts.some((a) => a.providerID === providerID))
14767
+ return;
14768
+ const cfg = loadConfig();
14769
+ const preview = selectAccount(snapshot.accounts, providerID, now, cfg);
14770
+ if (!preview || snapshot.lastSelected[providerID] === preview.account.id)
14771
+ return;
14772
+ await mutatePool((pool) => {
14773
+ const selection = selectAccount(pool.accounts, providerID, now, cfg);
14774
+ if (selection)
14775
+ pool.lastSelected[providerID] = selection.account.id;
14776
+ });
14777
+ }
14700
14778
 
14701
14779
  // src/index.ts
14702
14780
  var PROVIDER_LABELS = {
@@ -14786,7 +14864,7 @@ var AuthLoadBalancerStatusPlugin = async () => ({
14786
14864
  execute: async () => {
14787
14865
  const now = Date.now();
14788
14866
  const pool = await readPool();
14789
- await Promise.all([anthropicAdapter, openaiAdapter].map((adapter) => refreshUsageInBackground(adapter, now, pool).catch(ignore)));
14867
+ await refreshAllUsageInBackground(now, pool).catch(ignore);
14790
14868
  const renderedAt = Date.now();
14791
14869
  return lbResult(renderStatus(await readStatus(renderedAt), renderedAt));
14792
14870
  }
@@ -14819,6 +14897,28 @@ var AuthLoadBalancerStatusPlugin = async () => ({
14819
14897
  return lbResult(result.reason === "taken" ? `Label "${trimmed}" is already used by another account.` : `No account matching "${account}". Available: ${result.labels.join(", ") || "(none)"}`);
14820
14898
  return lbResult(`Renamed "${result.previous}" → "${trimmed}".`);
14821
14899
  }
14900
+ }),
14901
+ auth_lb_disable: tool({
14902
+ description: "Disable a pooled account so the load balancer stops selecting it (match by current label or id), or re-enable a previously disabled one with `enable: true`. A disabled account is skipped by scheduling and shows as `disabled` in the auth_lb_status dashboard — separate from an account that needs re-login.",
14903
+ args: {
14904
+ account: tool.schema.string().describe("Current label or id of the account to disable/enable"),
14905
+ enable: tool.schema.boolean().optional().describe("Set true to re-enable a previously disabled account; omit or false to disable it")
14906
+ },
14907
+ execute: async ({ account, enable }) => {
14908
+ const result = await mutatePool((pool) => {
14909
+ const target = pool.accounts.find((a) => a.id === account || a.label === account);
14910
+ if (!target)
14911
+ return {
14912
+ ok: false,
14913
+ labels: pool.accounts.map((a) => `${a.label} (${a.providerID})`)
14914
+ };
14915
+ target.disabledReason = enable ? null : MANUAL_DISABLED_REASON;
14916
+ return { ok: true, label: target.label, enabled: !!enable };
14917
+ });
14918
+ if (!result.ok)
14919
+ return lbResult(`No account matching "${account}". Available: ${result.labels.join(", ") || "(none)"}`);
14920
+ return lbResult(result.enabled ? `Enabled "${result.label}" — back in the load-balancer rotation.` : `Disabled "${result.label}" — the load balancer will skip it until you re-enable it.`);
14921
+ }
14822
14922
  })
14823
14923
  }
14824
14924
  });