lynkr 9.14.0 → 9.14.2

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.
Files changed (49) hide show
  1. package/package.json +10 -22
  2. package/src/api/health.js +0 -1
  3. package/src/api/middleware/session.js +0 -0
  4. package/src/api/openai-router.js +94 -13
  5. package/src/api/providers-handler.js +0 -1
  6. package/src/api/router.js +6 -3
  7. package/src/cache/prompt.js +0 -7
  8. package/src/clients/bedrock-utils.js +2 -1
  9. package/src/clients/codex-process.js +1 -1
  10. package/src/clients/codex-utils.js +0 -2
  11. package/src/clients/databricks.js +59 -25
  12. package/src/clients/gpt-utils.js +1 -1
  13. package/src/clients/openai-format.js +73 -23
  14. package/src/clients/provider-capabilities.js +0 -2
  15. package/src/clients/resilience.js +0 -1
  16. package/src/clients/responses-format.js +0 -56
  17. package/src/clients/routing.js +1 -1
  18. package/src/config/index.js +0 -1
  19. package/src/context/budget.js +1 -1
  20. package/src/context/tool-result-compressor.js +3 -5
  21. package/src/dashboard/api.js +0 -1
  22. package/src/headroom/client.js +44 -20
  23. package/src/logger/oversized-error-stream.js +1 -1
  24. package/src/memory/extractor.js +3 -3
  25. package/src/memory/search.js +2 -2
  26. package/src/memory/store.js +9 -24
  27. package/src/memory/tools.js +14 -0
  28. package/src/observability/metrics.js +0 -3
  29. package/src/orchestrator/index.js +1 -80
  30. package/src/orchestrator/sse-transformer.js +68 -10
  31. package/src/policy/safe-commands.js +0 -1
  32. package/src/routing/calibration.js +21 -1
  33. package/src/routing/client-profiles.js +26 -0
  34. package/src/routing/complexity-analyzer.js +5 -8
  35. package/src/routing/confidence-scorer.js +2 -2
  36. package/src/routing/cost-optimizer.js +1 -2
  37. package/src/routing/deescalator.js +1 -1
  38. package/src/routing/embedding-cache.js +0 -1
  39. package/src/routing/intent-score.js +0 -1
  40. package/src/routing/knn-router.js +27 -5
  41. package/src/routing/latency-tracker.js +1 -1
  42. package/src/routing/regret-estimator.js +1 -1
  43. package/src/stores/file-store.js +5 -1
  44. package/src/training/trajectory-compressor.js +1 -1
  45. package/src/workers/pool.js +1 -1
  46. package/src/workers/worker.js +1 -1
  47. package/scripts/convert-routellm.py +0 -105
  48. package/src/diff/comments.js +0 -153
  49. package/src/policy/web-fallback.js +0 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.14.0",
3
+ "version": "9.14.2",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -38,12 +38,12 @@
38
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",
39
39
  "lint": "eslint src index.js",
40
40
  "test": "npm run test:unit && npm run test:performance",
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",
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",
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",
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",
45
- "test:benchmark": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-benchmark.js",
46
- "test:quick": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js",
41
+ "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn 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",
42
+ "test:memory": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn 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",
43
+ "test:new-features": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn 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",
44
+ "test:performance": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn 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",
45
+ "test:benchmark": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-benchmark.js",
46
+ "test:quick": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js",
47
47
  "test:all": "npm run test:unit && npm run test:performance && npm run test:benchmark"
48
48
  },
49
49
  "keywords": [
@@ -90,32 +90,22 @@
90
90
  "node": ">=20.0.0"
91
91
  },
92
92
  "dependencies": {
93
- "@azure/openai": "^2.0.0",
94
- "@babel/parser": "^7.29.0",
95
- "@babel/traverse": "^7.29.0",
96
93
  "@blackwell-systems/gcf": "2.4.0",
97
- "@tencentdb-agent-memory/memory-tencentdb": "^1.0.1",
98
94
  "@toon-format/toon": "^2.1.0",
99
95
  "cockatiel": "^3.2.1",
100
- "compression": "^1.7.4",
101
96
  "diff": "^5.2.0",
102
97
  "dotenv": "^16.4.5",
103
98
  "express": "^5.1.0",
104
99
  "express-rate-limit": "^8.2.1",
105
- "fast-glob": "^3.3.2",
106
- "graphify": "^1.0.0",
107
100
  "hnswlib-node": "^3.0.0",
108
101
  "js-tiktoken": "^1.0.20",
109
- "js-yaml": "^4.1.1",
110
- "openai": "^6.14.0",
111
102
  "pino": "^8.17.2",
112
103
  "pino-http": "^8.6.0",
113
- "pino-roll": "^4.0.0",
114
- "undici": "^6.22.0"
104
+ "pino-roll": "^4.0.0"
115
105
  },
116
106
  "optionalDependencies": {
117
107
  "better-sqlite3": "^12.11.1",
118
- "dockerode": "^4.0.2",
108
+ "dockerode": "^5.0.1",
119
109
  "tree-sitter": "^0.21.1",
120
110
  "tree-sitter-javascript": "^0.21.0",
121
111
  "tree-sitter-python": "^0.21.0",
@@ -127,7 +117,5 @@
127
117
  "nodemon": "^3.1.0",
128
118
  "pino-pretty": "^10.2.0"
129
119
  },
130
- "allowScripts": {
131
- "@tencentdb-agent-memory/memory-tencentdb@1.0.1": true
132
- }
120
+ "allowScripts": {}
133
121
  }
package/src/api/health.js CHANGED
@@ -4,7 +4,6 @@ try {
4
4
  } catch {
5
5
  Database = null;
6
6
  }
7
- const { invokeModel } = require("../clients/databricks");
8
7
  const logger = require("../logger");
9
8
  const config = require("../config");
10
9
  const { getHeadroomManager } = require("../headroom");
Binary file
@@ -355,12 +355,36 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel)
355
355
  const created = Math.floor(Date.now() / 1000);
356
356
  const model = requestedModel || "lynkr";
357
357
 
358
- const chunk = (delta, finish = null) => `data: ${JSON.stringify({
358
+ const chunk = (delta, finish = null, usage = null) => `data: ${JSON.stringify({
359
359
  id, object: "chat.completion.chunk", created, model,
360
360
  system_fingerprint: "fp_lynkr",
361
361
  choices: [{ index: 0, delta, logprobs: null, finish_reason: finish }],
362
+ ...(usage ? { usage } : {}),
362
363
  })}\n\n`;
363
364
 
365
+ // Usage accounting (issue: opencode sidebar showed wrong token counts —
366
+ // this path never emitted usage at all). Anthropic sends input/cache
367
+ // tokens in message_start and output tokens in message_delta; accumulate
368
+ // and attach to the final chunk in OpenAI shape.
369
+ const usageAcc = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
370
+ const captureUsage = (u) => {
371
+ if (!u) return;
372
+ if (u.input_tokens != null) usageAcc.input = u.input_tokens;
373
+ if (u.cache_read_input_tokens != null) usageAcc.cacheRead = u.cache_read_input_tokens;
374
+ if (u.cache_creation_input_tokens != null) usageAcc.cacheWrite = u.cache_creation_input_tokens;
375
+ if (u.output_tokens != null) usageAcc.output = u.output_tokens;
376
+ };
377
+ const finalUsage = () => {
378
+ const promptTokens = usageAcc.input + usageAcc.cacheRead + usageAcc.cacheWrite;
379
+ const u = {
380
+ prompt_tokens: promptTokens,
381
+ completion_tokens: usageAcc.output,
382
+ total_tokens: promptTokens + usageAcc.output,
383
+ };
384
+ if (usageAcc.cacheRead > 0) u.prompt_tokens_details = { cached_tokens: usageAcc.cacheRead };
385
+ return u;
386
+ };
387
+
364
388
  const mapStop = (r) => r === "tool_use" ? "tool_calls" : r === "max_tokens" ? "length" : "stop";
365
389
 
366
390
  // Normalize Node Readable / web ReadableStream / async iterable to chunks.
@@ -394,6 +418,7 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel)
394
418
 
395
419
  switch (ev.type) {
396
420
  case "message_start":
421
+ captureUsage(ev.message?.usage);
397
422
  if (!started) { started = true; res.write(chunk({ role: "assistant", content: "" })); }
398
423
  break;
399
424
  case "content_block_start":
@@ -419,10 +444,11 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel)
419
444
  break;
420
445
  }
421
446
  case "message_delta":
447
+ captureUsage(ev.usage);
422
448
  if (ev.delta?.stop_reason) finishReason = mapStop(ev.delta.stop_reason);
423
449
  break;
424
450
  case "message_stop":
425
- res.write(chunk({}, finishReason || "stop"));
451
+ res.write(chunk({}, finishReason || "stop", finalUsage()));
426
452
  res.write("data: [DONE]\n\n");
427
453
  res.end();
428
454
  return;
@@ -447,7 +473,7 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel)
447
473
  // truncated stream: send an error chunk, never a fabricated "stop".
448
474
  if (!res.writableEnded) {
449
475
  if (finishReason) {
450
- res.write(chunk({}, finishReason));
476
+ res.write(chunk({}, finishReason, finalUsage()));
451
477
  res.write("data: [DONE]\n\n");
452
478
  } else {
453
479
  logger.warn("Upstream stream ended without message_stop — sending error chunk to OpenAI-surface client");
@@ -468,7 +494,11 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel)
468
494
 
469
495
  router.post("/chat/completions", async (req, res) => {
470
496
  const startTime = Date.now();
471
- const sessionId = req.headers["x-session-id"] || req.headers["authorization"]?.split(" ")[1] || "openai-session";
497
+ // Issue #95: use sessionMiddleware's id (header → body id → content
498
+ // fingerprint → UUID). Falling back to the raw Authorization token here
499
+ // collapsed every conversation from a static-key client into ONE session
500
+ // (defeating WS1 pinning) and persisted the bearer token as a store key.
501
+ const sessionId = req.sessionId;
472
502
 
473
503
  try {
474
504
  if (!req.body || typeof req.body !== 'object') {
@@ -730,7 +760,10 @@ router.post("/chat/completions", async (req, res) => {
730
760
  delta: {},
731
761
  logprobs: null,
732
762
  finish_reason: openaiResponse.choices[0].finish_reason
733
- }]
763
+ }],
764
+ // Clients (opencode et al.) read token accounting from the final
765
+ // chunk — omitting it here left streamed turns uncounted.
766
+ usage: openaiResponse.usage
734
767
  };
735
768
 
736
769
  logger.debug({ chunk: "finish", finishReason: openaiResponse.choices[0].finish_reason }, "Sending finish chunk");
@@ -835,6 +868,24 @@ router.post("/chat/completions", async (req, res) => {
835
868
  finishReason: openaiResponse.choices[0].finish_reason
836
869
  }, "=== OPENAI CHAT COMPLETION RESPONSE ===");
837
870
 
871
+ // Surface the routed reality: which model actually answered and what
872
+ // it cost (LiteLLM-style headers). Clients that can't price a dynamic
873
+ // router (opencode #24113) get the truth out-of-band.
874
+ const routingMeta = result.body?._routingMeta;
875
+ if (routingMeta?.model) {
876
+ res.set("x-lynkr-routed-model", String(routingMeta.model));
877
+ if (routingMeta.provider) res.set("x-lynkr-routed-provider", String(routingMeta.provider));
878
+ try {
879
+ const { computeCostUsd } = require("../clients/databricks");
880
+ const cost = computeCostUsd(
881
+ routingMeta.model,
882
+ result.body?.usage?.input_tokens || 0,
883
+ result.body?.usage?.output_tokens || 0
884
+ );
885
+ if (cost != null) res.set("x-lynkr-response-cost", cost.toFixed(6));
886
+ } catch { /* cost pricing unavailable — headers are best-effort */ }
887
+ }
888
+
838
889
  res.json(openaiResponse);
839
890
  }
840
891
 
@@ -1039,7 +1090,6 @@ function getConfiguredProviders() {
1039
1090
  router.get("/models", (req, res) => {
1040
1091
  try {
1041
1092
  const providers = getConfiguredProviders();
1042
- const primaryProvider = config.modelProvider?.type || "databricks";
1043
1093
  const timestamp = Math.floor(Date.now() / 1000);
1044
1094
  const models = [];
1045
1095
  const seenModelIds = new Set();
@@ -1476,7 +1526,7 @@ router.post("/embeddings", async (req, res) => {
1476
1526
  const startTime = Date.now();
1477
1527
 
1478
1528
  try {
1479
- const { input, model, encoding_format } = req.body;
1529
+ const { input, model, encoding_format: encodingFormat } = req.body;
1480
1530
 
1481
1531
  if (!input) {
1482
1532
  return res.status(400).json({
@@ -1488,6 +1538,16 @@ router.post("/embeddings", async (req, res) => {
1488
1538
  });
1489
1539
  }
1490
1540
 
1541
+ if (encodingFormat && encodingFormat !== "float" && encodingFormat !== "base64") {
1542
+ return res.status(400).json({
1543
+ error: {
1544
+ message: `Invalid encoding_format: ${encodingFormat}. Must be "float" or "base64".`,
1545
+ type: "invalid_request_error",
1546
+ code: "invalid_parameter"
1547
+ }
1548
+ });
1549
+ }
1550
+
1491
1551
  const inputs = Array.isArray(input) ? input : [input];
1492
1552
 
1493
1553
  logger.debug({
@@ -1561,6 +1621,20 @@ router.post("/embeddings", async (req, res) => {
1561
1621
  totalTokens: embeddingResponse.usage?.total_tokens || 0
1562
1622
  }, "=== EMBEDDINGS RESPONSE ===");
1563
1623
 
1624
+ // Providers return float arrays; honor encoding_format=base64 (OpenAI
1625
+ // spec: base64 of the little-endian float32 buffer) instead of silently
1626
+ // returning floats for every request.
1627
+ if (encodingFormat === "base64" && Array.isArray(embeddingResponse.data)) {
1628
+ for (const item of embeddingResponse.data) {
1629
+ if (Array.isArray(item.embedding)) {
1630
+ item.embedding = Buffer.from(new Float32Array(item.embedding).buffer).toString("base64");
1631
+ }
1632
+ }
1633
+ if ("encoding_format" in embeddingResponse) {
1634
+ embeddingResponse.encoding_format = "base64";
1635
+ }
1636
+ }
1637
+
1564
1638
  res.json(embeddingResponse);
1565
1639
 
1566
1640
  } catch (error) {
@@ -1588,7 +1662,10 @@ router.post("/embeddings", async (req, res) => {
1588
1662
  */
1589
1663
  router.post("/responses", async (req, res) => {
1590
1664
  const startTime = Date.now();
1591
- const sessionId = req.headers["x-session-id"] || req.headers["authorization"]?.split(" ")[1] || "responses-session";
1665
+ // Issue #95: same as /chat/completions — trust sessionMiddleware, never
1666
+ // the Authorization token. fingerprintSessionId understands the Responses
1667
+ // API's `input` field, so fingerprinting works on this route too.
1668
+ const sessionId = req.sessionId;
1592
1669
 
1593
1670
  try {
1594
1671
  const { convertResponsesToChat, convertChatToResponses } = require("../clients/responses-format");
@@ -1768,7 +1845,7 @@ router.post("/responses", async (req, res) => {
1768
1845
 
1769
1846
  // SSE comment keepalive (spec-compliant, ignored by all clients)
1770
1847
  const keepalive = setInterval(() => {
1771
- try { res.write(`: keepalive\n\n`); } catch {}
1848
+ try { res.write(`: keepalive\n\n`); } catch { /* client gone — best-effort keepalive */ }
1772
1849
  }, 2000);
1773
1850
 
1774
1851
  let result;
@@ -1875,21 +1952,25 @@ router.post("/responses", async (req, res) => {
1875
1952
  if (taskArgs.prompt && taskArgs.prompt.toLowerCase().includes("read")) {
1876
1953
  exploreCmd = "cat README.md 2>/dev/null && cat package.json 2>/dev/null";
1877
1954
  }
1878
- } catch {}
1955
+ } catch (err) {
1956
+ logger.debug({ err: err?.message }, "Failed to parse task tool arguments — using default explore command");
1957
+ }
1879
1958
  }
1880
1959
  converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", exploreCmd] }) } });
1881
1960
  continue;
1882
1961
  }
1883
1962
 
1884
1963
  let args = {};
1885
- try { args = JSON.parse(tc.function?.arguments || "{}"); } catch {}
1964
+ try { args = JSON.parse(tc.function?.arguments || "{}"); } catch (err) {
1965
+ logger.debug({ err: err?.message }, "Failed to parse tool call arguments — treating as empty object");
1966
+ }
1886
1967
 
1887
1968
  // Already a shell command — normalise to array format
1888
1969
  if (ln === "shell" || ln === "bash") {
1889
1970
  let cmd = args.command || "";
1890
1971
  // Handle string-encoded arrays: '["bash","-c","ls"]'
1891
1972
  if (typeof cmd === "string" && cmd.trim().startsWith("[")) {
1892
- try { cmd = JSON.parse(cmd); } catch {}
1973
+ try { cmd = JSON.parse(cmd); } catch { /* not a JSON array — keep as plain string */ }
1893
1974
  }
1894
1975
  // Extract the actual command from ["bash", "-c", "actual command"]
1895
1976
  if (Array.isArray(cmd) && cmd.length >= 3 && cmd[0] === "bash" && cmd[1] === "-c") {
@@ -2100,7 +2181,7 @@ router.post("/responses", async (req, res) => {
2100
2181
  sequence_number: 1
2101
2182
  })}\n\n`
2102
2183
  ].join("");
2103
- try { res.end(errorPayload); } catch { try { res.end(); } catch {} }
2184
+ try { res.end(errorPayload); } catch { try { res.end(); } catch { /* res already ended — best-effort */ } }
2104
2185
  }
2105
2186
 
2106
2187
  } else {
@@ -21,7 +21,6 @@ const router = express.Router();
21
21
  */
22
22
  function getConfiguredProviders() {
23
23
  const providers = [];
24
- const timestamp = Math.floor(Date.now() / 1000);
25
24
 
26
25
  // Check Databricks
27
26
  if (config.databricks?.url && config.databricks?.apiKey) {
package/src/api/router.js CHANGED
@@ -450,7 +450,7 @@ async function handleOauthPassthrough(req, res, opts = {}) {
450
450
  try { reader.cancel(); } catch { /* already dead */ }
451
451
  try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
452
452
  } finally {
453
- try { reader.releaseLock(); } catch {}
453
+ try { reader.releaseLock(); } catch { /* lock already released */ }
454
454
  }
455
455
  res.end();
456
456
  } else {
@@ -495,7 +495,7 @@ async function handleOauthPassthrough(req, res, opts = {}) {
495
495
  const tier = opts.tier || {};
496
496
  let parsedResponse = null;
497
497
  if (contentType.includes("application/json")) {
498
- try { parsedResponse = JSON.parse(responseTextForObservability); } catch {}
498
+ try { parsedResponse = JSON.parse(responseTextForObservability); } catch { /* not JSON — telemetry proceeds without a parsed body */ }
499
499
  } else if (contentType.includes("text/event-stream")) {
500
500
  // Extract a usable response object from the SSE stream by finding the
501
501
  // final message_delta / message_stop events.
@@ -1027,7 +1027,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1027
1027
  try {
1028
1028
  const { getMetricsCollector } = require("../observability/metrics");
1029
1029
  getMetricsCollector().recordRequest("POST", "/v1/messages", null, null);
1030
- } catch (_) {}
1030
+ } catch (err) {
1031
+ // If this fails silently, the wrap UI's "No requests tracked" bug returns.
1032
+ logger.debug({ err: err?.message }, "observability collector recordRequest failed");
1033
+ }
1031
1034
 
1032
1035
  messagesRequestCount += 1;
1033
1036
 
@@ -19,13 +19,6 @@ const fs = require("fs");
19
19
  const config = require("../config");
20
20
  const logger = require("../logger");
21
21
 
22
- function cloneValue(value) {
23
- if (typeof structuredClone === "function") {
24
- return structuredClone(value);
25
- }
26
- return JSON.parse(JSON.stringify(value));
27
- }
28
-
29
22
  function normaliseObject(value) {
30
23
  if (value === null || typeof value !== "object") return value;
31
24
  if (Array.isArray(value)) {
@@ -116,7 +116,7 @@ function convertAnthropicToBedrockFormat(body, modelFamily) {
116
116
  },
117
117
  };
118
118
 
119
- case "llama":
119
+ case "llama": {
120
120
  // Meta Llama format
121
121
  const llamaPrompt = messagesToPrompt(body.messages);
122
122
  return {
@@ -125,6 +125,7 @@ function convertAnthropicToBedrockFormat(body, modelFamily) {
125
125
  temperature: body.temperature || 0.7,
126
126
  top_p: body.top_p || 0.9,
127
127
  };
128
+ }
128
129
 
129
130
  case "jurassic":
130
131
  // AI21 Jurassic format
@@ -279,7 +279,7 @@ class CodexProcess {
279
279
  * Reject all pending requests
280
280
  */
281
281
  _rejectAllPending(error) {
282
- for (const [id, pending] of this.pendingRequests) {
282
+ for (const [_id, pending] of this.pendingRequests) {
283
283
  clearTimeout(pending.timeout);
284
284
  pending.reject(error);
285
285
  }
@@ -7,8 +7,6 @@
7
7
  * @module clients/codex-utils
8
8
  */
9
9
 
10
- const logger = require("../logger");
11
-
12
10
  /**
13
11
  * Extract text content from Anthropic message format
14
12
  * Handles both string content and content block arrays
@@ -165,7 +165,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
165
165
  });
166
166
  }
167
167
 
168
- async function invokeDatabricks(body, incomingHeaders = {}) {
168
+ async function invokeDatabricks(body, _incomingHeaders = {}) {
169
169
  if (!config.databricks?.url) {
170
170
  throw new Error("Databricks configuration is missing required URL.");
171
171
  }
@@ -423,12 +423,20 @@ function _liftLeakedThinkingBlocks(response) {
423
423
  const newContent = [];
424
424
  let lifted = 0;
425
425
  for (const block of payload.content) {
426
- if (block?.type === "text" && typeof block.text === "string" && block.text.includes("<think>")) {
426
+ if (block?.type === "text" && typeof block.text === "string" &&
427
+ (block.text.includes("<think>") || block.text.includes("</think>"))) {
427
428
  const thoughts = [];
428
429
  let m;
429
430
  while ((m = thinkRegex.exec(block.text)) !== null) thoughts.push(m[1].trim());
430
431
  thinkRegex.lastIndex = 0;
431
- const cleaned = block.text.replace(thinkRegex, "").trim();
432
+ let cleaned = block.text.replace(thinkRegex, "").trim();
433
+ // Orphan closing tag: GLM-family templates open <think> in the prompt,
434
+ // so the completion may carry reasoning terminated by a bare </think>.
435
+ const orphanIdx = cleaned.indexOf("</think>");
436
+ if (orphanIdx !== -1) {
437
+ thoughts.push(cleaned.slice(0, orphanIdx).trim());
438
+ cleaned = cleaned.slice(orphanIdx + "</think>".length).trim();
439
+ }
432
440
  const merged = thoughts.filter(Boolean).join("\n\n");
433
441
  if (merged) {
434
442
  newContent.push({ type: "thinking", thinking: merged });
@@ -489,7 +497,7 @@ async function _throwIfOllamaError(response, modelName) {
489
497
  return response;
490
498
  }
491
499
 
492
- async function invokeOllama(body, incomingHeaders = {}) {
500
+ async function invokeOllama(body, _incomingHeaders = {}) {
493
501
  if (!config.ollama?.endpoint) {
494
502
  throw new Error("Ollama endpoint is not configured.");
495
503
  }
@@ -754,7 +762,7 @@ async function invokeOllama(body, incomingHeaders = {}) {
754
762
  );
755
763
  }
756
764
 
757
- async function invokeOpenRouter(body, incomingHeaders = {}) {
765
+ async function invokeOpenRouter(body, _incomingHeaders = {}) {
758
766
  if (!config.openrouter?.endpoint || !config.openrouter?.apiKey) {
759
767
  throw new Error("OpenRouter endpoint or API key is not configured.");
760
768
  }
@@ -791,6 +799,13 @@ async function invokeOpenRouter(body, incomingHeaders = {}) {
791
799
  stream: body.stream ?? false
792
800
  };
793
801
 
802
+ // Issue #95: forward the Lynkr session id so OpenRouter's meta-routers
803
+ // (auto/pareto) can apply session stickiness — without it every call in a
804
+ // pinned session re-ranks fresh and one agentic task can span 6+ models.
805
+ if (body._sessionId) {
806
+ openRouterBody.session_id = body._sessionId;
807
+ }
808
+
794
809
  // Add tools - inject standard tools if client didn't send any (passthrough mode)
795
810
  let toolsToSend = body.tools;
796
811
  let toolsInjected = false;
@@ -820,7 +835,7 @@ async function invokeOpenRouter(body, incomingHeaders = {}) {
820
835
  // Eden AI is an OpenAI-compatible gateway (provider/model naming, EU/GDPR).
821
836
  // It speaks the same wire format as OpenRouter, so this mirrors invokeOpenRouter
822
837
  // and reuses the shared Anthropic<->OpenAI converters.
823
- async function invokeEdenAI(body, incomingHeaders = {}) {
838
+ async function invokeEdenAI(body, _incomingHeaders = {}) {
824
839
  if (!config.edenai?.endpoint || !config.edenai?.apiKey) {
825
840
  throw new Error("Eden AI endpoint or API key is not configured.");
826
841
  }
@@ -905,7 +920,7 @@ function derivePromptCacheKey(body) {
905
920
  return "conv-" + crypto.createHash("sha1").update(text.slice(0, 4000)).digest("hex").slice(0, 32);
906
921
  }
907
922
 
908
- async function invokeAzureOpenAI(body, incomingHeaders = {}) {
923
+ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
909
924
  if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
910
925
  throw new Error("Azure OpenAI endpoint or API key is not configured.");
911
926
  }
@@ -1350,7 +1365,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1350
1365
  }
1351
1366
 
1352
1367
 
1353
- async function invokeOpenAI(body, incomingHeaders = {}) {
1368
+ async function invokeOpenAI(body, _incomingHeaders = {}) {
1354
1369
  if (!config.openai?.apiKey) {
1355
1370
  throw new Error("OpenAI API key is not configured.");
1356
1371
  }
@@ -1488,7 +1503,7 @@ async function invokeAtlas(body) {
1488
1503
  }, "Atlas Cloud");
1489
1504
  }
1490
1505
 
1491
- async function invokeLlamaCpp(body, incomingHeaders = {}) {
1506
+ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1492
1507
  if (!config.llamacpp?.endpoint) {
1493
1508
  throw new Error("llama.cpp endpoint is not configured.");
1494
1509
  }
@@ -1634,7 +1649,7 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1634
1649
  return result;
1635
1650
  }
1636
1651
 
1637
- async function invokeLMStudio(body, incomingHeaders = {}) {
1652
+ async function invokeLMStudio(body, _incomingHeaders = {}) {
1638
1653
  if (!config.lmstudio?.endpoint) {
1639
1654
  throw new Error("LM Studio endpoint is not configured.");
1640
1655
  }
@@ -1761,7 +1776,7 @@ function normalizeBodyForConverse(body) {
1761
1776
  return normalized;
1762
1777
  }
1763
1778
 
1764
- async function invokeBedrock(body, incomingHeaders = {}) {
1779
+ async function invokeBedrock(body, _incomingHeaders = {}) {
1765
1780
  if (!config.bedrock?.apiKey) {
1766
1781
  throw new Error(
1767
1782
  "AWS Bedrock requires AWS_BEDROCK_API_KEY (Bearer token). " +
@@ -1774,11 +1789,9 @@ async function invokeBedrock(body, incomingHeaders = {}) {
1774
1789
 
1775
1790
  // Inject standard tools if needed
1776
1791
  let toolsToSend = body.tools;
1777
- let toolsInjected = false;
1778
1792
 
1779
1793
  if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1780
1794
  toolsToSend = STANDARD_TOOLS;
1781
- toolsInjected = true;
1782
1795
  logger.debug({
1783
1796
  injectedToolCount: STANDARD_TOOLS.length,
1784
1797
  injectedToolNames: STANDARD_TOOL_NAMES,
@@ -1947,7 +1960,7 @@ async function invokeBedrock(body, incomingHeaders = {}) {
1947
1960
  * Z.AI offers GLM models through an Anthropic-compatible API at ~1/7 the cost.
1948
1961
  * Minimal transformation needed - mostly passthrough with model mapping.
1949
1962
  */
1950
- async function invokeZai(body, incomingHeaders = {}) {
1963
+ async function invokeZai(body, _incomingHeaders = {}) {
1951
1964
  if (!config.zai?.apiKey) {
1952
1965
  throw new Error("Z.AI API key is not configured. Set ZAI_API_KEY in your .env file.");
1953
1966
  }
@@ -2152,7 +2165,7 @@ async function invokeZai(body, incomingHeaders = {}) {
2152
2165
  * Moonshot offers Kimi models through an OpenAI-compatible chat completions API.
2153
2166
  * Uses native system role support (unlike Z.AI which merges into user message).
2154
2167
  */
2155
- async function invokeMoonshot(body, incomingHeaders = {}) {
2168
+ async function invokeMoonshot(body, _incomingHeaders = {}) {
2156
2169
  if (!config.moonshot?.apiKey) {
2157
2170
  throw new Error("Moonshot API key is not configured. Set MOONSHOT_API_KEY in your .env file.");
2158
2171
  }
@@ -2314,7 +2327,7 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2314
2327
  * modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once
2315
2328
  * real traffic surfaces 400s.
2316
2329
  */
2317
- async function invokeBaidu(body, incomingHeaders = {}) {
2330
+ async function invokeBaidu(body, _incomingHeaders = {}) {
2318
2331
  if (!config.baidu?.apiKey) {
2319
2332
  throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file.");
2320
2333
  }
@@ -2450,12 +2463,34 @@ function convertOpenAIToAnthropic(response) {
2450
2463
  const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
2451
2464
 
2452
2465
  // Extract text content and reasoning from thinking models
2453
- const textContent = typeof message.content === 'string' ? message.content : '';
2466
+ let textContent = typeof message.content === 'string' ? message.content : '';
2454
2467
  const reasoningContent = typeof message.reasoning_content === 'string' ? message.reasoning_content : '';
2455
2468
 
2456
- // Emit reasoning_content as a proper thinking block (not discarded)
2457
- if (reasoningContent) {
2458
- content.push({ type: "thinking", thinking: reasoningContent });
2469
+ // GLM-family models leak reasoning into `content` in two shapes:
2470
+ // 1. matched <think>...</think> pairs;
2471
+ // 2. an ORPHAN closing </think> — GLM's chat template opens <think>
2472
+ // inside the prompt, so the completion starts mid-reasoning and only
2473
+ // emits the closer (observed live 2026-08-23, baidu/glm-5.2:
2474
+ // "Hi. What need?</think>Hi. What need?" reached the client verbatim).
2475
+ let leakedThinking = '';
2476
+ if (textContent.includes('</think>')) {
2477
+ if (textContent.includes('<think>')) {
2478
+ const thoughts = [];
2479
+ textContent = textContent
2480
+ .replace(/<think>([\s\S]*?)<\/think>/g, (_, t) => { thoughts.push(t.trim()); return ''; })
2481
+ .trim();
2482
+ leakedThinking = thoughts.filter(Boolean).join('\n\n');
2483
+ } else {
2484
+ const idx = textContent.indexOf('</think>');
2485
+ leakedThinking = textContent.slice(0, idx).trim();
2486
+ textContent = textContent.slice(idx + '</think>'.length).trim();
2487
+ }
2488
+ }
2489
+
2490
+ // Emit reasoning (side-channel and/or leaked) as a proper thinking block
2491
+ const combinedThinking = [reasoningContent, leakedThinking].filter(Boolean).join('\n\n');
2492
+ if (combinedThinking) {
2493
+ content.push({ type: "thinking", thinking: combinedThinking });
2459
2494
  }
2460
2495
 
2461
2496
  if (textContent) {
@@ -2551,7 +2586,7 @@ function sanitizeSchemaForGemini(schema) {
2551
2586
  * (generativelanguage.googleapis.com), not Google Cloud Vertex AI.
2552
2587
  * Converts Anthropic format to Gemini format and back.
2553
2588
  */
2554
- async function invokeVertex(body, incomingHeaders = {}) {
2589
+ async function invokeVertex(body, _incomingHeaders = {}) {
2555
2590
  const apiKey = config.vertex?.apiKey;
2556
2591
 
2557
2592
  if (!apiKey) {
@@ -2796,7 +2831,7 @@ function convertGeminiToAnthropic(response, requestedModel) {
2796
2831
  };
2797
2832
  }
2798
2833
 
2799
- async function invokeCodex(body, incomingHeaders = {}) {
2834
+ async function invokeCodex(body, _incomingHeaders = {}) {
2800
2835
  const { getCodexProcess } = require("./codex-process");
2801
2836
  const { convertAnthropicToCodexPrompt, convertCodexResponseToAnthropic } = require("./codex-utils");
2802
2837
 
@@ -2920,7 +2955,6 @@ const LYNKR_BADGE_PREFIX_RE = /^\*\[Lynkr\][^*\n]*\*\s*/;
2920
2955
  function stripLynkrBadges(messages) {
2921
2956
  if (!Array.isArray(messages)) return messages;
2922
2957
  let mutated = false;
2923
- let badgeCount = 0;
2924
2958
  const out = messages.map((msg) => {
2925
2959
  if (msg?.role !== 'assistant') return msg;
2926
2960
 
@@ -2931,7 +2965,6 @@ function stripLynkrBadges(messages) {
2931
2965
  if (!LYNKR_BADGE_PREFIX_RE.test(msg.content)) return msg;
2932
2966
  const stripped = msg.content.replace(LYNKR_BADGE_PREFIX_RE, '');
2933
2967
  mutated = true;
2934
- badgeCount++;
2935
2968
  // Badge-only content must not become an empty string — Anthropic
2936
2969
  // rejects empty assistant content (this is the interrupted-response
2937
2970
  // continuation case, where the partial text WAS just the badge).
@@ -2950,7 +2983,6 @@ function stripLynkrBadges(messages) {
2950
2983
  for (const b of msg.content) {
2951
2984
  if (b?.type === 'text' && typeof b.text === 'string' && LYNKR_BADGE_PREFIX_RE.test(b.text)) {
2952
2985
  changed = true;
2953
- badgeCount++;
2954
2986
  const strippedText = b.text.replace(LYNKR_BADGE_PREFIX_RE, '');
2955
2987
  if (strippedText.trim()) rebuilt.push({ ...b, text: strippedText });
2956
2988
  // badge-only block → drop
@@ -3853,6 +3885,8 @@ function destroyHttpAgents() {
3853
3885
 
3854
3886
  module.exports = {
3855
3887
  invokeModel,
3888
+ computeCostUsd,
3889
+ convertOpenAIToAnthropic,
3856
3890
  invokeAzureAnthropic,
3857
3891
  invokeZai,
3858
3892
  invokeOllama,