@liustack/modlens 3.23.1 → 3.24.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/main.js CHANGED
@@ -731,6 +731,62 @@ function schemaViolations(schema, value, path2) {
731
731
  }
732
732
  return [];
733
733
  }
734
+ function splitApiKeys(value) {
735
+ if (typeof value !== "string") {
736
+ return [];
737
+ }
738
+ return value.split(",").map((key) => key.trim()).filter((key) => key.length > 0);
739
+ }
740
+ class ApiKeyFailureError extends Error {
741
+ quotaCooldown;
742
+ resetAfterMs;
743
+ constructor(message, opts) {
744
+ super(message);
745
+ this.name = "ApiKeyFailureError";
746
+ this.quotaCooldown = opts?.quotaCooldown ?? "none";
747
+ this.resetAfterMs = opts?.resetAfterMs ?? null;
748
+ }
749
+ }
750
+ function isApiKeyFailure(error) {
751
+ return error instanceof ApiKeyFailureError;
752
+ }
753
+ const QUOTA_FAILURE_PATTERNS = [
754
+ /\bquota\b/i,
755
+ /\bpayment required\b/i,
756
+ /\b(?:out of|insufficient|not enough)\s+(?:account\s+)?(?:balance|credits?)\b/i,
757
+ /\b(?:balance|credits?)\s+(?:is\s+)?(?:insufficient|exhausted|depleted|empty|too low|used up)\b/i,
758
+ /\b(?:credit|usage)\s+(?:limit|cap)\s+(?:reached|exceeded)\b/i
759
+ ];
760
+ function isQuotaFailureMessage(message) {
761
+ return QUOTA_FAILURE_PATTERNS.some((pattern) => pattern.test(message));
762
+ }
763
+ function parseResetDuration(message) {
764
+ const match = /Resets? in\s+(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/i.exec(message);
765
+ if (!match || !match[1] && !match[2] && !match[3]) {
766
+ return null;
767
+ }
768
+ const hours = Number.parseInt(match[1] ?? "0", 10);
769
+ const minutes = Number.parseInt(match[2] ?? "0", 10);
770
+ const seconds = Number.parseInt(match[3] ?? "0", 10);
771
+ return (hours * 3600 + minutes * 60 + seconds) * 1e3;
772
+ }
773
+ function errorFromApiStatus(status, message, detail = "") {
774
+ const resetAfterMs = parseResetDuration(detail);
775
+ if (status >= 500) {
776
+ return new Error(message);
777
+ }
778
+ if (status === 432 || status === 433) {
779
+ return new ApiKeyFailureError(message, { quotaCooldown: "monthly", resetAfterMs });
780
+ }
781
+ if (status === 401 || status === 403 || status === 429 || isQuotaFailureMessage(detail)) {
782
+ const quotaClass = isQuotaFailureMessage(detail) || resetAfterMs !== null;
783
+ return new ApiKeyFailureError(message, {
784
+ quotaCooldown: quotaClass ? "default" : "none",
785
+ resetAfterMs
786
+ });
787
+ }
788
+ return new Error(message);
789
+ }
734
790
  function tryParseJson(text) {
735
791
  try {
736
792
  return JSON.parse(text);
@@ -939,7 +995,9 @@ const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
939
995
  const TOOL_NAME = "report_vision_evidence";
940
996
  async function executeAnthropicApi(options) {
941
997
  assertNoRetiredEndpointBinding("anthropic", options.settings ?? {});
942
- const apiKey = options.settings?.apiKey;
998
+ const apiKeys = splitApiKeys(options.settings?.apiKey);
999
+ const apiKey = apiKeys[0];
1000
+ const apiKeySecrets = [.../* @__PURE__ */ new Set([...apiKeys, ...options.apiKeySecrets ?? []])];
943
1001
  if (!apiKey) {
944
1002
  throw new Error(
945
1003
  "anthropic provider needs an API key. Run: modlens config set anthropic.apiKey and paste it at the hidden prompt"
@@ -1005,10 +1063,10 @@ Report your findings by calling the ${TOOL_NAME} tool.`;
1005
1063
  options.settings?.proxy
1006
1064
  );
1007
1065
  if (!response.ok) {
1008
- const body = await response.text();
1009
- throw new Error(
1010
- `Anthropic API error ${response.status}: ${truncate(redactSecrets(body, [apiKey]))}`
1011
- );
1066
+ const body = (await response.text().catch(() => "")).trim();
1067
+ const detail = redactSecrets(body, apiKeySecrets);
1068
+ const message = `Anthropic API error ${response.status}: ${truncate(detail)}`;
1069
+ throw errorFromApiStatus(response.status, message, detail);
1012
1070
  }
1013
1071
  const payload = await response.json();
1014
1072
  const toolUse = payload.content?.find((block) => block.type === "tool_use");
@@ -1105,11 +1163,18 @@ function describeAntigravityFailure(context) {
1105
1163
  ${context.stderr}
1106
1164
  ${readRecentAgyLog(since)}`.toLowerCase();
1107
1165
  if (evidence.includes("quota")) {
1108
- return [
1166
+ const text = [
1109
1167
  agyError || "Antigravity CLI reported a quota error.",
1110
1168
  "agy's free tier is one weekly bucket shared by the desktop app, the CLI, and the SDK, and subagents drain it in parallel. Wait for the reset shown above, or use a different provider.",
1111
1169
  SWITCH_HINT
1112
1170
  ].join("\n\n");
1171
+ const resetSource = `${agyError}
1172
+ ${context.stderr}
1173
+ ${readRecentAgyLog(since)}`;
1174
+ return new ApiKeyFailureError(text, {
1175
+ quotaCooldown: "default",
1176
+ resetAfterMs: parseResetDuration(resetSource) ?? parseResetDuration(text)
1177
+ });
1113
1178
  }
1114
1179
  if (evidence.includes("not logged into antigravity") || evidence.includes("getting token source") || evidence.includes("keyring") || evidence.includes("failed to read token store")) {
1115
1180
  return [
@@ -1247,7 +1312,9 @@ const claudeCliProvider = {
1247
1312
  const GEMINI_API_DEFAULT_MODEL = "gemini-3.6-flash";
1248
1313
  const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
1249
1314
  async function executeGeminiApi(options) {
1250
- const apiKey = options.settings?.apiKey;
1315
+ const apiKeys = splitApiKeys(options.settings?.apiKey);
1316
+ const apiKey = apiKeys[0];
1317
+ const apiKeySecrets = [.../* @__PURE__ */ new Set([...apiKeys, ...options.apiKeySecrets ?? []])];
1251
1318
  if (!apiKey) {
1252
1319
  throw new Error(
1253
1320
  "gemini-api provider needs an API key. Run: modlens config set gemini-api.apiKey and paste it at the hidden prompt (free key: https://aistudio.google.com)"
@@ -1305,10 +1372,10 @@ async function executeGeminiApi(options) {
1305
1372
  options.settings?.proxy
1306
1373
  );
1307
1374
  if (!response.ok) {
1308
- const body = await response.text();
1309
- throw new Error(
1310
- `Gemini API error ${response.status}: ${truncate(redactSecrets(body, [apiKey]))}`
1311
- );
1375
+ const body = (await response.text().catch(() => "")).trim();
1376
+ const detail = redactSecrets(body, apiKeySecrets);
1377
+ const message = `Gemini API error ${response.status}: ${truncate(detail)}`;
1378
+ throw errorFromApiStatus(response.status, message, detail);
1312
1379
  }
1313
1380
  const payload = await response.json();
1314
1381
  const text = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("");
@@ -1446,7 +1513,9 @@ function unusableOutputAdvice(finishReason, settings, quoteReason, whenFinished)
1446
1513
  }
1447
1514
  async function executeOpenaiCompat(options) {
1448
1515
  assertNoRetiredEndpointBinding("openai", options.settings ?? {});
1449
- const apiKey = options.settings?.apiKey;
1516
+ const apiKeys = splitApiKeys(options.settings?.apiKey);
1517
+ const apiKey = apiKeys[0];
1518
+ const apiKeySecrets = [.../* @__PURE__ */ new Set([...apiKeys, ...options.apiKeySecrets ?? []])];
1450
1519
  const baseUrl = options.settings?.baseUrl?.replace(/\/$/, "");
1451
1520
  const model = options.model || options.settings?.model;
1452
1521
  if (!apiKey || !baseUrl || !model) {
@@ -1501,10 +1570,12 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
1501
1570
  },
1502
1571
  options.settings?.proxy
1503
1572
  );
1504
- const quote = (shown, clip = truncate) => clip(redactSecrets(shown, [apiKey, baseUrl]));
1573
+ const quote = (shown, clip = truncate) => clip(redactSecrets(shown, [...apiKeySecrets, baseUrl]));
1505
1574
  if (!response.ok) {
1506
- const body = await response.text();
1507
- throw new Error(`OpenAI-compatible API error ${response.status}: ${quote(body)}`);
1575
+ const body = (await response.text().catch(() => "")).trim();
1576
+ const detail = redactSecrets(body, [...apiKeySecrets, baseUrl]);
1577
+ const message = `OpenAI-compatible API error ${response.status}: ${truncate(detail)}`;
1578
+ throw errorFromApiStatus(response.status, message, detail);
1508
1579
  }
1509
1580
  const payload = await response.json();
1510
1581
  const text = payload.choices?.[0]?.message?.content;
@@ -1592,7 +1663,7 @@ const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi", "grok"];
1592
1663
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
1593
1664
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
1594
1665
  const ENV_BINDINGS = {
1595
- "gemini-api": { apiKey: "GEMINI_API_KEY" },
1666
+ "gemini-api": { apiKey: "GEMINI_API_KEY", baseUrl: "GEMINI_BASE_URL" },
1596
1667
  openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
1597
1668
  anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
1598
1669
  };
@@ -1619,7 +1690,8 @@ function envSettingsFor(providerName, env) {
1619
1690
  const settings = {};
1620
1691
  for (const [field, variable] of Object.entries(ENV_BINDINGS[providerName] ?? {})) {
1621
1692
  const value = env[variable]?.trim();
1622
- if (value) {
1693
+ const present = field === "apiKey" ? splitApiKeys(value).length > 0 : Boolean(value);
1694
+ if (value && present) {
1623
1695
  settings[field] = value;
1624
1696
  }
1625
1697
  }
@@ -1673,6 +1745,13 @@ function loadConfigFile(configPath = CONFIG_PATH) {
1673
1745
  );
1674
1746
  }
1675
1747
  }
1748
+ function cooldownEnabled(config2) {
1749
+ return config2.cooldown?.trim().toLowerCase() !== "off";
1750
+ }
1751
+ function canonicalProviderName(name) {
1752
+ const trimmed = name.trim().toLowerCase();
1753
+ return foldProviderName(trimmed);
1754
+ }
1676
1755
  function providerConfiguredInFile(providerName, config2) {
1677
1756
  return fileKeysFor(providerName, config2).length > 0;
1678
1757
  }
@@ -1688,6 +1767,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
1688
1767
  const config2 = loadConfigFile(configPath);
1689
1768
  if (dottedKey === "provider") {
1690
1769
  config2.provider = value;
1770
+ } else if (dottedKey === "cooldown") {
1771
+ const normalized = value.trim().toLowerCase();
1772
+ if (normalized !== "on" && normalized !== "off") {
1773
+ throw new Error(`Invalid cooldown value: ${value}. Use on or off.`);
1774
+ }
1775
+ config2.cooldown = normalized;
1691
1776
  } else if (dottedKey === "proxy") {
1692
1777
  if (value.trim() === "") {
1693
1778
  delete config2.proxy;
@@ -1720,7 +1805,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
1720
1805
  const dot = dottedKey.indexOf(".");
1721
1806
  if (dot <= 0 || dot === dottedKey.length - 1) {
1722
1807
  throw new Error(
1723
- `Invalid config key: ${dottedKey}. Use "provider", "proxy", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|proxy|extraBody|structuredOutput>".`
1808
+ `Invalid config key: ${dottedKey}. Use "provider", "proxy", "cooldown", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|proxy|extraBody|structuredOutput>".`
1724
1809
  );
1725
1810
  }
1726
1811
  const typedName = dottedKey.slice(0, dot);
@@ -1807,6 +1892,9 @@ function assertReadableConfig(config2, configPath = CONFIG_PATH) {
1807
1892
  if (config2.proxy !== void 0 && typeof config2.proxy !== "string") {
1808
1893
  throw sentence('"proxy"', "is not a string");
1809
1894
  }
1895
+ if (config2.cooldown !== void 0 && typeof config2.cooldown !== "string") {
1896
+ throw sentence('"cooldown"', "is not a string");
1897
+ }
1810
1898
  if (config2.reuse !== void 0 && !isPlainObject(config2.reuse)) {
1811
1899
  throw sentence('"reuse"', "is not an object");
1812
1900
  }
@@ -1887,14 +1975,15 @@ function knownApiKeys(config2, env = process.env) {
1887
1975
  const providersRoot = isPlainObject(config2.providers) ? config2.providers : {};
1888
1976
  for (const entry of Object.values(providersRoot)) {
1889
1977
  if (isPlainObject(entry) && typeof entry.apiKey === "string") {
1890
- keys.add(entry.apiKey);
1978
+ for (const apiKey of splitApiKeys(entry.apiKey)) {
1979
+ keys.add(apiKey);
1980
+ }
1891
1981
  }
1892
1982
  }
1893
1983
  for (const bindings of Object.values(ENV_BINDINGS)) {
1894
1984
  const variable = bindings.apiKey;
1895
- const value = variable ? env[variable]?.trim() : void 0;
1896
- if (value) {
1897
- keys.add(value);
1985
+ for (const apiKey of splitApiKeys(variable ? env[variable] : void 0)) {
1986
+ keys.add(apiKey);
1898
1987
  }
1899
1988
  }
1900
1989
  const savedRoot = isPlainObject(config2.saved) ? config2.saved : {};
@@ -1902,7 +1991,9 @@ function knownApiKeys(config2, env = process.env) {
1902
1991
  if (!isPlainObject(bundles)) continue;
1903
1992
  for (const bundle of Object.values(bundles)) {
1904
1993
  if (isPlainObject(bundle) && typeof bundle.apiKey === "string") {
1905
- keys.add(bundle.apiKey);
1994
+ for (const apiKey of splitApiKeys(bundle.apiKey)) {
1995
+ keys.add(apiKey);
1996
+ }
1906
1997
  }
1907
1998
  }
1908
1999
  }
@@ -2101,7 +2192,12 @@ function renderEffectiveConfig(config2, env = process.env) {
2101
2192
  Object.keys(providersRoot ?? {}).map((key) => foldProviderName(key)).filter((name) => canonicalNames.has(name))
2102
2193
  );
2103
2194
  for (const [providerName, bindings] of Object.entries(ENV_BINDINGS)) {
2104
- if (Object.values(bindings).some((variable) => env[variable]?.trim())) {
2195
+ if (Object.entries(bindings).some(
2196
+ ([field, variable]) => {
2197
+ const value = env[variable]?.trim();
2198
+ return field === "apiKey" ? splitApiKeys(value).length > 0 : Boolean(value);
2199
+ }
2200
+ )) {
2105
2201
  providerNames.add(providerName);
2106
2202
  }
2107
2203
  }
@@ -2127,8 +2223,10 @@ function renderEffectiveConfig(config2, env = process.env) {
2127
2223
  const effective2 = mentioned ? fileSettings : envSettingsFor(name, env);
2128
2224
  const source = mentioned ? "file" : "env";
2129
2225
  const fields = {};
2130
- const entryKey = typeof effective2.apiKey === "string" ? effective2.apiKey : void 0;
2131
- const guard = (shown) => redactSecrets(shown, [entryKey]);
2226
+ const entryKeys = splitApiKeys(
2227
+ typeof effective2.apiKey === "string" ? effective2.apiKey : void 0
2228
+ );
2229
+ const guard = (shown) => redactSecrets(shown, entryKeys);
2132
2230
  for (const field of STRING_FIELDS) {
2133
2231
  const value = effective2[field];
2134
2232
  if (value === void 0) {
@@ -2138,7 +2236,7 @@ function renderEffectiveConfig(config2, env = process.env) {
2138
2236
  fields[field] = `(malformed: not a string) (${source})`;
2139
2237
  continue;
2140
2238
  }
2141
- const shown = field === "apiKey" ? maskKey(value) : field === "proxy" ? maskUrlCredentials(guard(value)) : guard(value);
2239
+ const shown = field === "apiKey" ? maskKeys(value) : field === "proxy" ? maskUrlCredentials(guard(value)) : guard(value);
2142
2240
  fields[field] = `${shown} (${source})`;
2143
2241
  }
2144
2242
  if (fileSettings.structuredOutput !== void 0) {
@@ -2152,7 +2250,8 @@ function renderEffectiveConfig(config2, env = process.env) {
2152
2250
  }
2153
2251
  }
2154
2252
  const effective = {
2155
- providers
2253
+ providers,
2254
+ cooldown: config2.cooldown ? `${config2.cooldown} (file)` : "on (default)"
2156
2255
  };
2157
2256
  const savedRows = [];
2158
2257
  const savedRoot = config2.saved;
@@ -2173,7 +2272,7 @@ function renderEffectiveConfig(config2, env = process.env) {
2173
2272
  const parts = [
2174
2273
  typeof bundle.model === "string" ? bundle.model : void 0,
2175
2274
  typeof bundle.baseUrl === "string" ? bundle.baseUrl : void 0,
2176
- bundle.apiKey === void 0 ? "no key" : typeof bundle.apiKey === "string" ? `key ${maskKey(bundle.apiKey)}` : "key (malformed: not a string)"
2275
+ bundle.apiKey === void 0 ? "no key" : typeof bundle.apiKey === "string" ? `key ${maskKeys(bundle.apiKey)}` : "key (malformed: not a string)"
2177
2276
  ].filter(Boolean);
2178
2277
  savedRows.push(`${slot}/${label}: ${parts.join(" @ ")}`);
2179
2278
  }
@@ -2245,6 +2344,10 @@ function maskKey(key) {
2245
2344
  }
2246
2345
  return `${key.slice(0, 6)}...${key.slice(-2)}`;
2247
2346
  }
2347
+ function maskKeys(value) {
2348
+ const keys = splitApiKeys(value);
2349
+ return keys.length > 0 ? keys.map(maskKey).join(", ") : "****";
2350
+ }
2248
2351
  function envValue(env, name, platform = process.platform) {
2249
2352
  if (platform !== "win32") {
2250
2353
  return env[name];
@@ -2362,7 +2465,9 @@ function providerAvailable(name, config2, env = process.env) {
2362
2465
  return findOnPath(descriptor.bin, env) !== null;
2363
2466
  }
2364
2467
  const settings = resolveProviderSettings(name, config2, env);
2365
- return (descriptor.required ?? []).every((req) => Boolean(settings[req.field]?.trim()));
2468
+ return (descriptor.required ?? []).every(
2469
+ (req) => req.field === "apiKey" ? splitApiKeys(settings.apiKey).length > 0 : Boolean(settings[req.field]?.trim())
2470
+ );
2366
2471
  }
2367
2472
  const LOCAL_FAILOVER_ORDER = [
2368
2473
  "gemini-api",
@@ -3412,6 +3517,215 @@ function reuseProviders(kind, config2, options = {}) {
3412
3517
  }
3413
3518
  return { inline, agents };
3414
3519
  }
3520
+ const KEY_COOLDOWN_SUFFIX = /^(.*)::key:(\d+)$/;
3521
+ function cooldownStateKey(engine, keyIndex) {
3522
+ const canonical = canonicalProviderName(engine);
3523
+ return keyIndex === void 0 ? canonical : `${canonical}::key:${keyIndex}`;
3524
+ }
3525
+ function parseCooldownStateKey(stateKey) {
3526
+ const match = KEY_COOLDOWN_SUFFIX.exec(stateKey);
3527
+ if (!match) {
3528
+ return { engine: canonicalProviderName(stateKey) };
3529
+ }
3530
+ return {
3531
+ engine: canonicalProviderName(match[1]),
3532
+ keyIndex: Number.parseInt(match[2], 10)
3533
+ };
3534
+ }
3535
+ function currentStatePath() {
3536
+ return path.join(os.homedir(), ".modlens", "state.json");
3537
+ }
3538
+ const DEFAULT_COOLDOWN_MS = 45 * 60 * 1e3;
3539
+ const MONTHLY_COOLDOWN_MS = 24 * 60 * 60 * 1e3;
3540
+ function emptyCooldownState() {
3541
+ return { engineCooldowns: {} };
3542
+ }
3543
+ function loadCooldownState(statePath = currentStatePath()) {
3544
+ let raw;
3545
+ try {
3546
+ raw = fs.readFileSync(statePath, "utf-8");
3547
+ } catch {
3548
+ return emptyCooldownState();
3549
+ }
3550
+ try {
3551
+ const parsed = JSON.parse(raw);
3552
+ if (!parsed || typeof parsed !== "object" || typeof parsed.engineCooldowns !== "object") {
3553
+ return emptyCooldownState();
3554
+ }
3555
+ const cooldowns = parsed.engineCooldowns;
3556
+ const clean = {};
3557
+ for (const [stateKey, entry] of Object.entries(cooldowns)) {
3558
+ if (entry && typeof entry === "object" && typeof entry.until === "string") {
3559
+ const e = entry;
3560
+ const target = parseCooldownStateKey(stateKey);
3561
+ const key = cooldownStateKey(target.engine, target.keyIndex);
3562
+ const normalized = {
3563
+ until: e.until,
3564
+ reason: typeof e.reason === "string" ? e.reason : "",
3565
+ observedAt: typeof e.observedAt === "string" ? e.observedAt : ""
3566
+ };
3567
+ clean[key] = clean[key] ? laterEntry(clean[key], normalized) : normalized;
3568
+ }
3569
+ }
3570
+ return { engineCooldowns: clean };
3571
+ } catch {
3572
+ return emptyCooldownState();
3573
+ }
3574
+ }
3575
+ function laterEntry(existing, incoming) {
3576
+ if (!existing) {
3577
+ return incoming;
3578
+ }
3579
+ const existingUntil = Date.parse(existing.until);
3580
+ const incomingUntil = Date.parse(incoming.until);
3581
+ return Number.isFinite(existingUntil) && existingUntil > incomingUntil ? existing : incoming;
3582
+ }
3583
+ function updateStateOnDisk(statePath, mutate) {
3584
+ const merged = loadCooldownState(statePath);
3585
+ const before = JSON.stringify(merged);
3586
+ mutate(merged);
3587
+ if (JSON.stringify(merged) === before) {
3588
+ return merged;
3589
+ }
3590
+ const dir = path.dirname(statePath);
3591
+ if (!fs.existsSync(dir)) {
3592
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
3593
+ try {
3594
+ fs.chmodSync(dir, 448);
3595
+ } catch {
3596
+ }
3597
+ }
3598
+ const unique = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
3599
+ const tmp = path.join(dir, `.state.${unique}.tmp`);
3600
+ fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}
3601
+ `, { mode: 384 });
3602
+ fs.renameSync(tmp, statePath);
3603
+ try {
3604
+ fs.chmodSync(statePath, 384);
3605
+ } catch {
3606
+ }
3607
+ return merged;
3608
+ }
3609
+ function coolingEntry(state2, engine, now, keyIndex) {
3610
+ const active = (stateKey) => {
3611
+ const entry2 = state2.engineCooldowns[stateKey];
3612
+ if (!entry2) {
3613
+ return void 0;
3614
+ }
3615
+ const until = Date.parse(entry2.until);
3616
+ return Number.isFinite(until) && until > now.getTime() ? entry2 : void 0;
3617
+ };
3618
+ const entry = active(cooldownStateKey(engine, keyIndex));
3619
+ if (entry || keyIndex === void 0) {
3620
+ return entry;
3621
+ }
3622
+ return active(cooldownStateKey(engine));
3623
+ }
3624
+ function coolingEngineEntry(state2, engine, keyCount, now) {
3625
+ if (keyCount <= 0) {
3626
+ return coolingEntry(state2, engine, now);
3627
+ }
3628
+ let representative;
3629
+ for (let keyIndex = 0; keyIndex < keyCount; keyIndex += 1) {
3630
+ const entry = coolingEntry(state2, engine, now, keyIndex);
3631
+ if (!entry) {
3632
+ return void 0;
3633
+ }
3634
+ representative = laterEntry(representative, entry);
3635
+ }
3636
+ return representative;
3637
+ }
3638
+ function classifyQuota(error, now) {
3639
+ if (!(error instanceof ApiKeyFailureError) || error.quotaCooldown === "none") {
3640
+ return null;
3641
+ }
3642
+ const fallbackMs = error.quotaCooldown === "monthly" ? MONTHLY_COOLDOWN_MS : DEFAULT_COOLDOWN_MS;
3643
+ return new Date(now.getTime() + (error.resetAfterMs ?? fallbackMs));
3644
+ }
3645
+ function recordQuotaCooldown(state2, engine, error, now, statePath, onPersistError, keyIndex, knownSecrets = []) {
3646
+ const until = classifyQuota(error, now);
3647
+ if (!until) {
3648
+ return null;
3649
+ }
3650
+ const reason = redactSecrets(
3651
+ error instanceof Error ? error.message : String(error),
3652
+ knownSecrets
3653
+ ).slice(0, 300);
3654
+ const entry = {
3655
+ until: until.toISOString(),
3656
+ reason,
3657
+ observedAt: now.toISOString()
3658
+ };
3659
+ const stateKey = cooldownStateKey(engine, keyIndex);
3660
+ try {
3661
+ const merged = updateStateOnDisk(statePath, (disk) => {
3662
+ disk.engineCooldowns[stateKey] = laterEntry(disk.engineCooldowns[stateKey], entry);
3663
+ });
3664
+ const persisted = merged.engineCooldowns[stateKey];
3665
+ state2.engineCooldowns[stateKey] = persisted;
3666
+ return persisted;
3667
+ } catch (persistError) {
3668
+ state2.engineCooldowns[stateKey] = entry;
3669
+ onPersistError?.(persistError);
3670
+ return entry;
3671
+ }
3672
+ }
3673
+ function clearEngineCooldown(state2, engine, statePath, onPersistError, keyIndex) {
3674
+ const stateKey = cooldownStateKey(engine, keyIndex);
3675
+ const legacyKey = cooldownStateKey(engine);
3676
+ const keysToDelete = keyIndex === void 0 ? [stateKey] : [stateKey, legacyKey];
3677
+ const hadInMemory = keysToDelete.some((key) => key in state2.engineCooldowns);
3678
+ for (const key of keysToDelete) {
3679
+ delete state2.engineCooldowns[key];
3680
+ }
3681
+ try {
3682
+ updateStateOnDisk(statePath, (disk) => {
3683
+ for (const key of keysToDelete) {
3684
+ delete disk.engineCooldowns[key];
3685
+ }
3686
+ });
3687
+ return hadInMemory;
3688
+ } catch (persistError) {
3689
+ onPersistError?.(persistError);
3690
+ return hadInMemory;
3691
+ }
3692
+ }
3693
+ function clearAllCooldowns(statePath = currentStatePath()) {
3694
+ fs.rmSync(statePath, { force: true });
3695
+ }
3696
+ function buildCooldownController(config2, opts = {}) {
3697
+ if (!cooldownEnabled(config2)) {
3698
+ return void 0;
3699
+ }
3700
+ const statePath = opts.statePath ?? currentStatePath();
3701
+ const now = opts.now ?? /* @__PURE__ */ new Date();
3702
+ const state2 = loadCooldownState(statePath);
3703
+ const warnings = [];
3704
+ const persistNote = (persistError) => {
3705
+ const message = persistError instanceof Error ? persistError.message : String(persistError);
3706
+ warnings.push(
3707
+ `Cooldown state could not be saved (${message}); failover still works, but the next run will rediscover this quota wall.`
3708
+ );
3709
+ };
3710
+ return {
3711
+ state: state2,
3712
+ now,
3713
+ warnings,
3714
+ record: (engine, error, keyIndex, knownSecrets) => recordQuotaCooldown(
3715
+ state2,
3716
+ engine,
3717
+ error,
3718
+ now,
3719
+ statePath,
3720
+ persistNote,
3721
+ keyIndex,
3722
+ knownSecrets
3723
+ ),
3724
+ clear: (engine, keyIndex) => {
3725
+ clearEngineCooldown(state2, engine, statePath, persistNote, keyIndex);
3726
+ }
3727
+ };
3728
+ }
3415
3729
  const DEFAULT_TIMEOUT_MS = 18e4;
3416
3730
  const KILL_GRACE_MS = 3e4;
3417
3731
  const DRAIN_GRACE_MS = 500;
@@ -3423,7 +3737,8 @@ async function analyzeImage(options) {
3423
3737
  }
3424
3738
  const config2 = options.config ?? loadConfigFile();
3425
3739
  assertReadableConfig(config2);
3426
- const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions);
3740
+ const controller = options.cooldown;
3741
+ const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions, controller);
3427
3742
  const named = options.provider ?? config2.provider?.trim();
3428
3743
  if (named) {
3429
3744
  try {
@@ -3443,30 +3758,131 @@ async function analyzeImage(options) {
3443
3758
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3444
3759
  const attempts = [];
3445
3760
  const warnings = [];
3761
+ if (controller && !options.provider && !options.providerBin) {
3762
+ for (const provider of chain) {
3763
+ const keyCount = splitApiKeys(
3764
+ resolveProviderSettings(provider.name, config2).apiKey
3765
+ ).length;
3766
+ const entry = coolingEngineEntry(
3767
+ controller.state,
3768
+ provider.name,
3769
+ keyCount,
3770
+ controller.now
3771
+ );
3772
+ if (entry) {
3773
+ warnings.push(
3774
+ `The ${provider.name} provider is cooling until ${entry.until}, so it moves to the back of the fallback chain.`
3775
+ );
3776
+ }
3777
+ }
3778
+ }
3446
3779
  let lastError;
3447
3780
  for (const provider of chain) {
3448
- const startedAt = Date.now();
3449
- const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
3450
- try {
3451
- const parsed = await runProvider(
3452
- provider,
3453
- model,
3454
- options,
3455
- resolvedInput,
3456
- timeoutMs,
3457
- config2,
3458
- warnings
3459
- );
3460
- attempts.push({
3461
- provider: provider.name,
3462
- ok: true,
3463
- durationSeconds: (Date.now() - startedAt) / 1e3
3464
- });
3465
- if (provider.reuseNote) {
3466
- warnings.push(provider.reuseNote);
3781
+ const configured = resolveProviderSettings(provider.name, config2);
3782
+ const settingsBase = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
3783
+ const firstProvider = attempts.every((attempt) => attempt.provider === provider.name);
3784
+ const model = (firstProvider ? options.model : void 0) || settingsBase.model || provider.defaultModel;
3785
+ const apiKeys = splitApiKeys(settingsBase.apiKey);
3786
+ const configuredKeyRuns = apiKeys.length > 0 ? apiKeys.map((apiKey, keyIndex) => ({ apiKey, keyIndex })) : [
3787
+ {
3788
+ apiKey: void 0,
3789
+ keyIndex: void 0
3467
3790
  }
3468
- if (attempts.length > 1) {
3469
- const failed = attempts.slice(0, -1);
3791
+ ];
3792
+ const keyRuns = controller ? [
3793
+ ...configuredKeyRuns.filter(
3794
+ (run) => run.keyIndex === void 0 || !coolingEntry(
3795
+ controller.state,
3796
+ provider.name,
3797
+ controller.now,
3798
+ run.keyIndex
3799
+ )
3800
+ ),
3801
+ ...configuredKeyRuns.filter(
3802
+ (run) => run.keyIndex !== void 0 && coolingEntry(
3803
+ controller.state,
3804
+ provider.name,
3805
+ controller.now,
3806
+ run.keyIndex
3807
+ )
3808
+ )
3809
+ ] : configuredKeyRuns;
3810
+ let parsed;
3811
+ let successfulStartedAt = 0;
3812
+ let successfulKeyIndex;
3813
+ for (let runIndex = 0; runIndex < keyRuns.length; runIndex += 1) {
3814
+ const keyRun = keyRuns[runIndex];
3815
+ const startedAt = Date.now();
3816
+ try {
3817
+ parsed = await runProvider(
3818
+ provider,
3819
+ model,
3820
+ options,
3821
+ resolvedInput,
3822
+ timeoutMs,
3823
+ { ...settingsBase, apiKey: keyRun.apiKey },
3824
+ warnings,
3825
+ apiKeys
3826
+ );
3827
+ successfulStartedAt = startedAt;
3828
+ successfulKeyIndex = keyRun.keyIndex;
3829
+ break;
3830
+ } catch (error) {
3831
+ const message = redactSecrets(
3832
+ error instanceof Error ? error.message : String(error),
3833
+ apiKeys
3834
+ );
3835
+ if (error instanceof Error) {
3836
+ error.message = message;
3837
+ }
3838
+ lastError = error;
3839
+ attempts.push({
3840
+ provider: provider.name,
3841
+ ...apiKeys.length > 1 ? { keyIndex: keyRun.keyIndex } : {},
3842
+ ok: false,
3843
+ durationSeconds: (Date.now() - startedAt) / 1e3,
3844
+ error: message.slice(0, 300)
3845
+ });
3846
+ if (controller) {
3847
+ const entry = controller.record(provider.name, error, keyRun.keyIndex, apiKeys);
3848
+ if (entry) {
3849
+ const keyNote = keyRun.keyIndex === void 0 ? "" : ` API key ${keyRun.keyIndex + 1}`;
3850
+ warnings.push(
3851
+ `The ${provider.name} provider${keyNote} hit its quota and is now cooling until ${entry.until}.`
3852
+ );
3853
+ }
3854
+ }
3855
+ const hasNextKey = runIndex + 1 < keyRuns.length;
3856
+ if (hasNextKey && isApiKeyFailure(error)) {
3857
+ continue;
3858
+ }
3859
+ break;
3860
+ }
3861
+ }
3862
+ if (!parsed) {
3863
+ continue;
3864
+ }
3865
+ attempts.push({
3866
+ provider: provider.name,
3867
+ ...apiKeys.length > 1 ? { keyIndex: successfulKeyIndex } : {},
3868
+ ok: true,
3869
+ durationSeconds: (Date.now() - successfulStartedAt) / 1e3
3870
+ });
3871
+ controller?.clear(provider.name, successfulKeyIndex);
3872
+ if (provider.reuseNote) {
3873
+ warnings.push(provider.reuseNote);
3874
+ }
3875
+ warnings.push(...controller?.warnings ?? []);
3876
+ const failed = attempts.filter((attempt) => !attempt.ok);
3877
+ if (failed.length > 0) {
3878
+ const rotatedWithinProvider = failed.every((attempt) => attempt.provider === provider.name) && successfulKeyIndex !== void 0;
3879
+ if (rotatedWithinProvider && successfulKeyIndex !== void 0) {
3880
+ warnings.push(
3881
+ `Rotated to ${provider.name} API key ${successfulKeyIndex + 1} after: ${failed.map(
3882
+ (attempt) => `${attempt.provider}${attempt.keyIndex === void 0 ? "" : ` (API key ${attempt.keyIndex + 1})`}: ${attempt.error}`
3883
+ ).join(" | ")}`
3884
+ );
3885
+ } else {
3470
3886
  warnings.push(
3471
3887
  `Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
3472
3888
  );
@@ -3476,35 +3892,24 @@ async function analyzeImage(options) {
3476
3892
  );
3477
3893
  }
3478
3894
  }
3479
- return {
3480
- image: resolvedInput.source,
3481
- provider: provider.name,
3482
- result: parsed.result,
3483
- meta: {
3484
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3485
- // Empty means the provider ran whatever it was already
3486
- // configured with and never told us which (kimi-cli), so
3487
- // the field says unknown rather than naming nothing.
3488
- model: model === "" ? null : model,
3489
- conversationId: parsed.meta.conversationId,
3490
- durationSeconds: parsed.meta.durationSeconds,
3491
- usage: parsed.meta.usage,
3492
- attempts,
3493
- warnings
3494
- }
3495
- };
3496
- } catch (error) {
3497
- lastError = error;
3498
- const message = error instanceof Error ? error.message : String(error);
3499
- attempts.push({
3500
- provider: provider.name,
3501
- ok: false,
3502
- durationSeconds: (Date.now() - startedAt) / 1e3,
3503
- // Providers redact their own errors, but attempts travel into
3504
- // output and model contexts, so the record gets the belt too.
3505
- error: redactSecrets(message).slice(0, 300)
3506
- });
3507
3895
  }
3896
+ return {
3897
+ image: resolvedInput.source,
3898
+ provider: provider.name,
3899
+ result: parsed.result,
3900
+ meta: {
3901
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3902
+ // Empty means the provider ran whatever it was already
3903
+ // configured with and never told us which (kimi-cli), so
3904
+ // the field says unknown rather than naming nothing.
3905
+ model: model === "" ? null : model,
3906
+ conversationId: parsed.meta.conversationId,
3907
+ durationSeconds: parsed.meta.durationSeconds,
3908
+ usage: parsed.meta.usage,
3909
+ attempts,
3910
+ warnings
3911
+ }
3912
+ };
3508
3913
  }
3509
3914
  if (chain.length === 1) {
3510
3915
  if (!options.provider && !options.providerBin && lastError instanceof Error) {
@@ -3520,7 +3925,7 @@ async function analyzeImage(options) {
3520
3925
  );
3521
3926
  }
3522
3927
  const INLINE_REGION = /* @__PURE__ */ new Set(["gemini-api", "openai", "anthropic"]);
3523
- function composeChain(kind, config2, autoOptions) {
3928
+ function composeChain(kind, config2, autoOptions, cooldown) {
3524
3929
  const chain = [...providerChain(kind, config2, autoOptions?.env ?? process.env)];
3525
3930
  const borrowed = reuseProviders(kind, config2, autoOptions);
3526
3931
  let preferredName = null;
@@ -3541,7 +3946,40 @@ function composeChain(kind, config2, autoOptions) {
3541
3946
  const beforeClaude = last?.name === "claude-cli" && preferredName !== "claude-cli";
3542
3947
  chain.splice(beforeClaude ? chain.length - 1 : chain.length, 0, ...borrowed.agents);
3543
3948
  }
3544
- return chain;
3949
+ if (!cooldown) {
3950
+ return chain;
3951
+ }
3952
+ return reorderByCooldown(chain, cooldown, config2, autoOptions?.env ?? process.env);
3953
+ }
3954
+ function reorderByCooldown(chain, cooldown, config2, env) {
3955
+ const tagged = chain.map((provider) => {
3956
+ const keyCount = splitApiKeys(
3957
+ resolveProviderSettings(provider.name, config2, env).apiKey
3958
+ ).length;
3959
+ return {
3960
+ provider,
3961
+ inline: !provider.isolateWorkdir,
3962
+ cooling: Boolean(
3963
+ coolingEngineEntry(cooldown.state, provider.name, keyCount, cooldown.now)
3964
+ )
3965
+ };
3966
+ });
3967
+ const healthyThenCooling = (items) => [
3968
+ ...items.filter((item) => !item.cooling),
3969
+ ...items.filter((item) => item.cooling)
3970
+ ];
3971
+ const inline = healthyThenCooling(tagged.filter((item) => item.inline));
3972
+ const agents = healthyThenCooling(tagged.filter((item) => !item.inline));
3973
+ const lead = tagged[0];
3974
+ if (lead && !lead.inline && !lead.cooling) {
3975
+ const restAgents = agents.filter((item) => item.provider.name !== lead.provider.name);
3976
+ return [
3977
+ lead.provider,
3978
+ ...inline.map((item) => item.provider),
3979
+ ...restAgents.map((item) => item.provider)
3980
+ ];
3981
+ }
3982
+ return [...inline.map((item) => item.provider), ...agents.map((item) => item.provider)];
3545
3983
  }
3546
3984
  const REUSE_KEY_BY_HARNESS = {
3547
3985
  codex: "codex",
@@ -3583,9 +4021,7 @@ function reuseHint(config2, autoOptions) {
3583
4021
  return "";
3584
4022
  }
3585
4023
  }
3586
- async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
3587
- const configured = resolveProviderSettings(provider.name, config2);
3588
- const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
4024
+ async function runProvider(provider, model, options, resolvedInput, timeoutMs, settings, warnings, apiKeySecrets = []) {
3589
4025
  if (settings.extraBody && !provider.execute) {
3590
4026
  warnings.push(
3591
4027
  `${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
@@ -3599,7 +4035,8 @@ async function runProvider(provider, model, options, resolvedInput, timeoutMs, c
3599
4035
  providerBin: options.providerBin,
3600
4036
  workdir: options.workdir,
3601
4037
  timeoutMs,
3602
- settings
4038
+ settings,
4039
+ apiKeySecrets
3603
4040
  };
3604
4041
  let parsed;
3605
4042
  if (provider.execute) {
@@ -3752,6 +4189,11 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
3752
4189
  }
3753
4190
  if (code !== 0) {
3754
4191
  const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
4192
+ if (explained instanceof Error) {
4193
+ explained.message = redactSecrets(explained.message);
4194
+ reject(explained);
4195
+ return;
4196
+ }
3755
4197
  reject(
3756
4198
  new Error(
3757
4199
  redactSecrets(
@@ -4558,6 +5000,15 @@ function inspectProvider(descriptor, config2, env) {
4558
5000
  const settings = resolveProviderSettings(descriptor.name, config2, env);
4559
5001
  const settingsSource = providerConfiguredInFile(descriptor.name, config2) ? "file" : "env";
4560
5002
  const statuses = (descriptor.required ?? []).map((req) => {
5003
+ if (req.field === "apiKey") {
5004
+ const keys = splitApiKeys(settings.apiKey);
5005
+ return {
5006
+ field: req.field,
5007
+ present: keys.length > 0,
5008
+ source: keys.length > 0 ? settingsSource : "missing",
5009
+ ...keys.length > 0 ? { keyCount: keys.length } : {}
5010
+ };
5011
+ }
4561
5012
  const value = settings[req.field]?.trim();
4562
5013
  return {
4563
5014
  field: req.field,
@@ -4567,7 +5018,12 @@ function inspectProvider(descriptor, config2, env) {
4567
5018
  });
4568
5019
  const missing = statuses.filter((s) => !s.present).map((s) => s.field);
4569
5020
  const ready = missing.length === 0;
4570
- const detail = ready ? statuses.map((s) => `${s.field}: ${s.source}`).join(", ") : `missing: ${missing.join(", ")}`;
5021
+ const detail = ready ? statuses.map((s) => {
5022
+ if (s.field === "apiKey" && s.present && s.keyCount) {
5023
+ return `${s.field}: ${s.source} (${s.keyCount} ${s.keyCount === 1 ? "key" : "keys"})`;
5024
+ }
5025
+ return `${s.field}: ${s.source}`;
5026
+ }).join(", ") : `missing: ${missing.join(", ")}`;
4571
5027
  return {
4572
5028
  name: descriptor.name,
4573
5029
  kind: "api",
@@ -4622,9 +5078,45 @@ function inspectConfigFile(configPath) {
4622
5078
  };
4623
5079
  }
4624
5080
  }
5081
+ function diagnoseCooldown(config2, statePath, now, env) {
5082
+ if (!cooldownEnabled(config2)) {
5083
+ return { enabled: false, statePath, providers: [] };
5084
+ }
5085
+ const state2 = loadCooldownState(statePath);
5086
+ const secrets = knownApiKeys(config2, env);
5087
+ const providers = [];
5088
+ for (const stateKey of Object.keys(state2.engineCooldowns)) {
5089
+ const target = parseCooldownStateKey(stateKey);
5090
+ const entry = coolingEntry(state2, target.engine, now, target.keyIndex);
5091
+ if (entry) {
5092
+ providers.push({
5093
+ provider: target.engine,
5094
+ ...target.keyIndex === void 0 ? {} : { keyIndex: target.keyIndex },
5095
+ until: entry.until,
5096
+ remaining: formatRemaining(Date.parse(entry.until) - now.getTime()),
5097
+ reason: redactSecrets(entry.reason, secrets).split("\n")[0].slice(0, 120)
5098
+ });
5099
+ }
5100
+ }
5101
+ providers.sort(
5102
+ (a, b) => a.provider.localeCompare(b.provider) || (a.keyIndex ?? -1) - (b.keyIndex ?? -1)
5103
+ );
5104
+ return { enabled: true, statePath, providers };
5105
+ }
5106
+ function formatRemaining(ms) {
5107
+ if (ms <= 0) {
5108
+ return "0m";
5109
+ }
5110
+ const totalMinutes = Math.round(ms / 6e4);
5111
+ const hours = Math.floor(totalMinutes / 60);
5112
+ const minutes = totalMinutes % 60;
5113
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
5114
+ }
4625
5115
  function buildDoctorReport(input) {
4626
5116
  const env = input.env ?? process.env;
4627
5117
  const configPath = input.configPath ?? CONFIG_PATH;
5118
+ const statePath = input.statePath ?? currentStatePath();
5119
+ const now = input.now ?? /* @__PURE__ */ new Date();
4628
5120
  assertReadableConfig(input.config, configPath);
4629
5121
  const harnessDetection = detectHarnessDetailed();
4630
5122
  const guardDetection = detectActiveModel({
@@ -4635,6 +5127,7 @@ function buildDoctorReport(input) {
4635
5127
  const guardVerdict = evaluateGuard(input.config.guards, guardDetection);
4636
5128
  const reuseDiscovery = discoverAuto({ env, fresh: true, ...input.auto });
4637
5129
  const reuseOptions = { env, ...input.auto, discovery: reuseDiscovery };
5130
+ const cooldown = cooldownEnabled(input.config) ? { state: loadCooldownState(statePath), now } : void 0;
4638
5131
  return {
4639
5132
  node: {
4640
5133
  version: process.version,
@@ -4648,8 +5141,10 @@ function buildDoctorReport(input) {
4648
5141
  // labeled, so a machine living entirely on granted logins does not
4649
5142
  // read as "no engine" right next to a granted Reuse section.
4650
5143
  chains: {
4651
- local: composeChain("local", input.config, reuseOptions).map(chainEntryName),
4652
- remote: composeChain("remote", input.config, reuseOptions).map(chainEntryName)
5144
+ local: composeChain("local", input.config, reuseOptions, cooldown).map(chainEntryName),
5145
+ remote: composeChain("remote", input.config, reuseOptions, cooldown).map(
5146
+ chainEntryName
5147
+ )
4653
5148
  },
4654
5149
  harness: { detected: harnessDetection.harness, source: harnessDetection.source },
4655
5150
  skillInstalls: input.version ? findSkillInstalls(input.version, input.home) : [],
@@ -4664,6 +5159,7 @@ function buildDoctorReport(input) {
4664
5159
  reason: guardVerdict.reason
4665
5160
  },
4666
5161
  config: inspectConfigFile(configPath),
5162
+ cooldown: diagnoseCooldown(input.config, statePath, now, env),
4667
5163
  reuse: {
4668
5164
  decisions: Object.fromEntries(
4669
5165
  REUSE_HARNESSES.map((harness) => {
@@ -4712,6 +5208,23 @@ function renderDoctorReport(report) {
4712
5208
  lines.push(` local: ${chainLine(report.chains.local)}`);
4713
5209
  lines.push(` remote: ${chainLine(report.chains.remote)}`);
4714
5210
  lines.push("");
5211
+ lines.push("Cooldown");
5212
+ if (!report.cooldown.enabled) {
5213
+ lines.push(" switch: off (state not consulted)");
5214
+ } else if (report.cooldown.providers.length === 0) {
5215
+ lines.push(" switch: on");
5216
+ lines.push(" no providers are cooling right now");
5217
+ } else {
5218
+ lines.push(" switch: on");
5219
+ for (const c of report.cooldown.providers) {
5220
+ const label = c.keyIndex === void 0 ? c.provider : `${c.provider} key ${c.keyIndex + 1}`;
5221
+ lines.push(` - ${label.padEnd(16)} cooling, ${c.remaining} left (until ${c.until})`);
5222
+ if (c.reason) {
5223
+ lines.push(` reason: ${c.reason}`);
5224
+ }
5225
+ }
5226
+ }
5227
+ lines.push("");
4715
5228
  lines.push("Harness");
4716
5229
  lines.push(
4717
5230
  report.harness.detected ? ` ${report.harness.detected} (via ${report.harness.source})` : ` none detected (${report.harness.source})`
@@ -4720,8 +5233,8 @@ function renderDoctorReport(report) {
4720
5233
  if (report.skillInstalls.length > 0) {
4721
5234
  lines.push("Installed skill copies (a copy keeps its install-time version)");
4722
5235
  for (const install of report.skillInstalls) {
4723
- const state = install.pinned === null ? "no pin found" : `pins ${install.pinned}`;
4724
- lines.push(` ${install.harness}: ${state}${install.outdated ? " [outdated]" : ""}`);
5236
+ const state2 = install.pinned === null ? "no pin found" : `pins ${install.pinned}`;
5237
+ lines.push(` ${install.harness}: ${state2}${install.outdated ? " [outdated]" : ""}`);
4725
5238
  }
4726
5239
  if (report.skillInstalls.some((install) => install.outdated)) {
4727
5240
  lines.push(" Refresh an outdated copy by re-running the install: it overwrites in");
@@ -5007,7 +5520,7 @@ function parsePositiveInt(raw, flag) {
5007
5520
  }
5008
5521
  return Number.parseInt(raw, 10);
5009
5522
  }
5010
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.23.1");
5523
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.24.0");
5011
5524
  program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
5012
5525
  "--extra-body <json>",
5013
5526
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -5036,7 +5549,8 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
5036
5549
  providerBin: options.providerBin,
5037
5550
  workdir: options.workdir,
5038
5551
  extraBody: options.extraBody ? parseExtraBody(options.extraBody, "--extra-body") : void 0,
5039
- config: config2
5552
+ config: config2,
5553
+ cooldown: buildCooldownController(config2)
5040
5554
  });
5041
5555
  const output = JSON.stringify(result, null, 2);
5042
5556
  if (options.output) {
@@ -5117,7 +5631,7 @@ program.command("doctor").description(
5117
5631
  configPath: CONFIG_PATH,
5118
5632
  // Lets doctor name an installed skill copy that is older than
5119
5633
  // the CLI reporting on it (issue #33).
5120
- version: "3.23.1"
5634
+ version: "3.24.0"
5121
5635
  });
5122
5636
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
5123
5637
  process.stdout.write(`${output}
@@ -5139,6 +5653,7 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
5139
5653
  `Created ${CONFIG_PATH}`,
5140
5654
  "Everything is optional. The usual ones:",
5141
5655
  " modlens config set provider <name> which provider analyzes images",
5656
+ " modlens config set cooldown on|off quota cooldown (on by default)",
5142
5657
  " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider settings",
5143
5658
  ` modlens config set <provider>.extraBody '{"thinking":{"type":"disabled"}}' vendor request fields`,
5144
5659
  ""
@@ -5219,4 +5734,21 @@ config.command("show").description("Print the effective config (file merged with
5219
5734
  process.exitCode = 1;
5220
5735
  }
5221
5736
  });
5737
+ const state = program.command("state").description("Manage the quota cooldown state at ~/.modlens/state.json");
5738
+ state.command("clear").description(
5739
+ "Forget every provider cooldown, so all providers are tried at full priority again"
5740
+ ).action(() => {
5741
+ try {
5742
+ const statePath = currentStatePath();
5743
+ clearAllCooldowns(statePath);
5744
+ process.stdout.write(`Cleared cooldown state (${statePath}).
5745
+ `);
5746
+ } catch (error) {
5747
+ process.stderr.write(
5748
+ `Error: ${error instanceof Error ? error.message : String(error)}
5749
+ `
5750
+ );
5751
+ process.exitCode = 1;
5752
+ }
5753
+ });
5222
5754
  await program.parseAsync(process.argv, { from: "node" });