@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/CHANGELOG.md +6 -0
- package/README.md +13 -4
- package/README.zh-CN.md +12 -3
- package/dist/main.js +626 -94
- package/docs/cli.md +3 -2
- package/docs/cli.zh-CN.md +3 -2
- package/docs/harness-setup.md +24 -2
- package/docs/harness-setup.zh-CN.md +8 -2
- package/docs/security.md +4 -0
- package/docs/security.zh-CN.md +4 -0
- package/docs/troubleshooting.md +3 -3
- package/docs/troubleshooting.zh-CN.md +3 -3
- package/dsh/client.js +19 -0
- package/dsh/index.js +14 -3
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/configure.md +5 -3
- package/skills/modlens/references/configure.zh-CN.md +5 -3
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
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
|
|
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
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1310
|
-
|
|
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
|
|
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, [
|
|
1573
|
+
const quote = (shown, clip = truncate) => clip(redactSecrets(shown, [...apiKeySecrets, baseUrl]));
|
|
1505
1574
|
if (!response.ok) {
|
|
1506
|
-
const body = await response.text();
|
|
1507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1896
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2131
|
-
|
|
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" ?
|
|
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 ${
|
|
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(
|
|
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
|
|
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
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
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
|
-
|
|
3469
|
-
|
|
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
|
-
|
|
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,
|
|
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) =>
|
|
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(
|
|
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
|
|
4724
|
-
lines.push(` ${install.harness}: ${
|
|
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.
|
|
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.
|
|
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" });
|