lynkr 9.14.1 → 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 (48) 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 +85 -11
  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 +52 -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/logger/oversized-error-stream.js +1 -1
  23. package/src/memory/extractor.js +3 -3
  24. package/src/memory/search.js +2 -2
  25. package/src/memory/store.js +9 -24
  26. package/src/memory/tools.js +14 -0
  27. package/src/observability/metrics.js +0 -3
  28. package/src/orchestrator/index.js +1 -80
  29. package/src/orchestrator/sse-transformer.js +68 -10
  30. package/src/policy/safe-commands.js +0 -1
  31. package/src/routing/calibration.js +21 -1
  32. package/src/routing/client-profiles.js +26 -0
  33. package/src/routing/complexity-analyzer.js +5 -8
  34. package/src/routing/confidence-scorer.js +2 -2
  35. package/src/routing/cost-optimizer.js +1 -2
  36. package/src/routing/deescalator.js +1 -1
  37. package/src/routing/embedding-cache.js +0 -1
  38. package/src/routing/intent-score.js +0 -1
  39. package/src/routing/knn-router.js +27 -5
  40. package/src/routing/latency-tracker.js +1 -1
  41. package/src/routing/regret-estimator.js +1 -1
  42. package/src/stores/file-store.js +5 -1
  43. package/src/training/trajectory-compressor.js +1 -1
  44. package/src/workers/pool.js +1 -1
  45. package/src/workers/worker.js +1 -1
  46. package/scripts/convert-routellm.py +0 -105
  47. package/src/diff/comments.js +0 -153
  48. 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.1",
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");
@@ -734,7 +760,10 @@ router.post("/chat/completions", async (req, res) => {
734
760
  delta: {},
735
761
  logprobs: null,
736
762
  finish_reason: openaiResponse.choices[0].finish_reason
737
- }]
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
738
767
  };
739
768
 
740
769
  logger.debug({ chunk: "finish", finishReason: openaiResponse.choices[0].finish_reason }, "Sending finish chunk");
@@ -839,6 +868,24 @@ router.post("/chat/completions", async (req, res) => {
839
868
  finishReason: openaiResponse.choices[0].finish_reason
840
869
  }, "=== OPENAI CHAT COMPLETION RESPONSE ===");
841
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
+
842
889
  res.json(openaiResponse);
843
890
  }
844
891
 
@@ -1043,7 +1090,6 @@ function getConfiguredProviders() {
1043
1090
  router.get("/models", (req, res) => {
1044
1091
  try {
1045
1092
  const providers = getConfiguredProviders();
1046
- const primaryProvider = config.modelProvider?.type || "databricks";
1047
1093
  const timestamp = Math.floor(Date.now() / 1000);
1048
1094
  const models = [];
1049
1095
  const seenModelIds = new Set();
@@ -1480,7 +1526,7 @@ router.post("/embeddings", async (req, res) => {
1480
1526
  const startTime = Date.now();
1481
1527
 
1482
1528
  try {
1483
- const { input, model, encoding_format } = req.body;
1529
+ const { input, model, encoding_format: encodingFormat } = req.body;
1484
1530
 
1485
1531
  if (!input) {
1486
1532
  return res.status(400).json({
@@ -1492,6 +1538,16 @@ router.post("/embeddings", async (req, res) => {
1492
1538
  });
1493
1539
  }
1494
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
+
1495
1551
  const inputs = Array.isArray(input) ? input : [input];
1496
1552
 
1497
1553
  logger.debug({
@@ -1565,6 +1621,20 @@ router.post("/embeddings", async (req, res) => {
1565
1621
  totalTokens: embeddingResponse.usage?.total_tokens || 0
1566
1622
  }, "=== EMBEDDINGS RESPONSE ===");
1567
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
+
1568
1638
  res.json(embeddingResponse);
1569
1639
 
1570
1640
  } catch (error) {
@@ -1775,7 +1845,7 @@ router.post("/responses", async (req, res) => {
1775
1845
 
1776
1846
  // SSE comment keepalive (spec-compliant, ignored by all clients)
1777
1847
  const keepalive = setInterval(() => {
1778
- try { res.write(`: keepalive\n\n`); } catch {}
1848
+ try { res.write(`: keepalive\n\n`); } catch { /* client gone — best-effort keepalive */ }
1779
1849
  }, 2000);
1780
1850
 
1781
1851
  let result;
@@ -1882,21 +1952,25 @@ router.post("/responses", async (req, res) => {
1882
1952
  if (taskArgs.prompt && taskArgs.prompt.toLowerCase().includes("read")) {
1883
1953
  exploreCmd = "cat README.md 2>/dev/null && cat package.json 2>/dev/null";
1884
1954
  }
1885
- } catch {}
1955
+ } catch (err) {
1956
+ logger.debug({ err: err?.message }, "Failed to parse task tool arguments — using default explore command");
1957
+ }
1886
1958
  }
1887
1959
  converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", exploreCmd] }) } });
1888
1960
  continue;
1889
1961
  }
1890
1962
 
1891
1963
  let args = {};
1892
- 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
+ }
1893
1967
 
1894
1968
  // Already a shell command — normalise to array format
1895
1969
  if (ln === "shell" || ln === "bash") {
1896
1970
  let cmd = args.command || "";
1897
1971
  // Handle string-encoded arrays: '["bash","-c","ls"]'
1898
1972
  if (typeof cmd === "string" && cmd.trim().startsWith("[")) {
1899
- try { cmd = JSON.parse(cmd); } catch {}
1973
+ try { cmd = JSON.parse(cmd); } catch { /* not a JSON array — keep as plain string */ }
1900
1974
  }
1901
1975
  // Extract the actual command from ["bash", "-c", "actual command"]
1902
1976
  if (Array.isArray(cmd) && cmd.length >= 3 && cmd[0] === "bash" && cmd[1] === "-c") {
@@ -2107,7 +2181,7 @@ router.post("/responses", async (req, res) => {
2107
2181
  sequence_number: 1
2108
2182
  })}\n\n`
2109
2183
  ].join("");
2110
- try { res.end(errorPayload); } catch { try { res.end(); } catch {} }
2184
+ try { res.end(errorPayload); } catch { try { res.end(); } catch { /* res already ended — best-effort */ } }
2111
2185
  }
2112
2186
 
2113
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
  }
@@ -827,7 +835,7 @@ async function invokeOpenRouter(body, incomingHeaders = {}) {
827
835
  // Eden AI is an OpenAI-compatible gateway (provider/model naming, EU/GDPR).
828
836
  // It speaks the same wire format as OpenRouter, so this mirrors invokeOpenRouter
829
837
  // and reuses the shared Anthropic<->OpenAI converters.
830
- async function invokeEdenAI(body, incomingHeaders = {}) {
838
+ async function invokeEdenAI(body, _incomingHeaders = {}) {
831
839
  if (!config.edenai?.endpoint || !config.edenai?.apiKey) {
832
840
  throw new Error("Eden AI endpoint or API key is not configured.");
833
841
  }
@@ -912,7 +920,7 @@ function derivePromptCacheKey(body) {
912
920
  return "conv-" + crypto.createHash("sha1").update(text.slice(0, 4000)).digest("hex").slice(0, 32);
913
921
  }
914
922
 
915
- async function invokeAzureOpenAI(body, incomingHeaders = {}) {
923
+ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
916
924
  if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
917
925
  throw new Error("Azure OpenAI endpoint or API key is not configured.");
918
926
  }
@@ -1357,7 +1365,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1357
1365
  }
1358
1366
 
1359
1367
 
1360
- async function invokeOpenAI(body, incomingHeaders = {}) {
1368
+ async function invokeOpenAI(body, _incomingHeaders = {}) {
1361
1369
  if (!config.openai?.apiKey) {
1362
1370
  throw new Error("OpenAI API key is not configured.");
1363
1371
  }
@@ -1495,7 +1503,7 @@ async function invokeAtlas(body) {
1495
1503
  }, "Atlas Cloud");
1496
1504
  }
1497
1505
 
1498
- async function invokeLlamaCpp(body, incomingHeaders = {}) {
1506
+ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1499
1507
  if (!config.llamacpp?.endpoint) {
1500
1508
  throw new Error("llama.cpp endpoint is not configured.");
1501
1509
  }
@@ -1641,7 +1649,7 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1641
1649
  return result;
1642
1650
  }
1643
1651
 
1644
- async function invokeLMStudio(body, incomingHeaders = {}) {
1652
+ async function invokeLMStudio(body, _incomingHeaders = {}) {
1645
1653
  if (!config.lmstudio?.endpoint) {
1646
1654
  throw new Error("LM Studio endpoint is not configured.");
1647
1655
  }
@@ -1768,7 +1776,7 @@ function normalizeBodyForConverse(body) {
1768
1776
  return normalized;
1769
1777
  }
1770
1778
 
1771
- async function invokeBedrock(body, incomingHeaders = {}) {
1779
+ async function invokeBedrock(body, _incomingHeaders = {}) {
1772
1780
  if (!config.bedrock?.apiKey) {
1773
1781
  throw new Error(
1774
1782
  "AWS Bedrock requires AWS_BEDROCK_API_KEY (Bearer token). " +
@@ -1781,11 +1789,9 @@ async function invokeBedrock(body, incomingHeaders = {}) {
1781
1789
 
1782
1790
  // Inject standard tools if needed
1783
1791
  let toolsToSend = body.tools;
1784
- let toolsInjected = false;
1785
1792
 
1786
1793
  if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1787
1794
  toolsToSend = STANDARD_TOOLS;
1788
- toolsInjected = true;
1789
1795
  logger.debug({
1790
1796
  injectedToolCount: STANDARD_TOOLS.length,
1791
1797
  injectedToolNames: STANDARD_TOOL_NAMES,
@@ -1954,7 +1960,7 @@ async function invokeBedrock(body, incomingHeaders = {}) {
1954
1960
  * Z.AI offers GLM models through an Anthropic-compatible API at ~1/7 the cost.
1955
1961
  * Minimal transformation needed - mostly passthrough with model mapping.
1956
1962
  */
1957
- async function invokeZai(body, incomingHeaders = {}) {
1963
+ async function invokeZai(body, _incomingHeaders = {}) {
1958
1964
  if (!config.zai?.apiKey) {
1959
1965
  throw new Error("Z.AI API key is not configured. Set ZAI_API_KEY in your .env file.");
1960
1966
  }
@@ -2159,7 +2165,7 @@ async function invokeZai(body, incomingHeaders = {}) {
2159
2165
  * Moonshot offers Kimi models through an OpenAI-compatible chat completions API.
2160
2166
  * Uses native system role support (unlike Z.AI which merges into user message).
2161
2167
  */
2162
- async function invokeMoonshot(body, incomingHeaders = {}) {
2168
+ async function invokeMoonshot(body, _incomingHeaders = {}) {
2163
2169
  if (!config.moonshot?.apiKey) {
2164
2170
  throw new Error("Moonshot API key is not configured. Set MOONSHOT_API_KEY in your .env file.");
2165
2171
  }
@@ -2321,7 +2327,7 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2321
2327
  * modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once
2322
2328
  * real traffic surfaces 400s.
2323
2329
  */
2324
- async function invokeBaidu(body, incomingHeaders = {}) {
2330
+ async function invokeBaidu(body, _incomingHeaders = {}) {
2325
2331
  if (!config.baidu?.apiKey) {
2326
2332
  throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file.");
2327
2333
  }
@@ -2457,12 +2463,34 @@ function convertOpenAIToAnthropic(response) {
2457
2463
  const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
2458
2464
 
2459
2465
  // Extract text content and reasoning from thinking models
2460
- const textContent = typeof message.content === 'string' ? message.content : '';
2466
+ let textContent = typeof message.content === 'string' ? message.content : '';
2461
2467
  const reasoningContent = typeof message.reasoning_content === 'string' ? message.reasoning_content : '';
2462
2468
 
2463
- // Emit reasoning_content as a proper thinking block (not discarded)
2464
- if (reasoningContent) {
2465
- 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 });
2466
2494
  }
2467
2495
 
2468
2496
  if (textContent) {
@@ -2558,7 +2586,7 @@ function sanitizeSchemaForGemini(schema) {
2558
2586
  * (generativelanguage.googleapis.com), not Google Cloud Vertex AI.
2559
2587
  * Converts Anthropic format to Gemini format and back.
2560
2588
  */
2561
- async function invokeVertex(body, incomingHeaders = {}) {
2589
+ async function invokeVertex(body, _incomingHeaders = {}) {
2562
2590
  const apiKey = config.vertex?.apiKey;
2563
2591
 
2564
2592
  if (!apiKey) {
@@ -2803,7 +2831,7 @@ function convertGeminiToAnthropic(response, requestedModel) {
2803
2831
  };
2804
2832
  }
2805
2833
 
2806
- async function invokeCodex(body, incomingHeaders = {}) {
2834
+ async function invokeCodex(body, _incomingHeaders = {}) {
2807
2835
  const { getCodexProcess } = require("./codex-process");
2808
2836
  const { convertAnthropicToCodexPrompt, convertCodexResponseToAnthropic } = require("./codex-utils");
2809
2837
 
@@ -2927,7 +2955,6 @@ const LYNKR_BADGE_PREFIX_RE = /^\*\[Lynkr\][^*\n]*\*\s*/;
2927
2955
  function stripLynkrBadges(messages) {
2928
2956
  if (!Array.isArray(messages)) return messages;
2929
2957
  let mutated = false;
2930
- let badgeCount = 0;
2931
2958
  const out = messages.map((msg) => {
2932
2959
  if (msg?.role !== 'assistant') return msg;
2933
2960
 
@@ -2938,7 +2965,6 @@ function stripLynkrBadges(messages) {
2938
2965
  if (!LYNKR_BADGE_PREFIX_RE.test(msg.content)) return msg;
2939
2966
  const stripped = msg.content.replace(LYNKR_BADGE_PREFIX_RE, '');
2940
2967
  mutated = true;
2941
- badgeCount++;
2942
2968
  // Badge-only content must not become an empty string — Anthropic
2943
2969
  // rejects empty assistant content (this is the interrupted-response
2944
2970
  // continuation case, where the partial text WAS just the badge).
@@ -2957,7 +2983,6 @@ function stripLynkrBadges(messages) {
2957
2983
  for (const b of msg.content) {
2958
2984
  if (b?.type === 'text' && typeof b.text === 'string' && LYNKR_BADGE_PREFIX_RE.test(b.text)) {
2959
2985
  changed = true;
2960
- badgeCount++;
2961
2986
  const strippedText = b.text.replace(LYNKR_BADGE_PREFIX_RE, '');
2962
2987
  if (strippedText.trim()) rebuilt.push({ ...b, text: strippedText });
2963
2988
  // badge-only block → drop
@@ -3860,6 +3885,8 @@ function destroyHttpAgents() {
3860
3885
 
3861
3886
  module.exports = {
3862
3887
  invokeModel,
3888
+ computeCostUsd,
3889
+ convertOpenAIToAnthropic,
3863
3890
  invokeAzureAnthropic,
3864
3891
  invokeZai,
3865
3892
  invokeOllama,
@@ -25,7 +25,7 @@ function stringSimilarity(s1, s2) {
25
25
  // Tokenize by whitespace and common delimiters
26
26
  const tokenize = (s) => new Set(
27
27
  s.toLowerCase()
28
- .split(/[\s\-_\/\.\,\:\;]+/)
28
+ .split(/[\s\-_/.,:;]+/)
29
29
  .filter(t => t.length > 0)
30
30
  );
31
31