lynkr 9.12.0 → 9.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <td align="center"><strong>84%</strong><br/>JSON Compression</td>
16
16
  <td align="center"><strong>53%</strong><br/>Tool Token Reduction</td>
17
17
  <td align="center"><strong>&lt;300ms</strong><br/>Semantic Cache Hits</td>
18
- <td align="center"><strong>13+</strong><br/>LLM Providers</td>
18
+ <td align="center"><strong>14+</strong><br/>LLM Providers</td>
19
19
  <td align="center"><strong>0</strong><br/>Code Changes Required</td>
20
20
  </tr>
21
21
  </table>
@@ -28,26 +28,25 @@
28
28
 
29
29
  ## 🚀 New: Wrap Mode for AI Coding Tools
30
30
 
31
- **Use Lynkr's routing with your AI coding assistant — BYO API keys, route by task complexity:**
31
+ **Use Lynkr's routing with your AI coding assistant — maximize your subscription value:**
32
32
 
33
33
  ```bash
34
34
  npm install -g lynkr
35
35
 
36
36
 
37
- # Claude Code (with your own ANTHROPIC_API_KEY)
37
+ # Claude Code Pro/Max
38
38
  lynkr wrap claude
39
39
 
40
40
 
41
41
  ```
42
42
 
43
43
  **Wrapping gives you:**
44
- - ✅ Tier routing (send simple tasks to free Ollama, complex to your configured API provider)
44
+ - ✅ Tier routing (send simple tasks to free Ollama, complex to your subscription/API)
45
45
  - ✅ Sticky sessions: one routing decision per conversation via content fingerprinting, with automatic escalation when the task outgrows the model
46
46
  - ✅ TOON/RTK compression (84% token reduction on large JSON tool outputs)
47
47
  - ✅ Semantic caching (sub-300ms cache hits, 0 tokens billed)
48
- - ✅ Works with API keys (Claude, Aider, Codex, Copilot, Cursor)
49
-
50
- > **Note on subscription (OAuth) logins:** Anthropic's Feb 2026 usage policy prohibits third-party tools from using OAuth tokens issued to Free/Pro/Max accounts. Wrap mode's tier routing, compression, and caching require an API key — they do not apply to and are not compatible with Claude Pro/Max subscription auth. See [ToS Compliance](docs/wrap-guide.md#tos-compliance) for details.
48
+ - ✅ **3-5x more usage from the same subscription limits**
49
+ - ✅ Works with OAuth (Claude, Copilot, Cursor) or API keys (Aider, Codex)
51
50
 
52
51
  [Full wrap guide →](docs/wrap-guide.md)
53
52
 
@@ -69,7 +68,7 @@ The fastest path is the interactive wizard:
69
68
  lynkr init
70
69
  ```
71
70
 
72
- It asks four questions — usage mode (BYO API keys, direct), tier picks for SIMPLE/MEDIUM/COMPLEX/REASONING across the 12 supported providers, credentials for what you chose, and a few routing-intelligence knobs — then writes a fully-populated `.env` with sensible production defaults for everything else (caching, compression, policy budgets, MCP sandbox, rate limiting).
71
+ It asks four questions — usage mode (Claude Pro/Max via wrap, or direct API keys), tier picks for SIMPLE/MEDIUM/COMPLEX/REASONING across the 14 supported providers, credentials for what you chose, and a few routing-intelligence knobs — then writes a fully-populated `.env` with sensible production defaults for everything else (caching, compression, policy budgets, MCP sandbox, rate limiting).
73
72
 
74
73
  Useful flags:
75
74
 
@@ -217,9 +216,24 @@ Claude Code / Cursor / Codex / Cline / Continue
217
216
  | **Azure OpenAI** | Cloud | GPT-4o, o1, o3 | $$$ |
218
217
  | **Azure Anthropic** | Cloud | Claude Sonnet, Opus | $$$ |
219
218
  | **OpenAI** | Cloud | GPT-4o, o3-mini | $$$ |
219
+ | **Atlas Cloud** | Cloud | Qwen, DeepSeek, and other OpenAI-compatible models | $-$$$ |
220
220
  | **DeepSeek** | Cloud | DeepSeek R1, Reasoner | $ |
221
-
222
- **4 local providers** for 100% offline, free usage. **10+ cloud providers** for scale.
221
+ | **Z.ai** | Cloud | GLM-4.7, GLM-4.5-Air | $ |
222
+ | **Moonshot AI** | Cloud | Kimi K2.6, Kimi K3 | $ |
223
+ | **Baidu Qianfan** | Cloud | ERNIE 4.5 Turbo, ERNIE X1.1 | $ (unverified — not yet probed against a live key) |
224
+
225
+ **4 local providers** for 100% offline, free usage. **14+ cloud providers** for scale.
226
+
227
+ <div align="center">
228
+ <a href="https://www.atlascloud.ai/?utm_source=github&amp;utm_medium=readme&amp;utm_campaign=lynkr">
229
+ <picture>
230
+ <source media="(prefers-color-scheme: dark)" srcset="https://www.atlascloud.ai/logo-white.svg">
231
+ <img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="196">
232
+ </picture>
233
+ </a>
234
+ <br>
235
+ <sub>Use Lynkr with Atlas Cloud's OpenAI-compatible API and route across its live model catalog with one optional provider configuration. <a href="https://www.atlascloud.ai/?utm_source=github&amp;utm_medium=readme&amp;utm_campaign=lynkr">Explore Atlas Cloud</a>.</sub>
236
+ </div>
223
237
 
224
238
  ---
225
239
 
package/bin/lynkr-init.js CHANGED
@@ -89,6 +89,17 @@ const PROVIDERS = {
89
89
  extras: [],
90
90
  defaultModel: 'gpt-4o',
91
91
  },
92
+ atlas: {
93
+ label: 'Atlas Cloud (OpenAI-compatible)',
94
+ local: false,
95
+ creds: [
96
+ { key: 'ATLASCLOUD_API_KEY', label: 'Atlas Cloud API key', secret: true },
97
+ ],
98
+ extras: [
99
+ { key: 'ATLASCLOUD_ENDPOINT', label: 'Chat Completions endpoint', default: 'https://api.atlascloud.ai/v1/chat/completions' },
100
+ ],
101
+ defaultModel: 'qwen/qwen3.8-max',
102
+ },
92
103
  openrouter: {
93
104
  label: 'OpenRouter (100+ models, one key)',
94
105
  local: false,
@@ -159,12 +170,21 @@ const PROVIDERS = {
159
170
  extras: [],
160
171
  defaultModel: 'kimi-k2-turbo-preview',
161
172
  },
173
+ baidu: {
174
+ label: 'Baidu Qianfan (ERNIE)',
175
+ local: false,
176
+ creds: [
177
+ { key: 'BAIDU_API_KEY', label: 'Baidu Qianfan API key (bce-v3/ALTAK-...)', secret: true },
178
+ ],
179
+ extras: [],
180
+ defaultModel: 'ernie-4.5-turbo-128k',
181
+ },
162
182
  };
163
183
 
164
184
  const PROVIDER_ORDER = [
165
185
  'ollama', 'llamacpp', 'lmstudio',
166
- 'azure-anthropic', 'azure-openai', 'openai', 'openrouter', 'edenai',
167
- 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot',
186
+ 'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai',
187
+ 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu',
168
188
  ];
169
189
  const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
170
190
 
@@ -319,6 +339,9 @@ const BASELINE_ENV = {
319
339
  MOONSHOT_API_KEY: '',
320
340
  MOONSHOT_ENDPOINT: 'https://api.moonshot.ai/v1/chat/completions',
321
341
  MOONSHOT_MODEL: 'kimi-k2.6',
342
+ BAIDU_API_KEY: '',
343
+ BAIDU_ENDPOINT: 'https://qianfan.baidubce.com/v2/chat/completions',
344
+ BAIDU_MODEL: 'ernie-4.5-turbo-128k',
322
345
  LLAMACPP_ENDPOINT: 'http://localhost:8080',
323
346
  LLAMACPP_MODEL: 'default',
324
347
  LLAMACPP_TIMEOUT_MS: '120000',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.12.0",
3
+ "version": "9.14.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -35,9 +35,10 @@
35
35
  "start": "node index.js 2>&1 | npx pino-pretty --sync",
36
36
  "stop": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom down || echo 'Headroom skipped (disabled or Docker not running)'",
37
37
  "dev": "nodemon index.js",
38
+ "start:supervised": "while true; do node index.js 2>&1 | npx pino-pretty --sync; code=$?; echo \"[supervisor] lynkr exited ($code) — restarting in 3s\"; sleep 3; done",
38
39
  "lint": "eslint src index.js",
39
40
  "test": "npm run test:unit && npm run test:performance",
40
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js",
41
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js",
41
42
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
42
43
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
43
44
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -1,4 +1,5 @@
1
1
  const os = require("os");
2
+ const v8 = require("v8");
2
3
  const logger = require("../../logger");
3
4
  const { ServiceUnavailableError } = require("./error-handling");
4
5
 
@@ -18,11 +19,31 @@ class LoadShedder {
18
19
  this.memoryThreshold = options.memoryThreshold || 0.85; // 85%
19
20
  this.heapThreshold = options.heapThreshold || 0.95; // 95% (increased from 90% to prevent false positives from temporary allocation spikes)
20
21
  this.activeRequestsThreshold = options.activeRequestsThreshold || 1000;
22
+ // Absolute free-heap floor. Percent-of-heapTotal is unreliable in both
23
+ // directions: heapTotal is V8's CURRENTLY allocated heap (grows on
24
+ // demand toward the real limit), and at the limit "99%" leaves ~40MB on
25
+ // a 4GB heap — nowhere near enough to parse one multi-MB request body.
26
+ // 2026-08-19 OOM: a 5MB compact request passed the percent check and
27
+ // died allocating during JSON.parse. Shed while there is still room to
28
+ // finish in-flight work.
29
+ this.headroomMB = options.headroomMB || 512;
30
+ this.heapLimitMB = v8.getHeapStatistics().heap_size_limit / (1024 * 1024);
31
+
32
+ // Watchdog: how long the heap may stay below the headroom floor with
33
+ // zero active requests before the process self-restarts. Retained
34
+ // memory GC can't reclaim never recovers on its own — without this the
35
+ // server sheds 100% of traffic until someone restarts it by hand.
36
+ this.wedgedRestartMs = options.wedgedRestartMs || 60000;
37
+ this.selfRestart = options.selfRestart !== false;
38
+ this.wedgedSince = null;
21
39
 
22
40
  // State
23
41
  this.activeRequests = 0;
24
42
  this.totalShed = 0;
25
- this.lastCheck = Date.now();
43
+ // 0, not Date.now(): the first isOverloaded() call must actually
44
+ // evaluate instead of returning the constructor's cached "false"
45
+ // for the first checkInterval window.
46
+ this.lastCheck = 0;
26
47
  this.checkInterval = options.checkInterval || 1000; // Check every second
27
48
  this.cachedOverloadState = false;
28
49
  }
@@ -42,26 +63,32 @@ class LoadShedder {
42
63
 
43
64
  // Check memory usage
44
65
  const memUsage = process.memoryUsage();
45
- const heapUsedPercent = memUsage.heapUsed / memUsage.heapTotal;
46
-
47
- // FIX: Only trigger if BOTH percentage is high AND actual usage is significant
48
- // This prevents false positives on startup when heapTotal is small but will grow
49
- const heapUsedMB = memUsage.heapUsed / (1024 * 1024);
50
- const minHeapThresholdMB = 500; // Only shed load if using more than 500MB
51
66
 
52
- if (heapUsedPercent > this.heapThreshold && heapUsedMB > minHeapThresholdMB) {
67
+ // Absolute headroom against the true V8 limit — the primary guard.
68
+ const heapStats = v8.getHeapStatistics();
69
+ const freeMB = (heapStats.heap_size_limit - heapStats.used_heap_size) / (1024 * 1024);
70
+ if (freeMB < this.headroomMB) {
53
71
  logger.warn(
54
72
  {
55
- heapUsedPercent: (heapUsedPercent * 100).toFixed(2),
56
- heapUsedMB: heapUsedMB.toFixed(2),
57
- threshold: (this.heapThreshold * 100).toFixed(2),
58
- minThresholdMB: minHeapThresholdMB,
73
+ freeMB: freeMB.toFixed(0),
74
+ headroomMB: this.headroomMB,
75
+ heapLimitMB: this.heapLimitMB.toFixed(0),
76
+ usedMB: (heapStats.used_heap_size / (1024 * 1024)).toFixed(0),
59
77
  },
60
- "Load shedding: Heap usage exceeded threshold"
78
+ "Load shedding: heap headroom below floor"
61
79
  );
80
+ this._trackWedged(now);
62
81
  this.cachedOverloadState = true;
63
82
  return true;
64
83
  }
84
+ this.wedgedSince = null;
85
+
86
+ // NOTE: the old heapUsed/heapTotal percent check is gone. heapTotal is
87
+ // V8's *currently allocated* heap, which it keeps close to usage, so the
88
+ // ratio reads ~99% during perfectly normal operation — it shed all
89
+ // traffic at 755MB used with gigabytes of real headroom remaining
90
+ // (2026-08-20 false-positive incident). The absolute-headroom check
91
+ // against heap_size_limit above is the correct form of this guard.
65
92
 
66
93
  // Check RSS / system memory
67
94
  const rssPercent = memUsage.rss / os.totalmem();
@@ -94,6 +121,32 @@ class LoadShedder {
94
121
  return false;
95
122
  }
96
123
 
124
+ /**
125
+ * Track how long the heap has been wedged below the headroom floor.
126
+ * If it persists past wedgedRestartMs with no in-flight requests, the
127
+ * memory is retained (not transient) and GC cannot recover it — exit so
128
+ * the supervisor restarts a clean process instead of shedding forever.
129
+ */
130
+ _trackWedged(now) {
131
+ if (!this.selfRestart) return;
132
+ if (this.wedgedSince === null) {
133
+ this.wedgedSince = now;
134
+ return;
135
+ }
136
+ if (now - this.wedgedSince >= this.wedgedRestartMs && this.activeRequests === 0) {
137
+ logger.fatal(
138
+ {
139
+ wedgedForMs: now - this.wedgedSince,
140
+ totalShed: this.totalShed,
141
+ heapLimitMB: this.heapLimitMB.toFixed(0),
142
+ },
143
+ "Load shedding: heap wedged below headroom floor with no active requests — exiting for supervisor restart"
144
+ );
145
+ // Give the fatal log a moment to flush, then exit non-zero.
146
+ setTimeout(() => process.exit(1), 250).unref();
147
+ }
148
+ }
149
+
97
150
  /**
98
151
  * Get current metrics
99
152
  */
@@ -107,10 +160,15 @@ class LoadShedder {
107
160
  heapTotalMB: (memUsage.heapTotal / (1024 * 1024)).toFixed(2),
108
161
  rssMB: (memUsage.rss / (1024 * 1024)).toFixed(2),
109
162
  rssPercent: ((memUsage.rss / os.totalmem()) * 100).toFixed(2),
163
+ heapLimitMB: this.heapLimitMB.toFixed(2),
164
+ freeHeadroomMB: ((v8.getHeapStatistics().heap_size_limit - v8.getHeapStatistics().used_heap_size) / (1024 * 1024)).toFixed(2),
110
165
  thresholds: {
111
166
  heapThreshold: (this.heapThreshold * 100).toFixed(2),
112
167
  memoryThreshold: (this.memoryThreshold * 100).toFixed(2),
113
168
  activeRequestsThreshold: this.activeRequestsThreshold,
169
+ headroomMB: this.headroomMB,
170
+ wedgedRestartMs: this.wedgedRestartMs,
171
+ selfRestart: this.selfRestart,
114
172
  },
115
173
  };
116
174
  }
@@ -129,6 +187,11 @@ function getLoadShedder(options) {
129
187
  process.env.LOAD_SHEDDING_ACTIVE_REQUESTS_THRESHOLD || "1000",
130
188
  10
131
189
  ),
190
+ // Not env-configurable by design: these are crash-prevention floors,
191
+ // and there is no deployment where turning them off is correct.
192
+ headroomMB: 512,
193
+ wedgedRestartMs: 60000,
194
+ selfRestart: true,
132
195
  };
133
196
  instance = new LoadShedder({ ...defaultOptions, ...options });
134
197
  }
@@ -426,22 +426,42 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel)
426
426
  res.write("data: [DONE]\n\n");
427
427
  res.end();
428
428
  return;
429
+ case "error":
430
+ // Forward upstream stream errors in OpenAI wire shape
431
+ // (data: {"error": ...}) and terminate WITHOUT finish_reason or
432
+ // [DONE]. Writing "stop" here told agent clients the model chose
433
+ // to end its turn — they stopped mid-task instead of retrying
434
+ // (2026-08-19 incident: opencode stalled between tool calls).
435
+ logger.warn({ error: ev.error?.message }, "Forwarding upstream stream error to OpenAI-surface client");
436
+ res.write(`data: ${JSON.stringify({ error: { message: ev.error?.message || "upstream stream error", type: ev.error?.type || "server_error", code: null } })}\n\n`);
437
+ res.end();
438
+ return;
429
439
  default:
430
440
  break; // ping, thinking deltas, unknown events — skip
431
441
  }
432
442
  if (res.writableEnded || res.destroyed) return;
433
443
  }
434
444
  }
435
- // Upstream closed without message_stop — still terminate the client stream cleanly.
445
+ // Upstream closed without message_stop. If a stop_reason already arrived
446
+ // the turn is materially complete — finish normally. Otherwise this is a
447
+ // truncated stream: send an error chunk, never a fabricated "stop".
436
448
  if (!res.writableEnded) {
437
- res.write(chunk({}, finishReason || "stop"));
438
- res.write("data: [DONE]\n\n");
449
+ if (finishReason) {
450
+ res.write(chunk({}, finishReason));
451
+ res.write("data: [DONE]\n\n");
452
+ } else {
453
+ logger.warn("Upstream stream ended without message_stop — sending error chunk to OpenAI-surface client");
454
+ res.write(`data: ${JSON.stringify({ error: { message: "Lynkr: upstream stream truncated — retry", type: "server_error", code: null } })}\n\n`);
455
+ }
439
456
  res.end();
440
457
  }
441
458
  } catch (err) {
442
459
  logger.warn({ err: err.message }, "OpenAI-surface stream forward failed mid-flight");
443
460
  if (!res.writableEnded) {
444
- try { res.write(chunk({}, "stop")); res.write("data: [DONE]\n\n"); res.end(); } catch { /* client gone */ }
461
+ try {
462
+ res.write(`data: ${JSON.stringify({ error: { message: `Lynkr: stream forward failed — retry (${err.message})`, type: "server_error", code: null } })}\n\n`);
463
+ res.end();
464
+ } catch { /* client gone */ }
445
465
  }
446
466
  }
447
467
  }
@@ -588,6 +608,34 @@ router.post("/chat/completions", async (req, res) => {
588
608
  const content = lynkrBadge(result.body) + (openaiResponse.choices[0].message.content || "");
589
609
  let toolCalls = openaiResponse.choices[0].message.tool_calls;
590
610
 
611
+ // Guard: never serve a clean empty completion. Upstream failures
612
+ // (e.g. exhausted 429 retries) can surface here as a contentless
613
+ // message with finish_reason "stop"; agent clients read that as
614
+ // "task complete" and terminate mid-task. Killing the stream without
615
+ // a finish chunk makes the client retry instead.
616
+ if (!content && (!toolCalls || toolCalls.length === 0)) {
617
+ const upstreamErr = result.body?.error || {};
618
+ logger.error({
619
+ finishReason: openaiResponse.choices[0]?.finish_reason,
620
+ terminationReason: result?.terminationReason,
621
+ status: result?.status,
622
+ upstreamError: upstreamErr.message || null,
623
+ }, "Empty completion reached serving path — sending error chunk to client");
624
+ // res.destroy() here surfaced as ECONNRESET ("connection reset by
625
+ // server") in clients, and the error chunk raced the teardown so
626
+ // it often never arrived. A cleanly-ended SSE error chunk (no
627
+ // finish chunk, no [DONE]) delivers the real upstream error and
628
+ // still reads as a failed request that agent clients retry.
629
+ res.write(`data: ${JSON.stringify({ error: {
630
+ message: upstreamErr.message || "Upstream returned an empty completion; retry.",
631
+ type: upstreamErr.type || "server_error",
632
+ code: upstreamErr.code || "empty_completion",
633
+ ...(result?.status ? { status: result.status } : {}),
634
+ } })}\n\n`);
635
+ res.end();
636
+ return;
637
+ }
638
+
591
639
  if (clientType !== "unknown" && toolCalls && toolCalls.length > 0) {
592
640
  toolCalls = toolCalls.map(tc => {
593
641
  const mapped = mapToolForClient(tc.function?.name || "", tc.function?.arguments || "{}", clientType);
@@ -764,6 +812,21 @@ router.post("/chat/completions", async (req, res) => {
764
812
  }, "Tool names mapped for non-streaming chat/completions");
765
813
  }
766
814
 
815
+ // Guard (mirrors the streaming path): never serve a clean empty
816
+ // completion — upstream failures can surface as contentless messages
817
+ // with finish_reason "stop", which agent clients read as "done" and
818
+ // terminate mid-task. A 502 is retryable; an empty 200 is a silent kill.
819
+ const _msg = openaiResponse.choices?.[0]?.message;
820
+ if (!_msg?.content && !(_msg?.tool_calls?.length > 0)) {
821
+ logger.error({
822
+ finishReason: openaiResponse.choices?.[0]?.finish_reason,
823
+ usage: openaiResponse.usage,
824
+ }, "Empty completion reached non-streaming serving path — returning 502");
825
+ return res.status(502).json({
826
+ error: { message: "Upstream returned an empty completion; retry.", type: "server_error", code: "empty_completion" }
827
+ });
828
+ }
829
+
767
830
  logger.info({
768
831
  duration: Date.now() - startTime,
769
832
  mode: "non-streaming",
@@ -939,6 +1002,18 @@ function getConfiguredProviders() {
939
1002
  });
940
1003
  }
941
1004
 
1005
+ if (config.baidu?.apiKey) {
1006
+ providers.push({
1007
+ name: "baidu",
1008
+ type: "baidu-qianfan",
1009
+ models: [
1010
+ config.baidu.model || "ernie-4.5-turbo-128k",
1011
+ "ernie-4.5-turbo-128k",
1012
+ "ernie-x1.1"
1013
+ ]
1014
+ });
1015
+ }
1016
+
942
1017
  if (config.vertex?.projectId) {
943
1018
  providers.push({
944
1019
  name: "vertex",
@@ -107,6 +107,19 @@ function getConfiguredProviders() {
107
107
  });
108
108
  }
109
109
 
110
+ // Check Atlas Cloud (OpenAI-compatible Chat Completions)
111
+ if (config.atlas?.apiKey) {
112
+ providers.push({
113
+ name: "atlas",
114
+ type: "atlas-cloud",
115
+ baseUrl: config.atlas.endpoint || "https://api.atlascloud.ai/v1/chat/completions",
116
+ enabled: true,
117
+ models: [
118
+ { id: config.atlas.model || "qwen/qwen3.8-max", name: "Configured Model" },
119
+ ]
120
+ });
121
+ }
122
+
110
123
  // Check OpenRouter
111
124
  if (config.openrouter?.apiKey) {
112
125
  providers.push({
@@ -210,6 +223,21 @@ function getConfiguredProviders() {
210
223
  });
211
224
  }
212
225
 
226
+ // Check Baidu Qianfan (ERNIE)
227
+ if (config.baidu?.apiKey) {
228
+ providers.push({
229
+ name: "baidu",
230
+ type: "baidu-qianfan",
231
+ baseUrl: config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions",
232
+ enabled: true,
233
+ models: [
234
+ { id: config.baidu.model || "ernie-4.5-turbo-128k", name: "Configured Model" },
235
+ { id: "ernie-4.5-turbo-128k", name: "ERNIE 4.5 Turbo 128K" },
236
+ { id: "ernie-x1.1", name: "ERNIE X1.1 (reasoning)" },
237
+ ]
238
+ });
239
+ }
240
+
213
241
  // Check Vertex AI (Google Cloud)
214
242
  if (config.vertex?.projectId) {
215
243
  const region = config.vertex.region || "us-east5";