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 +24 -10
- package/bin/lynkr-init.js +25 -2
- package/package.json +3 -2
- package/src/api/middleware/load-shedding.js +76 -13
- package/src/api/openai-router.js +79 -4
- package/src/api/providers-handler.js +28 -0
- package/src/clients/databricks.js +359 -40
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/resilience.js +9 -0
- package/src/config/index.js +37 -3
- package/src/context/tool-result-compressor.js +6 -3
- package/src/orchestrator/index.js +71 -7
- package/src/orchestrator/sse-transformer.js +28 -0
- package/src/routing/index.js +2 -0
- package/src/routing/intent-score.js +27 -2
- package/src/routing/model-tiers.js +4 -0
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><300ms</strong><br/>Semantic Cache Hits</td>
|
|
18
|
-
<td align="center"><strong>
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
- ✅
|
|
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 (
|
|
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
|
-
**
|
|
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&utm_medium=readme&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&utm_medium=readme&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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
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
|
}
|
package/src/api/openai-router.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
438
|
-
|
|
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 {
|
|
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";
|