codex-dev-mcp-suite 1.1.0 → 1.2.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/.env.example CHANGED
@@ -22,6 +22,9 @@
22
22
  # MCP_PROVIDER_CHAIN3_API_KEY=<openrouter-key>
23
23
  # MCP_PROVIDER_CHAIN3_MODEL=openai/gpt-4o-mini
24
24
 
25
+ # Cooldown (ms) before retrying a provider after a 429/5xx/timeout failure.
26
+ # MCP_PROVIDER_COOLDOWN_MS=60000
27
+
25
28
  # Base URL of an OpenAI-compatible API (must expose /v1/...)
26
29
  MCP_LLM_BASE_URL=http://localhost:11434/v1
27
30
  # API key for that endpoint (leave blank for local servers that don't need one)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 - 2026-06-15
4
+
5
+ ### Added
6
+ - CLI meta handling for all bins: `--help`, `--version`, and `--doctor` (config diagnostics with API keys redacted) before the stdio server starts.
7
+ - Provider diagnostics `providerChainDiagnostics()` that lists active providers with redacted keys and flags incomplete numbered slots.
8
+ - Per-provider cooldown on `429`/`5xx`/timeout/network failures via `MCP_PROVIDER_COOLDOWN_MS` (default `60000`).
9
+ - CI job that packs the tarball, installs it in a temp dir, and runs each bin `--version`.
10
+
11
+ ### Changed
12
+ - Rerank now skips providers that are cooling down and records success/failure outcomes.
13
+ - Rerank never logs API keys or response bodies.
14
+
3
15
  ## 1.1.0 - 2026-06-15
4
16
 
5
17
  ### Added
package/bin/_meta.mjs ADDED
@@ -0,0 +1,69 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ function pkgVersion() {
8
+ try {
9
+ const raw = fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8");
10
+ return JSON.parse(raw).version || "unknown";
11
+ } catch { return "unknown"; }
12
+ }
13
+
14
+ function redactKey(name) {
15
+ const v = String(process.env[name] || "");
16
+ return v ? `set (${v.length} chars)` : "not set";
17
+ }
18
+
19
+ function doctorLines(meta) {
20
+ const lines = [];
21
+ lines.push(`${meta.title} doctor`);
22
+ lines.push(`version: ${pkgVersion()}`);
23
+ lines.push(`node: ${process.version}`);
24
+ for (const [label, name] of meta.storage || []) {
25
+ lines.push(`${label}: ${process.env[name] || meta.storageDefaults?.[name] || "(default)"}`);
26
+ }
27
+ const det = /^(1|true|yes|on)$/i.test(String(process.env.MCP_DETERMINISTIC_FALLBACK || "").trim());
28
+ lines.push(`deterministic no-network: ${det ? "on" : "off"}`);
29
+ if (meta.usesModel) {
30
+ lines.push("model/provider config (keys redacted):");
31
+ lines.push(` MCP_LLM_BASE_URL: ${process.env.MCP_LLM_BASE_URL || "(unset)"}`);
32
+ lines.push(` MCP_LLM_API_KEY: ${redactKey("MCP_LLM_API_KEY")}`);
33
+ lines.push(` MCP_PROVIDER_PRIMARY: ${process.env.MCP_PROVIDER_PRIMARY || "(unset)"}`);
34
+ lines.push(` MCP_PROVIDER_PRIMARY_API_KEY: ${redactKey("MCP_PROVIDER_PRIMARY_API_KEY")}`);
35
+ lines.push(` MCP_EMBED_API_KEY: ${redactKey("MCP_EMBED_API_KEY")}`);
36
+ lines.push(` MCP_RERANK_API_KEY: ${redactKey("MCP_RERANK_API_KEY")}`);
37
+ } else {
38
+ lines.push("model/provider config: not used by this server");
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ export function handleCliMeta(meta) {
44
+ const argv = process.argv.slice(2);
45
+ if (argv.includes("-v") || argv.includes("--version")) {
46
+ process.stdout.write(`${pkgVersion()}\n`);
47
+ process.exit(0);
48
+ }
49
+ if (argv.includes("-h") || argv.includes("--help")) {
50
+ const out = [
51
+ `${meta.title} (${meta.bin}) v${pkgVersion()}`,
52
+ "",
53
+ "An MCP server that speaks JSON-RPC over stdio. Launch it from an MCP client.",
54
+ "",
55
+ "Usage:",
56
+ ` ${meta.bin} start the MCP stdio server`,
57
+ ` ${meta.bin} --version print version`,
58
+ ` ${meta.bin} --doctor print config diagnostics (API keys redacted)`,
59
+ ` ${meta.bin} --help show this help`,
60
+ ];
61
+ process.stdout.write(out.join("\n") + "\n");
62
+ process.exit(0);
63
+ }
64
+ if (argv.includes("--doctor")) {
65
+ process.stdout.write(doctorLines(meta).join("\n") + "\n");
66
+ process.exit(0);
67
+ }
68
+ return false;
69
+ }
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import "../checkpoint/server.js";
2
+ import { handleCliMeta } from "./_meta.mjs";
3
+ handleCliMeta({
4
+ bin: "checkpoint-mcp",
5
+ title: "Checkpoint MCP",
6
+ usesModel: false,
7
+ storage: [["store", "CHECKPOINT_DIR"]],
8
+ });
9
+ import("../checkpoint/server.js");
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import "../context-pack/server.js";
2
+ import { handleCliMeta } from "./_meta.mjs";
3
+ handleCliMeta({
4
+ bin: "context-pack-mcp",
5
+ title: "Context Pack MCP",
6
+ usesModel: false,
7
+ storage: [],
8
+ });
9
+ import("../context-pack/server.js");
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import "../devjournal/server.js";
2
+ import { handleCliMeta } from "./_meta.mjs";
3
+ handleCliMeta({
4
+ bin: "devjournal-mcp",
5
+ title: "Dev Journal MCP",
6
+ usesModel: true,
7
+ storage: [["store", "JOURNAL_DIR"]],
8
+ });
9
+ import("../devjournal/server.js");
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import "../project-memory/server.js";
2
+ import { handleCliMeta } from "./_meta.mjs";
3
+ handleCliMeta({
4
+ bin: "project-memory-mcp",
5
+ title: "Project Memory MCP",
6
+ usesModel: true,
7
+ storage: [["vault", "MEMORY_VAULT_DIR"]],
8
+ });
9
+ import("../project-memory/server.js");
@@ -7,6 +7,11 @@ function value(name) {
7
7
  return String(process.env[name] || "").trim();
8
8
  }
9
9
 
10
+ export function redactKey(key) {
11
+ const k = String(key || "");
12
+ return k ? `set (${k.length} chars)` : "not set";
13
+ }
14
+
10
15
  function providerEnv(prefix) {
11
16
  const label = value(prefix);
12
17
  const base = value(`${prefix}_BASE_URL`);
@@ -16,20 +21,34 @@ function providerEnv(prefix) {
16
21
  return { label, base, key, model };
17
22
  }
18
23
 
19
- function numberedProviders() {
20
- const providers = [];
21
- const primary = providerEnv("MCP_PROVIDER_PRIMARY");
22
- if (primary) providers.push(primary);
24
+ function slotIssues(prefix) {
25
+ const present = ["", "_BASE_URL", "_API_KEY", "_MODEL"].some((s) => value(`${prefix}${s}`));
26
+ if (!present) return null;
27
+ const missing = [];
28
+ if (!value(prefix)) missing.push(`${prefix}`);
29
+ if (!value(`${prefix}_BASE_URL`)) missing.push(`${prefix}_BASE_URL`);
30
+ if (!value(`${prefix}_API_KEY`)) missing.push(`${prefix}_API_KEY`);
31
+ if (!value(`${prefix}_MODEL`)) missing.push(`${prefix}_MODEL`);
32
+ return missing.length ? { prefix, missing } : null;
33
+ }
23
34
 
24
- const slots = Object.keys(process.env)
25
- .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)$/)?.[1])
35
+ function numberedPrefixes() {
36
+ const prefixes = ["MCP_PROVIDER_PRIMARY"];
37
+ const nums = Object.keys(process.env)
38
+ .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)/)?.[1])
26
39
  .filter(Boolean)
27
40
  .map(Number)
28
- .filter((n) => n >= 2)
29
- .sort((a, b) => a - b);
41
+ .filter((n) => n >= 2);
42
+ for (const n of [...new Set(nums)].sort((a, b) => a - b)) {
43
+ prefixes.push(`MCP_PROVIDER_CHAIN${n}`);
44
+ }
45
+ return prefixes;
46
+ }
30
47
 
31
- for (const n of slots) {
32
- const provider = providerEnv(`MCP_PROVIDER_CHAIN${n}`);
48
+ function numberedProviders() {
49
+ const providers = [];
50
+ for (const prefix of numberedPrefixes()) {
51
+ const provider = providerEnv(prefix);
33
52
  if (provider) providers.push(provider);
34
53
  }
35
54
  return providers;
@@ -54,3 +73,37 @@ export function providerChainConfig() {
54
73
  }
55
74
  return { providers, enabled: providers.length > 0, deterministic };
56
75
  }
76
+
77
+ export function providerChainDiagnostics() {
78
+ const cfg = providerChainConfig();
79
+ const issues = [];
80
+ for (const prefix of numberedPrefixes()) {
81
+ const issue = slotIssues(prefix);
82
+ if (issue) issues.push(issue);
83
+ }
84
+ return {
85
+ deterministic: cfg.deterministic,
86
+ enabled: cfg.enabled,
87
+ providers: cfg.providers.map((p) => ({ label: p.label, base: p.base, model: p.model, apiKey: redactKey(p.key) })),
88
+ issues,
89
+ };
90
+ }
91
+
92
+ const COOLDOWN_MS = Number(process.env.MCP_PROVIDER_COOLDOWN_MS || 60000);
93
+ const cooldowns = new Map();
94
+
95
+ export function isCoolingDown(key, now = Date.now()) {
96
+ const until = cooldowns.get(key);
97
+ if (!until) return false;
98
+ if (now >= until) { cooldowns.delete(key); return false; }
99
+ return true;
100
+ }
101
+
102
+ export function recordOutcome(key, ok, now = Date.now()) {
103
+ if (ok) { cooldowns.delete(key); return; }
104
+ cooldowns.set(key, now + COOLDOWN_MS);
105
+ }
106
+
107
+ export function _resetCooldowns() {
108
+ cooldowns.clear();
109
+ }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * LLM reranker via any OpenAI-compatible /v1/chat/completions model.
3
- * Used when embeddings are unavailable: keyword prefilter -> LLM picks the
4
- * most relevant candidates. Degrades gracefully: returns null on any failure
5
- * so callers fall back to keyword ordering. Never throws.
3
+ * Tries provider chain slots in order, skipping any currently cooling down
4
+ * after a recent failure. Degrades gracefully: returns null on any failure so
5
+ * callers fall back to keyword ordering. Never throws. Never logs keys/bodies.
6
6
  */
7
7
  import http from "http";
8
8
  import https from "https";
9
9
  import { URL } from "url";
10
10
  import { deterministicEnabled } from "./env.js";
11
- import { providerChainConfig } from "./provider-chain.js";
11
+ import { providerChainConfig, isCoolingDown, recordOutcome } from "./provider-chain.js";
12
12
 
13
13
  const TIMEOUT_MS = Number(process.env.RERANK_TIMEOUT_MS || 30000);
14
14
  const ENABLED = process.env.RERANK_ENABLED !== "0";
@@ -25,10 +25,19 @@ export function rerankConfig() {
25
25
  };
26
26
  }
27
27
 
28
+ function providerKey(provider) {
29
+ return `${provider.label}|${provider.base}|${provider.model}`;
30
+ }
31
+
32
+ /**
33
+ * Resolves { content, retryable }. retryable=true means try the next provider
34
+ * (timeout, network error, 429, or 5xx). retryable=false with null content
35
+ * means a definitive non-retryable response (e.g. 4xx auth).
36
+ */
28
37
  function chatWithProvider(provider, messages) {
29
38
  return new Promise((resolve) => {
30
39
  let u;
31
- try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve(null); }
40
+ try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve({ content: null, retryable: false }); }
32
41
  const payload = Buffer.from(JSON.stringify({ model: provider.model, stream: false, temperature: 0, max_tokens: 200, messages }));
33
42
  const lib = u.protocol === "https:" ? https : http;
34
43
  const req = lib.request(
@@ -44,10 +53,14 @@ function chatWithProvider(provider, messages) {
44
53
  let body = "";
45
54
  res.on("data", (c) => (body += c));
46
55
  res.on("end", () => {
47
- if (res.statusCode !== 200) return resolve(null);
56
+ const status = res.statusCode || 0;
57
+ if (status !== 200) {
58
+ const retryable = status === 429 || status >= 500;
59
+ return resolve({ content: null, retryable });
60
+ }
48
61
  try {
49
62
  const j = JSON.parse(body);
50
- return resolve(j?.choices?.[0]?.message?.content ?? null);
63
+ return resolve({ content: j?.choices?.[0]?.message?.content ?? null, retryable: false });
51
64
  } catch { /* maybe SSE */ }
52
65
  try {
53
66
  let acc = "";
@@ -59,13 +72,13 @@ function chatWithProvider(provider, messages) {
59
72
  const j = JSON.parse(d);
60
73
  acc += j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
61
74
  }
62
- return resolve(acc || null);
63
- } catch { return resolve(null); }
75
+ return resolve({ content: acc || null, retryable: false });
76
+ } catch { return resolve({ content: null, retryable: true }); }
64
77
  });
65
78
  }
66
79
  );
67
- req.on("error", () => resolve(null));
68
- req.on("timeout", () => { req.destroy(); resolve(null); });
80
+ req.on("error", () => resolve({ content: null, retryable: true }));
81
+ req.on("timeout", () => { req.destroy(); resolve({ content: null, retryable: true }); });
69
82
  req.write(payload);
70
83
  req.end();
71
84
  });
@@ -75,8 +88,11 @@ async function chat(messages) {
75
88
  if (!ENABLED || deterministicEnabled()) return null;
76
89
  const { providers } = providerChainConfig();
77
90
  for (const provider of providers) {
78
- const out = await chatWithProvider(provider, messages);
79
- if (out) return out;
91
+ const key = providerKey(provider);
92
+ if (isCoolingDown(key)) continue;
93
+ const { content, retryable } = await chatWithProvider(provider, messages);
94
+ if (content) { recordOutcome(key, true); return content; }
95
+ if (retryable) recordOutcome(key, false);
80
96
  }
81
97
  return null;
82
98
  }
@@ -73,6 +73,20 @@ MCP_PROVIDER_CHAIN3_MODEL=openai/gpt-4o-mini
73
73
 
74
74
  You can add `MCP_PROVIDER_CHAIN4_*`, `MCP_PROVIDER_CHAIN5_*`, and so on. The provider name is only a label; any OpenAI-compatible endpoint works when `BASE_URL`, `API_KEY`, and `MODEL` are set.
75
75
 
76
+ On a `429`, `5xx`, timeout, or network error, a provider is put on a short cooldown and the next slot is tried. Tune the window with `MCP_PROVIDER_COOLDOWN_MS` (default `60000`).
77
+
78
+ ## Diagnostics
79
+
80
+ Each server bin supports inspection flags that never start the server:
81
+
82
+ ```bash
83
+ project-memory-mcp --version
84
+ project-memory-mcp --doctor # storage + model/provider config, API keys redacted
85
+ project-memory-mcp --help
86
+ ```
87
+
88
+ `--doctor` reports configured providers and storage paths and shows API keys only as `set (<n> chars)` or `not set`, never the raw value.
89
+
76
90
  Example OpenRouter-style config:
77
91
 
78
92
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-dev-mcp-suite",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Four local, file-based MCP servers for solo devs/vibecoders: persistent project memory, session handoff/resume, git-independent file checkpoints, and token-efficient project briefings. Works with any MCP client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "files": [
44
44
  "bin/*.mjs",
45
+ "bin/_meta.mjs",
45
46
  "project-memory/*.js",
46
47
  "project-memory/server.js",
47
48
  "project-memory/embedding.js",
@@ -7,6 +7,11 @@ function value(name) {
7
7
  return String(process.env[name] || "").trim();
8
8
  }
9
9
 
10
+ export function redactKey(key) {
11
+ const k = String(key || "");
12
+ return k ? `set (${k.length} chars)` : "not set";
13
+ }
14
+
10
15
  function providerEnv(prefix) {
11
16
  const label = value(prefix);
12
17
  const base = value(`${prefix}_BASE_URL`);
@@ -16,20 +21,34 @@ function providerEnv(prefix) {
16
21
  return { label, base, key, model };
17
22
  }
18
23
 
19
- function numberedProviders() {
20
- const providers = [];
21
- const primary = providerEnv("MCP_PROVIDER_PRIMARY");
22
- if (primary) providers.push(primary);
24
+ function slotIssues(prefix) {
25
+ const present = ["", "_BASE_URL", "_API_KEY", "_MODEL"].some((s) => value(`${prefix}${s}`));
26
+ if (!present) return null;
27
+ const missing = [];
28
+ if (!value(prefix)) missing.push(`${prefix}`);
29
+ if (!value(`${prefix}_BASE_URL`)) missing.push(`${prefix}_BASE_URL`);
30
+ if (!value(`${prefix}_API_KEY`)) missing.push(`${prefix}_API_KEY`);
31
+ if (!value(`${prefix}_MODEL`)) missing.push(`${prefix}_MODEL`);
32
+ return missing.length ? { prefix, missing } : null;
33
+ }
23
34
 
24
- const slots = Object.keys(process.env)
25
- .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)$/)?.[1])
35
+ function numberedPrefixes() {
36
+ const prefixes = ["MCP_PROVIDER_PRIMARY"];
37
+ const nums = Object.keys(process.env)
38
+ .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)/)?.[1])
26
39
  .filter(Boolean)
27
40
  .map(Number)
28
- .filter((n) => n >= 2)
29
- .sort((a, b) => a - b);
41
+ .filter((n) => n >= 2);
42
+ for (const n of [...new Set(nums)].sort((a, b) => a - b)) {
43
+ prefixes.push(`MCP_PROVIDER_CHAIN${n}`);
44
+ }
45
+ return prefixes;
46
+ }
30
47
 
31
- for (const n of slots) {
32
- const provider = providerEnv(`MCP_PROVIDER_CHAIN${n}`);
48
+ function numberedProviders() {
49
+ const providers = [];
50
+ for (const prefix of numberedPrefixes()) {
51
+ const provider = providerEnv(prefix);
33
52
  if (provider) providers.push(provider);
34
53
  }
35
54
  return providers;
@@ -54,3 +73,37 @@ export function providerChainConfig() {
54
73
  }
55
74
  return { providers, enabled: providers.length > 0, deterministic };
56
75
  }
76
+
77
+ export function providerChainDiagnostics() {
78
+ const cfg = providerChainConfig();
79
+ const issues = [];
80
+ for (const prefix of numberedPrefixes()) {
81
+ const issue = slotIssues(prefix);
82
+ if (issue) issues.push(issue);
83
+ }
84
+ return {
85
+ deterministic: cfg.deterministic,
86
+ enabled: cfg.enabled,
87
+ providers: cfg.providers.map((p) => ({ label: p.label, base: p.base, model: p.model, apiKey: redactKey(p.key) })),
88
+ issues,
89
+ };
90
+ }
91
+
92
+ const COOLDOWN_MS = Number(process.env.MCP_PROVIDER_COOLDOWN_MS || 60000);
93
+ const cooldowns = new Map();
94
+
95
+ export function isCoolingDown(key, now = Date.now()) {
96
+ const until = cooldowns.get(key);
97
+ if (!until) return false;
98
+ if (now >= until) { cooldowns.delete(key); return false; }
99
+ return true;
100
+ }
101
+
102
+ export function recordOutcome(key, ok, now = Date.now()) {
103
+ if (ok) { cooldowns.delete(key); return; }
104
+ cooldowns.set(key, now + COOLDOWN_MS);
105
+ }
106
+
107
+ export function _resetCooldowns() {
108
+ cooldowns.clear();
109
+ }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * LLM reranker via any OpenAI-compatible /v1/chat/completions model.
3
- * Used when embeddings are unavailable: keyword prefilter -> LLM picks the
4
- * most relevant candidates. Degrades gracefully: returns null on any failure
5
- * so callers fall back to keyword ordering. Never throws.
3
+ * Tries provider chain slots in order, skipping any currently cooling down
4
+ * after a recent failure. Degrades gracefully: returns null on any failure so
5
+ * callers fall back to keyword ordering. Never throws. Never logs keys/bodies.
6
6
  */
7
7
  import http from "http";
8
8
  import https from "https";
9
9
  import { URL } from "url";
10
10
  import { deterministicEnabled } from "./env.js";
11
- import { providerChainConfig } from "./provider-chain.js";
11
+ import { providerChainConfig, isCoolingDown, recordOutcome } from "./provider-chain.js";
12
12
 
13
13
  const TIMEOUT_MS = Number(process.env.RERANK_TIMEOUT_MS || 30000);
14
14
  const ENABLED = process.env.RERANK_ENABLED !== "0";
@@ -25,10 +25,19 @@ export function rerankConfig() {
25
25
  };
26
26
  }
27
27
 
28
+ function providerKey(provider) {
29
+ return `${provider.label}|${provider.base}|${provider.model}`;
30
+ }
31
+
32
+ /**
33
+ * Resolves { content, retryable }. retryable=true means try the next provider
34
+ * (timeout, network error, 429, or 5xx). retryable=false with null content
35
+ * means a definitive non-retryable response (e.g. 4xx auth).
36
+ */
28
37
  function chatWithProvider(provider, messages) {
29
38
  return new Promise((resolve) => {
30
39
  let u;
31
- try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve(null); }
40
+ try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve({ content: null, retryable: false }); }
32
41
  const payload = Buffer.from(JSON.stringify({ model: provider.model, stream: false, temperature: 0, max_tokens: 200, messages }));
33
42
  const lib = u.protocol === "https:" ? https : http;
34
43
  const req = lib.request(
@@ -44,10 +53,14 @@ function chatWithProvider(provider, messages) {
44
53
  let body = "";
45
54
  res.on("data", (c) => (body += c));
46
55
  res.on("end", () => {
47
- if (res.statusCode !== 200) return resolve(null);
56
+ const status = res.statusCode || 0;
57
+ if (status !== 200) {
58
+ const retryable = status === 429 || status >= 500;
59
+ return resolve({ content: null, retryable });
60
+ }
48
61
  try {
49
62
  const j = JSON.parse(body);
50
- return resolve(j?.choices?.[0]?.message?.content ?? null);
63
+ return resolve({ content: j?.choices?.[0]?.message?.content ?? null, retryable: false });
51
64
  } catch { /* maybe SSE */ }
52
65
  try {
53
66
  let acc = "";
@@ -59,13 +72,13 @@ function chatWithProvider(provider, messages) {
59
72
  const j = JSON.parse(d);
60
73
  acc += j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
61
74
  }
62
- return resolve(acc || null);
63
- } catch { return resolve(null); }
75
+ return resolve({ content: acc || null, retryable: false });
76
+ } catch { return resolve({ content: null, retryable: true }); }
64
77
  });
65
78
  }
66
79
  );
67
- req.on("error", () => resolve(null));
68
- req.on("timeout", () => { req.destroy(); resolve(null); });
80
+ req.on("error", () => resolve({ content: null, retryable: true }));
81
+ req.on("timeout", () => { req.destroy(); resolve({ content: null, retryable: true }); });
69
82
  req.write(payload);
70
83
  req.end();
71
84
  });
@@ -75,8 +88,11 @@ async function chat(messages) {
75
88
  if (!ENABLED || deterministicEnabled()) return null;
76
89
  const { providers } = providerChainConfig();
77
90
  for (const provider of providers) {
78
- const out = await chatWithProvider(provider, messages);
79
- if (out) return out;
91
+ const key = providerKey(provider);
92
+ if (isCoolingDown(key)) continue;
93
+ const { content, retryable } = await chatWithProvider(provider, messages);
94
+ if (content) { recordOutcome(key, true); return content; }
95
+ if (retryable) recordOutcome(key, false);
80
96
  }
81
97
  return null;
82
98
  }