lynkr 9.14.1 → 9.14.3

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 (96) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +16 -22
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
@@ -77,11 +77,26 @@ function _stripInternalFields(body) {
77
77
  return cleaned || body;
78
78
  }
79
79
 
80
- async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride }, providerLabel) {
80
+ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride, timeoutMs }, providerLabel) {
81
81
  const agent = url.startsWith('https:') ? httpsAgent : httpAgent;
82
82
  body = _stripInternalFields(body);
83
83
  const isStreaming = body.stream === true;
84
84
 
85
+ // Optional per-call abort timeout (opt-in via timeoutMs) — most callers
86
+ // omit it and keep the historical unbounded-fetch behavior. Added for
87
+ // llama.cpp (see invokeLlamaCpp): a dead upstream that accepts the TCP
88
+ // connection but never answers at the application layer hangs fetch()
89
+ // indefinitely — live-observed at ~75s, riding an incidental OS/tunnel
90
+ // socket timeout rather than any deliberate bound. This makes the
91
+ // already-configured (but previously unused) LLAMACPP_TIMEOUT_MS real.
92
+ // A fresh signal is created per attempt below (not hoisted here) —
93
+ // AbortSignal.timeout()'s clock starts at construction, so one signal
94
+ // shared across withRetry's attempts would leave later retries with a
95
+ // shrinking (or already-expired) budget instead of a full timeout each.
96
+ const makeSignal = () => (typeof timeoutMs === "number" && timeoutMs > 0
97
+ ? AbortSignal.timeout(timeoutMs)
98
+ : undefined);
99
+
85
100
  // Streaming requests can't be retried, so handle them directly
86
101
  if (isStreaming) {
87
102
  const response = await fetch(url, {
@@ -89,6 +104,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
89
104
  headers,
90
105
  body: JSON.stringify(body),
91
106
  agent,
107
+ signal: makeSignal(),
92
108
  });
93
109
 
94
110
  logger.debug({
@@ -122,6 +138,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
122
138
  headers,
123
139
  body: JSON.stringify(body),
124
140
  agent,
141
+ signal: makeSignal(),
125
142
  });
126
143
  const text = await response.text();
127
144
 
@@ -165,7 +182,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
165
182
  });
166
183
  }
167
184
 
168
- async function invokeDatabricks(body, incomingHeaders = {}) {
185
+ async function invokeDatabricks(body, _incomingHeaders = {}) {
169
186
  if (!config.databricks?.url) {
170
187
  throw new Error("Databricks configuration is missing required URL.");
171
188
  }
@@ -423,12 +440,20 @@ function _liftLeakedThinkingBlocks(response) {
423
440
  const newContent = [];
424
441
  let lifted = 0;
425
442
  for (const block of payload.content) {
426
- if (block?.type === "text" && typeof block.text === "string" && block.text.includes("<think>")) {
443
+ if (block?.type === "text" && typeof block.text === "string" &&
444
+ (block.text.includes("<think>") || block.text.includes("</think>"))) {
427
445
  const thoughts = [];
428
446
  let m;
429
447
  while ((m = thinkRegex.exec(block.text)) !== null) thoughts.push(m[1].trim());
430
448
  thinkRegex.lastIndex = 0;
431
- const cleaned = block.text.replace(thinkRegex, "").trim();
449
+ let cleaned = block.text.replace(thinkRegex, "").trim();
450
+ // Orphan closing tag: GLM-family templates open <think> in the prompt,
451
+ // so the completion may carry reasoning terminated by a bare </think>.
452
+ const orphanIdx = cleaned.indexOf("</think>");
453
+ if (orphanIdx !== -1) {
454
+ thoughts.push(cleaned.slice(0, orphanIdx).trim());
455
+ cleaned = cleaned.slice(orphanIdx + "</think>".length).trim();
456
+ }
432
457
  const merged = thoughts.filter(Boolean).join("\n\n");
433
458
  if (merged) {
434
459
  newContent.push({ type: "thinking", thinking: merged });
@@ -489,7 +514,7 @@ async function _throwIfOllamaError(response, modelName) {
489
514
  return response;
490
515
  }
491
516
 
492
- async function invokeOllama(body, incomingHeaders = {}) {
517
+ async function invokeOllama(body, _incomingHeaders = {}) {
493
518
  if (!config.ollama?.endpoint) {
494
519
  throw new Error("Ollama endpoint is not configured.");
495
520
  }
@@ -754,7 +779,7 @@ async function invokeOllama(body, incomingHeaders = {}) {
754
779
  );
755
780
  }
756
781
 
757
- async function invokeOpenRouter(body, incomingHeaders = {}) {
782
+ async function invokeOpenRouter(body, _incomingHeaders = {}) {
758
783
  if (!config.openrouter?.endpoint || !config.openrouter?.apiKey) {
759
784
  throw new Error("OpenRouter endpoint or API key is not configured.");
760
785
  }
@@ -821,13 +846,27 @@ async function invokeOpenRouter(body, incomingHeaders = {}) {
821
846
  }, "Sending tools to OpenRouter");
822
847
  }
823
848
 
824
- return performJsonRequest(endpoint, { headers, body: openRouterBody }, "OpenRouter");
849
+ // Same pre-return 429 check as invokeMoonshot/invokeBaidu — see the comment
850
+ // on invokeOpenAI's equivalent check for why this matters for streaming.
851
+ const response = await performJsonRequest(endpoint, {
852
+ headers,
853
+ body: openRouterBody,
854
+ retryableStatusesOverride: [500, 502, 503, 504],
855
+ }, "OpenRouter");
856
+
857
+ if (!response.ok && response.status === 429) {
858
+ const err = new Error(`OpenRouter rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
859
+ err.status = 429;
860
+ throw err;
861
+ }
862
+
863
+ return response;
825
864
  }
826
865
 
827
866
  // Eden AI is an OpenAI-compatible gateway (provider/model naming, EU/GDPR).
828
867
  // It speaks the same wire format as OpenRouter, so this mirrors invokeOpenRouter
829
868
  // and reuses the shared Anthropic<->OpenAI converters.
830
- async function invokeEdenAI(body, incomingHeaders = {}) {
869
+ async function invokeEdenAI(body, _incomingHeaders = {}) {
831
870
  if (!config.edenai?.endpoint || !config.edenai?.apiKey) {
832
871
  throw new Error("Eden AI endpoint or API key is not configured.");
833
872
  }
@@ -912,7 +951,7 @@ function derivePromptCacheKey(body) {
912
951
  return "conv-" + crypto.createHash("sha1").update(text.slice(0, 4000)).digest("hex").slice(0, 32);
913
952
  }
914
953
 
915
- async function invokeAzureOpenAI(body, incomingHeaders = {}) {
954
+ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
916
955
  if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
917
956
  throw new Error("Azure OpenAI endpoint or API key is not configured.");
918
957
  }
@@ -1077,7 +1116,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1077
1116
  originalMessageCount: (body.messages || []).length,
1078
1117
  convertedMessageCount: azureBody.messages.length,
1079
1118
  messageRoles: (body.messages || []).map(m => m.role),
1080
- }, "=== CONTINUATION REQUEST DETECTED - using minimal system prompt to avoid Azure content filter ===");
1119
+ }, "Continuation request — system prompt PRESERVED, system-reminder blocks stripped uniformly (the filter trigger was the reminders, not the prompt; see system-message handling below)");
1081
1120
  } else {
1082
1121
  logger.debug({
1083
1122
  hasToolResults: false,
@@ -1221,17 +1260,34 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1221
1260
  // the conversation's first user message — stable across every turn of
1222
1261
  // the same task. Keep per-key traffic under ~15 req/min.
1223
1262
  ...(isGpt5 ? { prompt_cache_key: derivePromptCacheKey(body) } : {}),
1224
- stream: false
1263
+ stream: body.stream ?? false
1225
1264
  };
1226
1265
  logger.debug({
1227
1266
  format: "responses",
1228
1267
  inputCount: responsesBody.input?.length,
1229
1268
  model: responsesBody.model,
1230
- hasTools: !!responsesBody.tools
1269
+ hasTools: !!responsesBody.tools,
1270
+ streaming: responsesBody.stream,
1231
1271
  }, "Using Responses API format");
1232
1272
 
1233
1273
  const result = await performJsonRequest(endpoint, { headers, body: responsesBody }, "Azure OpenAI Responses");
1234
1274
 
1275
+ // Streaming: hand back a synthetic OpenAI-Chat-Completions-shaped SSE
1276
+ // stream (see src/orchestrator/azure-responses-sse.js for why that shape
1277
+ // rather than Anthropic SSE directly) so the orchestrator's existing
1278
+ // sseTransform.openaiToAnthropicSSE call site — already wired for
1279
+ // moonshot/baidu/openai's raw streams — handles this one identically,
1280
+ // with zero changes needed there. Skips the whole buffered
1281
+ // output-array/dedup/usage-remap conversion below entirely; that logic
1282
+ // only makes sense for a complete, already-materialized response.
1283
+ if (result.stream) {
1284
+ const { azureResponsesToOpenAIChunks } = require("../orchestrator/azure-responses-sse");
1285
+ return {
1286
+ ...result,
1287
+ stream: azureResponsesToOpenAIChunks(result.stream, { model: responsesBody.model }),
1288
+ };
1289
+ }
1290
+
1235
1291
  // Convert Responses API response to Chat Completions format
1236
1292
  if (result.ok && result.json?.output) {
1237
1293
  const outputArray = result.json.output || [];
@@ -1298,6 +1354,8 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1298
1354
  toolCallNames: toolCalls.map(tc => tc.function.name)
1299
1355
  }, "Parsing Responses API output");
1300
1356
 
1357
+ const _rawUsage = result.json.usage;
1358
+
1301
1359
  result.json = {
1302
1360
  id: result.json.id,
1303
1361
  object: "chat.completion",
@@ -1314,15 +1372,15 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1314
1372
  }],
1315
1373
  // Responses API usage is {input,output}_tokens; downstream reads
1316
1374
  // Chat Completions' {prompt,completion}_tokens.
1317
- usage: result.json.usage ? {
1318
- prompt_tokens: result.json.usage.prompt_tokens ?? result.json.usage.input_tokens ?? 0,
1319
- completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0,
1320
- total_tokens: result.json.usage.total_tokens
1321
- ?? ((result.json.usage.input_tokens ?? 0) + (result.json.usage.output_tokens ?? 0)),
1375
+ usage: _rawUsage ? {
1376
+ prompt_tokens: _rawUsage.prompt_tokens ?? _rawUsage.input_tokens ?? 0,
1377
+ completion_tokens: _rawUsage.completion_tokens ?? _rawUsage.output_tokens ?? 0,
1378
+ total_tokens: _rawUsage.total_tokens
1379
+ ?? ((_rawUsage.input_tokens ?? 0) + (_rawUsage.output_tokens ?? 0)),
1322
1380
  // Provider-side prompt-cache hits (Responses API: input_tokens_details,
1323
1381
  // Chat Completions: prompt_tokens_details) — telemetry reads this name.
1324
- cache_read_input_tokens: result.json.usage.input_tokens_details?.cached_tokens
1325
- ?? result.json.usage.prompt_tokens_details?.cached_tokens
1382
+ cache_read_input_tokens: _rawUsage.input_tokens_details?.cached_tokens
1383
+ ?? _rawUsage.prompt_tokens_details?.cached_tokens
1326
1384
  ?? null,
1327
1385
  } : undefined
1328
1386
  };
@@ -1357,7 +1415,7 @@ async function invokeAzureOpenAI(body, incomingHeaders = {}) {
1357
1415
  }
1358
1416
 
1359
1417
 
1360
- async function invokeOpenAI(body, incomingHeaders = {}) {
1418
+ async function invokeOpenAI(body, _incomingHeaders = {}) {
1361
1419
  if (!config.openai?.apiKey) {
1362
1420
  throw new Error("OpenAI API key is not configured.");
1363
1421
  }
@@ -1429,7 +1487,23 @@ async function invokeOpenAI(body, incomingHeaders = {}) {
1429
1487
  max_tokens: openAIBody.max_tokens,
1430
1488
  }, "=== OPENAI REQUEST ===");
1431
1489
 
1432
- return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
1490
+ // Same pre-return 429 check as invokeMoonshot/invokeBaidu: performJsonRequest's
1491
+ // streaming branch never throws on a bad status (retry is skipped entirely for
1492
+ // stream:true), so without this a 429 was silently handed back as a "successful"
1493
+ // stream/response and tier-fallback never got a chance to climb.
1494
+ const response = await performJsonRequest(endpoint, {
1495
+ headers,
1496
+ body: openAIBody,
1497
+ retryableStatusesOverride: [500, 502, 503, 504],
1498
+ }, "OpenAI");
1499
+
1500
+ if (!response.ok && response.status === 429) {
1501
+ const err = new Error(`OpenAI rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
1502
+ err.status = 429;
1503
+ throw err;
1504
+ }
1505
+
1506
+ return response;
1433
1507
  }
1434
1508
 
1435
1509
  async function invokeAtlas(body) {
@@ -1487,15 +1561,29 @@ async function invokeAtlas(body) {
1487
1561
 
1488
1562
  // Chat completions are billable POSTs, so Atlas requests are never replayed
1489
1563
  // automatically. Callers can retry explicitly with their own idempotency policy.
1490
- return performJsonRequest(endpoint, {
1564
+ // maxRetriesOverride/retryableStatusesOverride stay at 0/[] — this does NOT
1565
+ // retry the Atlas request itself, it only decides whether invokeModel's
1566
+ // caller escalates to a DIFFERENT provider/tier on 429, same as
1567
+ // invokeMoonshot/invokeBaidu/invokeOpenAI/invokeOpenRouter already do.
1568
+ // Without this, a 429 here was silently handed back as a "successful"
1569
+ // response and tier-fallback never got a chance to climb.
1570
+ const response = await performJsonRequest(endpoint, {
1491
1571
  headers,
1492
1572
  body: atlasBody,
1493
1573
  maxRetriesOverride: 0,
1494
1574
  retryableStatusesOverride: [],
1495
1575
  }, "Atlas Cloud");
1576
+
1577
+ if (!response.ok && response.status === 429) {
1578
+ const err = new Error(`Atlas Cloud rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
1579
+ err.status = 429;
1580
+ throw err;
1581
+ }
1582
+
1583
+ return response;
1496
1584
  }
1497
1585
 
1498
- async function invokeLlamaCpp(body, incomingHeaders = {}) {
1586
+ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1499
1587
  if (!config.llamacpp?.endpoint) {
1500
1588
  throw new Error("llama.cpp endpoint is not configured.");
1501
1589
  }
@@ -1572,10 +1660,18 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1572
1660
  }, 'llama.cpp: Removed consecutive duplicate roles from message sequence');
1573
1661
  }
1574
1662
 
1663
+ // max_tokens floor: reasoning-capable local models (live-confirmed on this
1664
+ // deployment's GPT-OSS build) can spend their entire budget on
1665
+ // `reasoning_content` before writing any visible answer — a caller-
1666
+ // supplied tiny budget (e.g. the caveman probe's max_tokens:1 seen in
1667
+ // production logs) then returns finish_reason:"length" with zero visible
1668
+ // text. Same fix already shipped for Ollama as LYNKR_OLLAMA_MIN_MAX_TOKENS;
1669
+ // mirrored here rather than inventing a different mechanism.
1670
+ const llamacppMinMaxTokens = Number(process.env.LYNKR_LLAMACPP_MIN_MAX_TOKENS) || 1536;
1575
1671
  const llamacppBody = {
1576
1672
  messages: deduplicated,
1577
1673
  temperature: body.temperature ?? 0.7,
1578
- max_tokens: body.max_tokens ?? 16384,
1674
+ max_tokens: Math.max(body.max_tokens ?? 16384, llamacppMinMaxTokens),
1579
1675
  top_p: body.top_p ?? 1.0,
1580
1676
  stream: body.stream ?? false
1581
1677
  };
@@ -1625,7 +1721,11 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1625
1721
  }))
1626
1722
  }, "=== LLAMA.CPP REQUEST ===");
1627
1723
 
1628
- const result = await performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
1724
+ const result = await performJsonRequest(
1725
+ endpoint,
1726
+ { headers, body: llamacppBody, timeoutMs: config.llamacpp.timeout },
1727
+ "llama.cpp",
1728
+ );
1629
1729
 
1630
1730
  // Context overflow is a CAPACITY error, not a request error: the local
1631
1731
  // model's slot is simply too small for this conversation (llama-server
@@ -1641,7 +1741,7 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
1641
1741
  return result;
1642
1742
  }
1643
1743
 
1644
- async function invokeLMStudio(body, incomingHeaders = {}) {
1744
+ async function invokeLMStudio(body, _incomingHeaders = {}) {
1645
1745
  if (!config.lmstudio?.endpoint) {
1646
1746
  throw new Error("LM Studio endpoint is not configured.");
1647
1747
  }
@@ -1768,7 +1868,7 @@ function normalizeBodyForConverse(body) {
1768
1868
  return normalized;
1769
1869
  }
1770
1870
 
1771
- async function invokeBedrock(body, incomingHeaders = {}) {
1871
+ async function invokeBedrock(body, _incomingHeaders = {}) {
1772
1872
  if (!config.bedrock?.apiKey) {
1773
1873
  throw new Error(
1774
1874
  "AWS Bedrock requires AWS_BEDROCK_API_KEY (Bearer token). " +
@@ -1781,11 +1881,9 @@ async function invokeBedrock(body, incomingHeaders = {}) {
1781
1881
 
1782
1882
  // Inject standard tools if needed
1783
1883
  let toolsToSend = body.tools;
1784
- let toolsInjected = false;
1785
1884
 
1786
1885
  if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1787
1886
  toolsToSend = STANDARD_TOOLS;
1788
- toolsInjected = true;
1789
1887
  logger.debug({
1790
1888
  injectedToolCount: STANDARD_TOOLS.length,
1791
1889
  injectedToolNames: STANDARD_TOOL_NAMES,
@@ -1954,7 +2052,7 @@ async function invokeBedrock(body, incomingHeaders = {}) {
1954
2052
  * Z.AI offers GLM models through an Anthropic-compatible API at ~1/7 the cost.
1955
2053
  * Minimal transformation needed - mostly passthrough with model mapping.
1956
2054
  */
1957
- async function invokeZai(body, incomingHeaders = {}) {
2055
+ async function invokeZai(body, _incomingHeaders = {}) {
1958
2056
  if (!config.zai?.apiKey) {
1959
2057
  throw new Error("Z.AI API key is not configured. Set ZAI_API_KEY in your .env file.");
1960
2058
  }
@@ -2057,11 +2155,23 @@ async function invokeZai(body, incomingHeaders = {}) {
2057
2155
  zaiBody = { ...body };
2058
2156
  zaiBody.model = mappedModel;
2059
2157
 
2060
- // Force buffered mode: with stream:true this endpoint returns ANTHROPIC
2061
- // SSE, but the downstream transformer parses OPENAI SSE — every chunk is
2062
- // unreadable and the client receives an empty completion. Buffered JSON
2063
- // converts correctly; the router synthesizes client-side SSE as usual.
2064
- zaiBody.stream = false;
2158
+ // Honor body.stream as-is here. The ONLY caller that ever sets it true
2159
+ // for this branch is passthrough-stream.js's handleNativeStream — zai
2160
+ // isn't in sse-transformer.js's DEFAULT_OPENAI_SSE_PROVIDERS, so the
2161
+ // normal buffered orchestrator (orchestrator/index.js) always sets
2162
+ // cleanPayload.stream=false before invoking any provider not on that
2163
+ // allowlist, and never reaches this branch with stream:true itself.
2164
+ // Forcing false unconditionally (as this used to do) broke exactly that
2165
+ // caller: _anthropicNative() advertises this endpoint as native-passthrough
2166
+ // eligible, handleNativeStream sets stream:true and expects real
2167
+ // Anthropic SSE bytes back, but got a buffered JSON body instead —
2168
+ // handleNativeStream then saw no `.stream` field, silently gave up, and
2169
+ // fell back to the buffered orchestrator path, which called zai AGAIN.
2170
+ // Every native-passthrough request to this endpoint was double-invoking
2171
+ // the real API. If a future caller other than handleNativeStream ever
2172
+ // needs stream:true routed through the OpenAI-SSE transformer instead,
2173
+ // that's a different bug — add zai to DEFAULT_OPENAI_SSE_PROVIDERS then,
2174
+ // don't reintroduce this override.
2065
2175
 
2066
2176
  // Inject standard tools if client didn't send any (passthrough mode)
2067
2177
  if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
@@ -2159,7 +2269,7 @@ async function invokeZai(body, incomingHeaders = {}) {
2159
2269
  * Moonshot offers Kimi models through an OpenAI-compatible chat completions API.
2160
2270
  * Uses native system role support (unlike Z.AI which merges into user message).
2161
2271
  */
2162
- async function invokeMoonshot(body, incomingHeaders = {}) {
2272
+ async function invokeMoonshot(body, _incomingHeaders = {}) {
2163
2273
  if (!config.moonshot?.apiKey) {
2164
2274
  throw new Error("Moonshot API key is not configured. Set MOONSHOT_API_KEY in your .env file.");
2165
2275
  }
@@ -2216,12 +2326,18 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2216
2326
  // Only the moonshot-v1-* models accept caller-supplied values.
2217
2327
  const isKimiPinned = /^kimi-k/i.test(mappedModel);
2218
2328
 
2329
+ const { resolveThinkingParam } = require("./provider-capabilities");
2219
2330
  const moonshotBody = {
2220
2331
  model: mappedModel,
2221
2332
  messages,
2222
2333
  max_tokens: body.max_tokens || 16384,
2223
2334
  temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
2224
2335
  top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
2336
+ // kimi-k3 emits verbose reasoning_content by default, sharing the same
2337
+ // token budget as the answer — without this, reasoning alone can (and
2338
+ // did, live-confirmed) consume the whole max_tokens before any answer
2339
+ // is written. See resolveThinkingParam's doc comment for the full story.
2340
+ thinking: resolveThinkingParam(body),
2225
2341
  // Streaming honored since the Phase-2b sse-transformer landed: the raw
2226
2342
  // OpenAI SSE stream is returned below and reshaped in flight by the
2227
2343
  // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
@@ -2321,7 +2437,7 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
2321
2437
  * modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once
2322
2438
  * real traffic surfaces 400s.
2323
2439
  */
2324
- async function invokeBaidu(body, incomingHeaders = {}) {
2440
+ async function invokeBaidu(body, _incomingHeaders = {}) {
2325
2441
  if (!config.baidu?.apiKey) {
2326
2442
  throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file.");
2327
2443
  }
@@ -2365,12 +2481,18 @@ async function invokeBaidu(body, incomingHeaders = {}) {
2365
2481
  messages.unshift({ role: "system", content: systemContent });
2366
2482
  }
2367
2483
 
2484
+ const { resolveThinkingParam } = require("./provider-capabilities");
2368
2485
  const baiduBody = {
2369
2486
  model: mappedModel,
2370
2487
  messages,
2371
2488
  max_tokens: body.max_tokens || 16384,
2372
2489
  temperature: body.temperature ?? 0.7,
2373
2490
  top_p: body.top_p ?? 1.0,
2491
+ // glm-5.2 emits verbose reasoning_content by default, sharing the same
2492
+ // token budget as the answer — without this, reasoning alone can (and
2493
+ // did, live-confirmed) consume the whole max_tokens before any answer
2494
+ // is written. See resolveThinkingParam's doc comment for the full story.
2495
+ thinking: resolveThinkingParam(body),
2374
2496
  // Streaming honored once "baidu" is added to DEFAULT_OPENAI_SSE_PROVIDERS
2375
2497
  // (sse-transformer.js) and confirmed to match OpenAI SSE shape. Buffered
2376
2498
  // requests use the Anthropic conversion path below regardless.
@@ -2430,6 +2552,147 @@ async function invokeBaidu(body, incomingHeaders = {}) {
2430
2552
  return response;
2431
2553
  }
2432
2554
 
2555
+ /**
2556
+ * Fireworks AI Provider (serverless inference)
2557
+ *
2558
+ * Fireworks exposes an OpenAI-compatible Chat Completions API
2559
+ * (https://api.fireworks.ai/inference/v1/chat/completions, bearer-token
2560
+ * auth). Modeled on invokeBaidu: request side reuses the shared
2561
+ * openrouter-utils converters, response is converted to Anthropic shape
2562
+ * locally so the orchestrator branch is a plain passthrough.
2563
+ *
2564
+ * Model ids are long-form serverless paths
2565
+ * (accounts/fireworks/models/<slug>, accounts/fireworks/routers/<slug>).
2566
+ * The modelMap below covers bare Anthropic names; tier-selected Fireworks
2567
+ * ids (e.g. TIER_COMPLEX=fireworks:accounts/fireworks/models/glm-5p2)
2568
+ * reach the wire unchanged.
2569
+ *
2570
+ * NOTE (E2E-unverified as of this addition, same caveat Baidu shipped
2571
+ * with): sampling quirks, reasoning_content emission on reasoning models,
2572
+ * finish_reason shape on tool calls, and 429 retryability are best-effort
2573
+ * from public docs, not yet probed against a live key. Revisit once real
2574
+ * traffic surfaces 400s — see Moonshot's kimi-k* precedent for the shape
2575
+ * such fixes take.
2576
+ */
2577
+ async function invokeFireworks(body, _incomingHeaders = {}) {
2578
+ if (!config.fireworks?.apiKey) {
2579
+ throw new Error("Fireworks API key is not configured. Set FIREWORKS_API_KEY in your .env file.");
2580
+ }
2581
+
2582
+ const {
2583
+ convertAnthropicToolsToOpenRouter,
2584
+ convertAnthropicMessagesToOpenRouter
2585
+ } = require("./openrouter-utils");
2586
+
2587
+ const endpoint = config.fireworks.endpoint || "https://api.fireworks.ai/inference/v1/chat/completions";
2588
+
2589
+ // Model mapping: Anthropic names → Fireworks serverless ids.
2590
+ const modelMap = {
2591
+ "claude-sonnet-4-5-20250929": "accounts/fireworks/models/kimi-k2-instruct-0905",
2592
+ "claude-sonnet-4-5": "accounts/fireworks/models/kimi-k2-instruct-0905",
2593
+ "claude-sonnet-4.5": "accounts/fireworks/models/kimi-k2-instruct-0905",
2594
+ "claude-3-5-sonnet": "accounts/fireworks/models/kimi-k2-instruct-0905",
2595
+ "claude-opus-4-5": "accounts/fireworks/models/glm-5p2",
2596
+ "claude-opus-4.5": "accounts/fireworks/models/glm-5p2",
2597
+ "claude-3-opus": "accounts/fireworks/models/glm-5p2",
2598
+ "claude-haiku-4-5-20251001": "accounts/fireworks/models/llama-3.1-8b-instruct",
2599
+ "claude-haiku-4-5": "accounts/fireworks/models/llama-3.1-8b-instruct",
2600
+ "claude-3-haiku": "accounts/fireworks/models/llama-3.1-8b-instruct",
2601
+ };
2602
+
2603
+ const requestedModel = body._tierModel || body.model || config.fireworks.model;
2604
+
2605
+ // Honor tier-selected Fireworks ids instead of silently swapping in the
2606
+ // .env default model. Accepts full serverless paths as well as well-known
2607
+ // open-weight family slugs hosted on Fireworks.
2608
+ const FIREWORKS_ID_RE = /^(accounts\/|kimi-|glm-|deepseek-|qwen|llama-|mistral-|mixtral-|phi-|gemma-|grok-|solar-|yi-|fireworks-)/i;
2609
+ const mappedModel = modelMap[requestedModel]
2610
+ || (FIREWORKS_ID_RE.test(requestedModel || "") ? requestedModel : null)
2611
+ || config.fireworks.model
2612
+ || "accounts/fireworks/models/kimi-k2-instruct-0905";
2613
+
2614
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
2615
+
2616
+ // Fireworks supports the system role natively.
2617
+ if (body.system) {
2618
+ const systemContent = Array.isArray(body.system)
2619
+ ? body.system.map(s => s.text || s).join("\n")
2620
+ : body.system;
2621
+ messages.unshift({ role: "system", content: systemContent });
2622
+ }
2623
+
2624
+ const { resolveThinkingParam } = require("./provider-capabilities");
2625
+ const fireworksBody = {
2626
+ model: mappedModel,
2627
+ messages,
2628
+ max_tokens: body.max_tokens || 16384,
2629
+ temperature: body.temperature ?? 0.7,
2630
+ top_p: body.top_p ?? 1.0,
2631
+ // Reasoning models (R1/GLM) share the answer budget with thinking output;
2632
+ // the buffered path lifts reasoning_content into thinking blocks.
2633
+ thinking: resolveThinkingParam(body),
2634
+ // Streaming honored once "fireworks" joins DEFAULT_OPENAI_SSE_PROVIDERS
2635
+ // (sse-transformer.js). Buffered requests use the Anthropic conversion
2636
+ // path below regardless.
2637
+ stream: body.stream ?? false,
2638
+ };
2639
+
2640
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
2641
+ fireworksBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2642
+ fireworksBody.tool_choice = "auto";
2643
+ fireworksBody.parallel_tool_calls = false;
2644
+ }
2645
+
2646
+ const headers = {
2647
+ "Content-Type": "application/json",
2648
+ "Authorization": `Bearer ${config.fireworks.apiKey}`,
2649
+ };
2650
+
2651
+ logger.debug({
2652
+ endpoint,
2653
+ model: fireworksBody.model,
2654
+ originalModel: requestedModel,
2655
+ messageCount: fireworksBody.messages?.length || 0,
2656
+ hasTools: !!fireworksBody.tools,
2657
+ toolCount: fireworksBody.tools?.length || 0,
2658
+ }, "=== Fireworks REQUEST ===");
2659
+
2660
+ // No retryableStatusesOverride: Fireworks serverless 429s are standard
2661
+ // per-minute rate limits (unlike Moonshot's persistent org quotas), so the
2662
+ // default retry-with-backoff applies. A 429 that survives retries throws
2663
+ // below with status set so tier-fallback climbs instead of hanging.
2664
+ const response = await performJsonRequest(endpoint, {
2665
+ headers,
2666
+ body: fireworksBody,
2667
+ }, "Fireworks");
2668
+
2669
+ if (!response.ok && response.status === 429) {
2670
+ const err = new Error(`Fireworks rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
2671
+ err.status = 429;
2672
+ throw err;
2673
+ }
2674
+
2675
+ // Streaming request: hand the raw stream to the orchestrator's stream
2676
+ // branch. The Anthropic conversion below is buffered-only.
2677
+ if (response?.stream) {
2678
+ return response;
2679
+ }
2680
+
2681
+ if (response?.ok && response?.json) {
2682
+ const anthropicJson = convertOpenAIToAnthropic(response.json);
2683
+ return {
2684
+ ok: response.ok,
2685
+ status: response.status,
2686
+ json: anthropicJson,
2687
+ text: JSON.stringify(anthropicJson),
2688
+ contentType: "application/json",
2689
+ headers: response.headers,
2690
+ };
2691
+ }
2692
+
2693
+ return response;
2694
+ }
2695
+
2433
2696
  /**
2434
2697
  * Convert OpenAI response to Anthropic format
2435
2698
  */
@@ -2457,12 +2720,34 @@ function convertOpenAIToAnthropic(response) {
2457
2720
  const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
2458
2721
 
2459
2722
  // Extract text content and reasoning from thinking models
2460
- const textContent = typeof message.content === 'string' ? message.content : '';
2723
+ let textContent = typeof message.content === 'string' ? message.content : '';
2461
2724
  const reasoningContent = typeof message.reasoning_content === 'string' ? message.reasoning_content : '';
2462
2725
 
2463
- // Emit reasoning_content as a proper thinking block (not discarded)
2464
- if (reasoningContent) {
2465
- content.push({ type: "thinking", thinking: reasoningContent });
2726
+ // GLM-family models leak reasoning into `content` in two shapes:
2727
+ // 1. matched <think>...</think> pairs;
2728
+ // 2. an ORPHAN closing </think> — GLM's chat template opens <think>
2729
+ // inside the prompt, so the completion starts mid-reasoning and only
2730
+ // emits the closer (observed live 2026-08-23, baidu/glm-5.2:
2731
+ // "Hi. What need?</think>Hi. What need?" reached the client verbatim).
2732
+ let leakedThinking = '';
2733
+ if (textContent.includes('</think>')) {
2734
+ if (textContent.includes('<think>')) {
2735
+ const thoughts = [];
2736
+ textContent = textContent
2737
+ .replace(/<think>([\s\S]*?)<\/think>/g, (_, t) => { thoughts.push(t.trim()); return ''; })
2738
+ .trim();
2739
+ leakedThinking = thoughts.filter(Boolean).join('\n\n');
2740
+ } else {
2741
+ const idx = textContent.indexOf('</think>');
2742
+ leakedThinking = textContent.slice(0, idx).trim();
2743
+ textContent = textContent.slice(idx + '</think>'.length).trim();
2744
+ }
2745
+ }
2746
+
2747
+ // Emit reasoning (side-channel and/or leaked) as a proper thinking block
2748
+ const combinedThinking = [reasoningContent, leakedThinking].filter(Boolean).join('\n\n');
2749
+ if (combinedThinking) {
2750
+ content.push({ type: "thinking", thinking: combinedThinking });
2466
2751
  }
2467
2752
 
2468
2753
  if (textContent) {
@@ -2558,7 +2843,7 @@ function sanitizeSchemaForGemini(schema) {
2558
2843
  * (generativelanguage.googleapis.com), not Google Cloud Vertex AI.
2559
2844
  * Converts Anthropic format to Gemini format and back.
2560
2845
  */
2561
- async function invokeVertex(body, incomingHeaders = {}) {
2846
+ async function invokeVertex(body, _incomingHeaders = {}) {
2562
2847
  const apiKey = config.vertex?.apiKey;
2563
2848
 
2564
2849
  if (!apiKey) {
@@ -2803,7 +3088,7 @@ function convertGeminiToAnthropic(response, requestedModel) {
2803
3088
  };
2804
3089
  }
2805
3090
 
2806
- async function invokeCodex(body, incomingHeaders = {}) {
3091
+ async function invokeCodex(body, _incomingHeaders = {}) {
2807
3092
  const { getCodexProcess } = require("./codex-process");
2808
3093
  const { convertAnthropicToCodexPrompt, convertCodexResponseToAnthropic } = require("./codex-utils");
2809
3094
 
@@ -2927,7 +3212,6 @@ const LYNKR_BADGE_PREFIX_RE = /^\*\[Lynkr\][^*\n]*\*\s*/;
2927
3212
  function stripLynkrBadges(messages) {
2928
3213
  if (!Array.isArray(messages)) return messages;
2929
3214
  let mutated = false;
2930
- let badgeCount = 0;
2931
3215
  const out = messages.map((msg) => {
2932
3216
  if (msg?.role !== 'assistant') return msg;
2933
3217
 
@@ -2938,7 +3222,6 @@ function stripLynkrBadges(messages) {
2938
3222
  if (!LYNKR_BADGE_PREFIX_RE.test(msg.content)) return msg;
2939
3223
  const stripped = msg.content.replace(LYNKR_BADGE_PREFIX_RE, '');
2940
3224
  mutated = true;
2941
- badgeCount++;
2942
3225
  // Badge-only content must not become an empty string — Anthropic
2943
3226
  // rejects empty assistant content (this is the interrupted-response
2944
3227
  // continuation case, where the partial text WAS just the badge).
@@ -2957,7 +3240,6 @@ function stripLynkrBadges(messages) {
2957
3240
  for (const b of msg.content) {
2958
3241
  if (b?.type === 'text' && typeof b.text === 'string' && LYNKR_BADGE_PREFIX_RE.test(b.text)) {
2959
3242
  changed = true;
2960
- badgeCount++;
2961
3243
  const strippedText = b.text.replace(LYNKR_BADGE_PREFIX_RE, '');
2962
3244
  if (strippedText.trim()) rebuilt.push({ ...b, text: strippedText });
2963
3245
  // badge-only block → drop
@@ -3003,6 +3285,7 @@ const PROVIDER_INVOKERS = {
3003
3285
  moonshot: invokeMoonshot,
3004
3286
  codex: invokeCodex,
3005
3287
  baidu: invokeBaidu,
3288
+ fireworks: invokeFireworks,
3006
3289
  };
3007
3290
 
3008
3291
  function invokeProvider(provider, body, incomingHeaders) {
@@ -3264,6 +3547,7 @@ async function invokeModel(body, options = {}) {
3264
3547
  base_tier: routingResult.base_tier ?? null,
3265
3548
  escalation_source: routingResult.escalation_source ?? null,
3266
3549
  propensity: routingResult.propensity ?? null,
3550
+ context: routingResult._banditContext ?? null,
3267
3551
  candidates: routingResult.candidates ?? null,
3268
3552
  pinned: routingResult.pinned ? 1 : 0,
3269
3553
  switch_reason: routingResult.switch_reason ?? null,
@@ -3498,6 +3782,7 @@ async function invokeModel(body, options = {}) {
3498
3782
  base_tier: routingResult.base_tier ?? null,
3499
3783
  escalation_source: routingResult.escalation_source ?? null,
3500
3784
  propensity: routingResult.propensity ?? null,
3785
+ context: routingResult._banditContext ?? null,
3501
3786
  candidates: routingResult.candidates ?? null,
3502
3787
  pinned: routingResult.pinned ? 1 : 0,
3503
3788
  switch_reason: routingResult.switch_reason ?? null,
@@ -3610,6 +3895,7 @@ async function invokeModel(body, options = {}) {
3610
3895
  base_tier: routingResult.base_tier ?? null,
3611
3896
  escalation_source: routingResult.escalation_source ?? null,
3612
3897
  propensity: routingResult.propensity ?? null,
3898
+ context: routingResult._banditContext ?? null,
3613
3899
  candidates: routingResult.candidates ?? null,
3614
3900
  pinned: routingResult.pinned ? 1 : 0,
3615
3901
  switch_reason: routingResult.switch_reason ?? null,
@@ -3706,6 +3992,7 @@ async function invokeModel(body, options = {}) {
3706
3992
  base_tier: routingResult.base_tier ?? null,
3707
3993
  escalation_source: routingResult.escalation_source ?? null,
3708
3994
  propensity: routingResult.propensity ?? null,
3995
+ context: routingResult._banditContext ?? null,
3709
3996
  candidates: routingResult.candidates ?? null,
3710
3997
  pinned: routingResult.pinned ? 1 : 0,
3711
3998
  switch_reason: routingResult.switch_reason ?? null,
@@ -3766,6 +4053,7 @@ async function invokeModel(body, options = {}) {
3766
4053
  base_tier: routingResult.base_tier ?? null,
3767
4054
  escalation_source: routingResult.escalation_source ?? null,
3768
4055
  propensity: routingResult.propensity ?? null,
4056
+ context: routingResult._banditContext ?? null,
3769
4057
  candidates: routingResult.candidates ?? null,
3770
4058
  pinned: routingResult.pinned ? 1 : 0,
3771
4059
  switch_reason: routingResult.switch_reason ?? null,
@@ -3860,11 +4148,14 @@ function destroyHttpAgents() {
3860
4148
 
3861
4149
  module.exports = {
3862
4150
  invokeModel,
4151
+ computeCostUsd,
4152
+ convertOpenAIToAnthropic,
3863
4153
  invokeAzureAnthropic,
3864
4154
  invokeZai,
3865
4155
  invokeOllama,
3866
4156
  invokeMoonshot,
3867
4157
  invokeBaidu,
4158
+ invokeFireworks,
3868
4159
  invokeAtlas,
3869
4160
  PROVIDER_INVOKERS,
3870
4161
  stripLynkrBadges,