lynkr 9.14.12 → 9.14.14

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.
@@ -10,6 +10,30 @@ const { createBulkhead } = require("./resilience");
10
10
  const logger = require("../logger");
11
11
  const { STANDARD_TOOLS, STANDARD_TOOL_NAMES } = require("./standard-tools");
12
12
  const { convertAnthropicToolsToOpenRouter } = require("./openrouter-utils");
13
+
14
+ /**
15
+ * Capability-boundary guard (issue #114): a caller that sets
16
+ * tool_choice "none" grants no tools. Substituting STANDARD_TOOLS widens
17
+ * the grant to shell execution with zero caller-visible signal, so every
18
+ * injection site below must consult this first. Matches the convention
19
+ * already used by the OpenRouter ingress path.
20
+ */
21
+ function toolsDeclined(body) {
22
+ const tc = body?.tool_choice;
23
+ if (typeof tc === 'string') return tc.toLowerCase() === 'none';
24
+ if (tc && typeof tc === 'object') return tc.type === 'none';
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Carry the caller's tool_choice through where the target expresses it.
30
+ * Only "none" is forwarded verbatim (the safety-critical value — the model
31
+ * must not be offered tools the caller forbade); everything else keeps the
32
+ * historical "auto" default so exotic shapes never 400 strict providers.
33
+ */
34
+ function forwardToolChoice(body) {
35
+ return toolsDeclined(body) ? 'none' : 'auto';
36
+ }
13
37
  const {
14
38
  detectModelFamily
15
39
  } = require("./bedrock-utils");
@@ -190,8 +214,9 @@ async function invokeDatabricks(body, _incomingHeaders = {}) {
190
214
  // Create a copy of body to avoid mutating the original
191
215
  const databricksBody = { ...body };
192
216
 
193
- // Inject standard tools if client didn't send any (passthrough mode)
194
- if (!Array.isArray(databricksBody.tools) || databricksBody.tools.length === 0) {
217
+ // Inject standard tools if client didn't send any (passthrough mode).
218
+ // Never when the caller declined tools (issue #114).
219
+ if (!toolsDeclined(body) && (!Array.isArray(databricksBody.tools) || databricksBody.tools.length === 0)) {
195
220
  databricksBody.tools = STANDARD_TOOLS;
196
221
  logger.debug({
197
222
  injectedToolCount: STANDARD_TOOLS.length,
@@ -535,7 +560,7 @@ async function invokeOllama(body, _incomingHeaders = {}) {
535
560
 
536
561
  if (!supportsTools) {
537
562
  toolsToSend = null;
538
- } else if (injectToolsOllama && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
563
+ } else if (!toolsDeclined(body) && injectToolsOllama && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
539
564
  toolsToSend = STANDARD_TOOLS;
540
565
  toolsInjected = true;
541
566
  }
@@ -827,7 +852,7 @@ async function invokeOpenRouter(body, _incomingHeaders = {}) {
827
852
  let toolsToSend = body.tools;
828
853
  let toolsInjected = false;
829
854
 
830
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
855
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
831
856
  toolsToSend = STANDARD_TOOLS;
832
857
  toolsInjected = true;
833
858
  logger.debug({
@@ -844,6 +869,10 @@ async function invokeOpenRouter(body, _incomingHeaders = {}) {
844
869
  toolNames: toolsToSend.map(t => t.name || t.function?.name),
845
870
  toolsInjected
846
871
  }, "Sending tools to OpenRouter");
872
+ } else if (toolsDeclined(body)) {
873
+ // Explicit none with nothing to offer: state it so the upstream never
874
+ // defaults into tool use (issue #114).
875
+ openRouterBody.tool_choice = "none";
847
876
  }
848
877
 
849
878
  // Same pre-return 429 check as invokeMoonshot/invokeBaidu — see the comment
@@ -904,7 +933,7 @@ async function invokeEdenAI(body, _incomingHeaders = {}) {
904
933
  let toolsToSend = body.tools;
905
934
  let toolsInjected = false;
906
935
 
907
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
936
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
908
937
  toolsToSend = STANDARD_TOOLS;
909
938
  toolsInjected = true;
910
939
  logger.debug({
@@ -921,6 +950,10 @@ async function invokeEdenAI(body, _incomingHeaders = {}) {
921
950
  toolNames: toolsToSend.map(t => t.name || t.function?.name),
922
951
  toolsInjected
923
952
  }, "Sending tools to Eden AI");
953
+ } else if (toolsDeclined(body)) {
954
+ // Explicit none with nothing to offer: state it so the upstream never
955
+ // defaults into tool use (issue #114).
956
+ edenAIBody.tool_choice = "none";
924
957
  }
925
958
 
926
959
  return performJsonRequest(endpoint, { headers, body: edenAIBody }, "EdenAI");
@@ -1009,7 +1042,7 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
1009
1042
  let toolsToSend = body.tools;
1010
1043
  let toolsInjected = false;
1011
1044
 
1012
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1045
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1013
1046
  toolsToSend = STANDARD_TOOLS;
1014
1047
  toolsInjected = true;
1015
1048
  logger.debug({
@@ -1022,7 +1055,7 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
1022
1055
  if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
1023
1056
  azureBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
1024
1057
  azureBody.parallel_tool_calls = true;
1025
- azureBody.tool_choice = "auto"; // Explicitly enable tool use (helps GPT models understand they should use tools)
1058
+ azureBody.tool_choice = forwardToolChoice(body); // Explicitly enable tool use (helps GPT models understand they should use tools)
1026
1059
  logger.debug({
1027
1060
  toolCount: toolsToSend.length,
1028
1061
  toolNames: toolsToSend.map(t => t.name || t.function?.name),
@@ -1457,7 +1490,7 @@ async function invokeOpenAI(body, _incomingHeaders = {}) {
1457
1490
  let toolsToSend = body.tools;
1458
1491
  let toolsInjected = false;
1459
1492
 
1460
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1493
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1461
1494
  toolsToSend = STANDARD_TOOLS;
1462
1495
  toolsInjected = true;
1463
1496
  logger.debug({
@@ -1470,7 +1503,7 @@ async function invokeOpenAI(body, _incomingHeaders = {}) {
1470
1503
  if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
1471
1504
  openAIBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
1472
1505
  openAIBody.parallel_tool_calls = false; // Disable parallel tool calls - GPT often makes duplicate calls
1473
- openAIBody.tool_choice = "auto"; // Let the model decide when to use tools
1506
+ openAIBody.tool_choice = forwardToolChoice(body); // Let the model decide when to use tools
1474
1507
  logger.debug({
1475
1508
  toolCount: toolsToSend.length,
1476
1509
  toolNames: toolsToSend.map(t => t.name || t.function?.name),
@@ -1538,7 +1571,7 @@ async function invokeAtlas(body) {
1538
1571
 
1539
1572
  let toolsToSend = body.tools;
1540
1573
  let toolsInjected = false;
1541
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1574
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1542
1575
  toolsToSend = STANDARD_TOOLS;
1543
1576
  toolsInjected = true;
1544
1577
  }
@@ -1546,7 +1579,7 @@ async function invokeAtlas(body) {
1546
1579
  if (toolsToSend.length > 0) {
1547
1580
  atlasBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
1548
1581
  atlasBody.parallel_tool_calls = false;
1549
- atlasBody.tool_choice = "auto";
1582
+ atlasBody.tool_choice = forwardToolChoice(body);
1550
1583
  }
1551
1584
 
1552
1585
  logger.debug({
@@ -1681,7 +1714,7 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1681
1714
  let toolsInjected = false;
1682
1715
 
1683
1716
  const injectToolsLlamacpp = process.env.INJECT_TOOLS_LLAMACPP !== "false";
1684
- if (injectToolsLlamacpp && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1717
+ if (!toolsDeclined(body) && injectToolsLlamacpp && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1685
1718
  toolsToSend = STANDARD_TOOLS;
1686
1719
  toolsInjected = true;
1687
1720
  logger.debug({
@@ -1695,7 +1728,7 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1695
1728
 
1696
1729
  if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
1697
1730
  llamacppBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
1698
- llamacppBody.tool_choice = "auto";
1731
+ llamacppBody.tool_choice = forwardToolChoice(body);
1699
1732
  logger.debug({
1700
1733
  toolCount: toolsToSend.length,
1701
1734
  toolNames: toolsToSend.map(t => t.name || t.function?.name),
@@ -1779,7 +1812,7 @@ async function invokeLMStudio(body, _incomingHeaders = {}) {
1779
1812
  let toolsToSend = body.tools;
1780
1813
  let toolsInjected = false;
1781
1814
 
1782
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1815
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1783
1816
  toolsToSend = STANDARD_TOOLS;
1784
1817
  toolsInjected = true;
1785
1818
  logger.debug({
@@ -1791,7 +1824,7 @@ async function invokeLMStudio(body, _incomingHeaders = {}) {
1791
1824
 
1792
1825
  if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
1793
1826
  lmstudioBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
1794
- lmstudioBody.tool_choice = "auto";
1827
+ lmstudioBody.tool_choice = forwardToolChoice(body);
1795
1828
  logger.debug({
1796
1829
  toolCount: toolsToSend.length,
1797
1830
  toolNames: toolsToSend.map(t => t.name || t.function?.name),
@@ -1938,7 +1971,7 @@ async function invokeBedrock(body, _incomingHeaders = {}) {
1938
1971
  // Inject standard tools if needed
1939
1972
  let toolsToSend = body.tools;
1940
1973
 
1941
- if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
1974
+ if (!toolsDeclined(body) && (!Array.isArray(toolsToSend) || toolsToSend.length === 0)) {
1942
1975
  toolsToSend = STANDARD_TOOLS;
1943
1976
  logger.debug({
1944
1977
  injectedToolCount: STANDARD_TOOLS.length,
@@ -2199,7 +2232,7 @@ async function invokeZai(body, _incomingHeaders = {}) {
2199
2232
  zaiBody.tools = tools;
2200
2233
  // Use "auto" to let the model decide when to use tools
2201
2234
  // "required" was forcing tools even for simple greetings
2202
- zaiBody.tool_choice = "auto";
2235
+ zaiBody.tool_choice = forwardToolChoice(body);
2203
2236
  zaiBody.parallel_tool_calls = false; // Disabled: duplicate-call risk
2204
2237
  }
2205
2238
 
@@ -2230,8 +2263,9 @@ async function invokeZai(body, _incomingHeaders = {}) {
2230
2263
  // that's a different bug — add zai to DEFAULT_OPENAI_SSE_PROVIDERS then,
2231
2264
  // don't reintroduce this override.
2232
2265
 
2233
- // Inject standard tools if client didn't send any (passthrough mode)
2234
- if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
2266
+ // Inject standard tools if client didn't send any (passthrough mode).
2267
+ // Never when the caller declined tools (issue #114).
2268
+ if (!toolsDeclined(body) && (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0)) {
2235
2269
  zaiBody.tools = STANDARD_TOOLS;
2236
2270
  logger.debug({
2237
2271
  injectedToolCount: STANDARD_TOOLS.length,
@@ -2404,7 +2438,7 @@ async function invokeMoonshot(body, _incomingHeaders = {}) {
2404
2438
 
2405
2439
  if (Array.isArray(body.tools) && body.tools.length > 0) {
2406
2440
  moonshotBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2407
- moonshotBody.tool_choice = "auto";
2441
+ moonshotBody.tool_choice = forwardToolChoice(body);
2408
2442
  moonshotBody.parallel_tool_calls = false;
2409
2443
  }
2410
2444
 
@@ -2558,7 +2592,7 @@ async function invokeBaidu(body, _incomingHeaders = {}) {
2558
2592
 
2559
2593
  if (Array.isArray(body.tools) && body.tools.length > 0) {
2560
2594
  baiduBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2561
- baiduBody.tool_choice = "auto";
2595
+ baiduBody.tool_choice = forwardToolChoice(body);
2562
2596
  baiduBody.parallel_tool_calls = false;
2563
2597
  }
2564
2598
 
@@ -2696,7 +2730,7 @@ async function invokeFireworks(body, _incomingHeaders = {}) {
2696
2730
 
2697
2731
  if (Array.isArray(body.tools) && body.tools.length > 0) {
2698
2732
  fireworksBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2699
- fireworksBody.tool_choice = "auto";
2733
+ fireworksBody.tool_choice = forwardToolChoice(body);
2700
2734
  fireworksBody.parallel_tool_calls = false;
2701
2735
  }
2702
2736
 
@@ -2956,7 +2990,7 @@ async function invokeOrcaRouter(body, _incomingHeaders = {}) {
2956
2990
 
2957
2991
  if (Array.isArray(body.tools) && body.tools.length > 0) {
2958
2992
  orcaBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2959
- orcaBody.tool_choice = "auto";
2993
+ orcaBody.tool_choice = forwardToolChoice(body);
2960
2994
  orcaBody.parallel_tool_calls = false;
2961
2995
  }
2962
2996
 
@@ -3767,6 +3801,7 @@ async function invokeModel(body, options = {}) {
3767
3801
  candidates: routingResult.candidates ?? null,
3768
3802
  pinned: routingResult.pinned ? 1 : 0,
3769
3803
  switch_reason: routingResult.switch_reason ?? null,
3804
+ ...telemetry.jevFields(routingResult),
3770
3805
  cache_decision: routingResult._cacheDecision ?? null,
3771
3806
  cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
3772
3807
  cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
@@ -4002,6 +4037,7 @@ async function invokeModel(body, options = {}) {
4002
4037
  candidates: routingResult.candidates ?? null,
4003
4038
  pinned: routingResult.pinned ? 1 : 0,
4004
4039
  switch_reason: routingResult.switch_reason ?? null,
4040
+ ...telemetry.jevFields(routingResult),
4005
4041
  cache_decision: routingResult._cacheDecision ?? null,
4006
4042
  });
4007
4043
 
@@ -4115,6 +4151,7 @@ async function invokeModel(body, options = {}) {
4115
4151
  candidates: routingResult.candidates ?? null,
4116
4152
  pinned: routingResult.pinned ? 1 : 0,
4117
4153
  switch_reason: routingResult.switch_reason ?? null,
4154
+ ...telemetry.jevFields(routingResult),
4118
4155
  cache_decision: routingResult._cacheDecision ?? null,
4119
4156
  });
4120
4157
 
@@ -4212,6 +4249,7 @@ async function invokeModel(body, options = {}) {
4212
4249
  candidates: routingResult.candidates ?? null,
4213
4250
  pinned: routingResult.pinned ? 1 : 0,
4214
4251
  switch_reason: routingResult.switch_reason ?? null,
4252
+ ...telemetry.jevFields(routingResult),
4215
4253
  cache_decision: routingResult._cacheDecision ?? null,
4216
4254
  cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
4217
4255
  cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
@@ -4273,6 +4311,7 @@ async function invokeModel(body, options = {}) {
4273
4311
  candidates: routingResult.candidates ?? null,
4274
4312
  pinned: routingResult.pinned ? 1 : 0,
4275
4313
  switch_reason: routingResult.switch_reason ?? null,
4314
+ ...telemetry.jevFields(routingResult),
4276
4315
  cache_decision: routingResult._cacheDecision ?? null,
4277
4316
  });
4278
4317
 
@@ -4364,6 +4403,8 @@ function destroyHttpAgents() {
4364
4403
 
4365
4404
  module.exports = {
4366
4405
  invokeModel,
4406
+ toolsDeclined,
4407
+ forwardToolChoice,
4367
4408
  computeCostUsd,
4368
4409
  convertOpenAIToAnthropic,
4369
4410
  invokeAzureAnthropic,
@@ -69,12 +69,47 @@ function convertOpenAIToAnthropic(openaiRequest) {
69
69
  } else if (part.type === "image_url") {
70
70
  const url = part.image_url?.url || "";
71
71
  if (url.startsWith("data:")) {
72
- const match = url.match(/^data:(image\/[^;]+);base64,(.+)$/);
72
+ // Accept base64 and base64url alphabets; anything else falls
73
+ // through to url-shape (provider validates downstream).
74
+ const match = url.match(/^data:([^;]+);base64,([A-Za-z0-9+/=_-]+)$/);
73
75
  if (match) {
74
- return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } };
76
+ // Image payloads become Anthropic image blocks; anything
77
+ // else (e.g. application/pdf) becomes a document block so
78
+ // the bytes survive the translation (issue #115).
79
+ if (match[1].startsWith("image/")) {
80
+ return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } };
81
+ }
82
+ return { type: "document", source: { type: "base64", media_type: match[1], data: match[2] } };
75
83
  }
76
84
  }
77
85
  return { type: "image", source: { type: "url", url } };
86
+ } else if (part.type === "file" && part.file) {
87
+ // OpenAI file part → Anthropic document block. file_data carries
88
+ // a data URL whose media type + base64 payload map directly
89
+ // (issue #115: previously forwarded unchanged → upstream 400).
90
+ const dataUrl = part.file.file_data || "";
91
+ const match = typeof dataUrl === "string" && dataUrl.match(/^data:([^;]+);base64,([A-Za-z0-9+/=_-]+)$/);
92
+ if (match) {
93
+ return { type: "document", source: { type: "base64", media_type: match[1], data: match[2] } };
94
+ }
95
+ const err = new Error(
96
+ `Cannot translate OpenAI file part "${part.file.filename || "unnamed"}" to Anthropic format: ` +
97
+ `file_data must be a base64 data: URL.`
98
+ );
99
+ err.statusCode = 400;
100
+ err.code = "unsupported_content_block";
101
+ err.isOperational = true;
102
+ throw err;
103
+ } else if (part.type === "input_audio") {
104
+ // No Anthropic equivalent — fail loudly naming the block rather
105
+ // than forwarding bytes the upstream rejects opaquely (issue #115).
106
+ const err = new Error(
107
+ "Cannot translate OpenAI input_audio content to Anthropic format: audio input is not supported."
108
+ );
109
+ err.statusCode = 400;
110
+ err.code = "unsupported_content_block";
111
+ err.isOperational = true;
112
+ throw err;
78
113
  } else if (part.type === "document" || part.type === "image") {
79
114
  return part;
80
115
  }
@@ -208,6 +208,26 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
208
208
  }
209
209
  let message;
210
210
  if (imageUrls.length === 0) {
211
+ // Fail loudly instead of emptying: an attachment-only message
212
+ // whose blocks have no OpenAI representation (document, file,
213
+ // video, audio) must error naming the block, not become "" and
214
+ // earn a confident 2xx answer about an attachment never received
215
+ // (issue #115). Thinking-only turns are exempt — thinking models
216
+ // legitimately send those.
217
+ const unconvertible = content
218
+ .filter((b) => b && typeof b === 'object'
219
+ && ['document', 'file', 'video', 'audio', 'input_audio'].includes(b.type))
220
+ .map((b) => b.type);
221
+ if (unconvertible.length > 0 && !textContent) {
222
+ const err = new Error(
223
+ `Cannot convert ${[...new Set(unconvertible)].join('/')} content block(s) to OpenAI format: ` +
224
+ `no equivalent representation exists.`
225
+ );
226
+ err.statusCode = 400;
227
+ err.code = 'unsupported_content_block';
228
+ err.isOperational = true;
229
+ throw err;
230
+ }
211
231
  message = { role: msg.role, content: textContent || '' };
212
232
  } else {
213
233
  const parts = [];
@@ -1,5 +1,5 @@
1
1
  const config = require("../config");
2
- const { invokeModel } = require("../clients/databricks");
2
+ const { invokeModel, toolsDeclined } = require("../clients/databricks");
3
3
  const { appendTurnToSession } = require("../sessions/record");
4
4
  const { upsertSession } = require("../sessions/store");
5
5
  const policy = require("../policy");
@@ -1113,15 +1113,50 @@ function sanitizePayload(payload) {
1113
1113
  if (tools) clean.tools = tools;
1114
1114
  else delete clean.tools;
1115
1115
  } else if (providerType === "azure-anthropic") {
1116
- const tools = sanitiseAzureTools(clean.tools);
1117
- clean.tools =
1118
- tools && tools.length > 0
1119
- ? tools
1120
- : DEFAULT_AZURE_TOOLS.map((tool) => ({
1121
- name: tool.name,
1122
- input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
1123
- }));
1124
- delete clean.tool_choice;
1116
+ // toolsDeclined is imported top-level from ../clients/databricks
1117
+ // alongside invokeModel — same module instance, no inline require
1118
+ // needed (a guarded re-require would imply a failure mode that cannot
1119
+ // occur without breaking everything else first).
1120
+ // A caller that declined tools gets none — never substitute defaults
1121
+ // (issue #114: substitution here turned tool_choice "none" into an
1122
+ // unrelated tool set with the choice deleted).
1123
+ if (toolsDeclined(clean)) {
1124
+ delete clean.tools;
1125
+ clean.tool_choice = "none";
1126
+ } else {
1127
+ const tools = sanitiseAzureTools(clean.tools);
1128
+ if (Array.isArray(clean.tools) && clean.tools.length > 0 && (!tools || tools.length === 0)) {
1129
+ // The caller declared tools and every one was rejected: substituting
1130
+ // unrelated defaults would silently change the contract, so fail
1131
+ // loudly naming the tools instead (issue #114).
1132
+ const names = clean.tools
1133
+ .map((t) => t?.name || t?.function?.name || "unnamed")
1134
+ .filter(Boolean);
1135
+ const err = new Error(
1136
+ `azure-anthropic supports only built-in tools (WebSearch, WebFetch, Bash family); ` +
1137
+ `rejected tool(s): ${names.join(", ")}. Remove them or route to a provider that serves them.`
1138
+ );
1139
+ err.statusCode = 400;
1140
+ err.code = "unsupported_tools";
1141
+ err.isOperational = true;
1142
+ throw err;
1143
+ }
1144
+ const declaredNames = Array.isArray(clean.tools)
1145
+ ? clean.tools.map((t) => t?.name || t?.function?.name).filter(Boolean)
1146
+ : [];
1147
+ clean.tools =
1148
+ tools && tools.length > 0
1149
+ ? tools
1150
+ : DEFAULT_AZURE_TOOLS.map((tool) => ({
1151
+ name: tool.name,
1152
+ input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
1153
+ }));
1154
+ const dropped = declaredNames.filter((n) => !(tools || []).some((k) => k.name === n));
1155
+ if (dropped.length > 0) {
1156
+ logger.debug({ dropped }, '[azure-anthropic] Partial tool substitution: unlisted tools dropped');
1157
+ }
1158
+ delete clean.tool_choice;
1159
+ }
1125
1160
  } else if (providerType === "ollama") {
1126
1161
  // Always pass tools through to Ollama in Anthropic format when they exist.
1127
1162
  // Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if
@@ -3249,6 +3284,8 @@ function trimLoopMessages(messages, max) {
3249
3284
 
3250
3285
  module.exports = {
3251
3286
  processMessage,
3287
+ // Exported for unit testing of sanitizePayload tool handling.
3288
+ sanitizePayload,
3252
3289
  // Exported for unit testing of response-metadata conversion.
3253
3290
  toAnthropicResponse,
3254
3291
  // Exported for unit testing of loop trimming (task-preservation contract).
@@ -73,61 +73,53 @@ function compressToolDescriptions(tools, mode = null) {
73
73
  // Normalize to Anthropic shape: OpenAI-format callers (OpenWorker, aisuite,
74
74
  // any OpenAI SDK) send {type:"function", function:{name, description, parameters}}.
75
75
  // Unwrap once here so the compression logic below can assume input_schema exists.
76
- tools = tools.map(tool =>
77
- tool?.type === "function" && tool.function
78
- ? {
79
- name: tool.function.name,
80
- description: tool.function.description,
81
- input_schema: tool.function.parameters || { type: "object", properties: {} },
82
- }
83
- : tool
84
- );
85
-
86
- return tools.map(tool => {
87
- const input_schema = tool.input_schema || { type: "object", properties: {} };
88
- const compressed = {
89
- name: tool.name,
90
- input_schema: {
91
- type: input_schema.type || "object",
92
- properties: {},
93
- required: input_schema.required || [],
94
- }
76
+ // Every other key on the function object (strict, and any future keywords)
77
+ // passes through untouched — an allowlist here silently drops enforceable
78
+ // constraints (issue #116).
79
+ tools = tools.map(tool => {
80
+ if (tool?.type !== "function" || !tool.function) return tool;
81
+ const { parameters, ...kept } = tool.function;
82
+ return {
83
+ ...kept,
84
+ input_schema: parameters || { type: "object", properties: {} },
95
85
  };
86
+ });
96
87
 
97
- // Add minimal description only if it exists
98
- if (tool.description) {
99
- compressed.description = compressText(tool.description, 50);
88
+ return tools.map(tool => {
89
+ // Copy everything through; compression below targets description TEXT
90
+ // only, never schema shape (issue #116: an allowlist rebuild silently
91
+ // dropped strict/pattern/const/oneOf and misleveled additionalProperties).
92
+ const out = { ...tool };
93
+ if (typeof out.description === 'string' && out.description) {
94
+ out.description = compressText(out.description, 50);
100
95
  }
101
-
102
- // Compress property descriptions
103
- if (input_schema.properties) {
104
- for (const [key, value] of Object.entries(input_schema.properties)) {
105
- compressed.input_schema.properties[key] = {
106
- type: value.type,
107
- };
108
-
109
- // Only include description if it's critical
110
- if (value.description && !isObviousFromName(key)) {
111
- compressed.input_schema.properties[key].description = compressText(value.description, 30);
112
- }
113
-
114
- // Preserve enum, format, and other critical constraints
115
- if (value.enum) compressed.input_schema.properties[key].enum = value.enum;
116
- if (value.format) compressed.input_schema.properties[key].format = value.format;
117
- if (value.items) compressed.input_schema.properties[key].items = value.items;
118
- if (value.additionalProperties !== undefined) {
119
- compressed.input_schema.properties[key].additionalProperties = value.additionalProperties;
120
- }
121
- }
96
+ if (out.input_schema && typeof out.input_schema === 'object') {
97
+ out.input_schema = compressSchemaDescriptions(out.input_schema, null);
122
98
  }
99
+ return out;
100
+ });
101
+ }
123
102
 
124
- // Preserve additionalProperties if set
125
- if (input_schema.additionalProperties !== undefined) {
126
- compressed.input_schema.additionalProperties = input_schema.additionalProperties;
103
+ /**
104
+ * Recursively compress `description` strings in a JSON Schema, preserving
105
+ * every other keyword byte-for-byte in shape. Descriptions obvious from the
106
+ * property name are dropped, the rest truncated — matching the historical
107
+ * minimal-mode behavior without an allowlist that lags the spec.
108
+ */
109
+ function compressSchemaDescriptions(node, key) {
110
+ if (Array.isArray(node)) return node.map((v) => compressSchemaDescriptions(v, key));
111
+ if (node && typeof node === 'object') {
112
+ const out = {};
113
+ for (const [k, v] of Object.entries(node)) {
114
+ if (k === 'description' && typeof v === 'string') {
115
+ if (!isObviousFromName(key)) out[k] = compressText(v, 30);
116
+ } else {
117
+ out[k] = compressSchemaDescriptions(v, k);
118
+ }
127
119
  }
128
-
129
- return compressed;
130
- });
120
+ return out;
121
+ }
122
+ return node;
131
123
  }
132
124
 
133
125
  /**
@@ -59,9 +59,25 @@ function _db() {
59
59
  // Additive migration for cache-aware routing (Phase 1): JSON blob
60
60
  // holding {warmPrefixTokens, provider, model, lastRequestAt, ttlMs},
61
61
  // updated after every upstream response that reports cache usage.
62
+ // NOTE (2026-09-18): superseded by the dedicated session_cache_state
63
+ // table below. The column stays (and old blobs stay readable nowhere —
64
+ // loadCacheState reads the new table), but NOTHING writes it anymore:
65
+ // sharing the pins table let cache-only rows (no tier) be served as
66
+ // routing pins, permanently locking sessions with no escalation path.
62
67
  if (!cols.has("cache_state")) {
63
68
  db.exec("ALTER TABLE session_pins ADD COLUMN cache_state TEXT");
64
69
  }
70
+ db.exec(`
71
+ CREATE TABLE IF NOT EXISTS session_cache_state (
72
+ session_id TEXT PRIMARY KEY,
73
+ warm_prefix_tokens INTEGER NOT NULL,
74
+ provider TEXT NOT NULL,
75
+ model TEXT,
76
+ last_request_at INTEGER NOT NULL,
77
+ ttl_ms INTEGER NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_cache_state_ts ON session_cache_state(last_request_at);
80
+ `);
65
81
  schemaEnsured = true;
66
82
  } catch (err) {
67
83
  degradation.record("feedback", err);
@@ -168,9 +184,10 @@ function save(sessionId, pin) {
168
184
 
169
185
  /**
170
186
  * Persist per-session prompt-cache state (Phase 1, cache-aware routing).
171
- * Piggybacks on the session_pins row; creates a minimal row when the session
172
- * has no pin yet (possible when sticky sessions are disabled but tracking
173
- * is on). Best-effort like everything else in this module.
187
+ * Lives in its OWN table (session_cache_state), deliberately separate from
188
+ * session_pins: the old piggyback design wrote tier-less rows that got
189
+ * served as routing pins, locking sessions with no escalation path.
190
+ * Best-effort like everything else in this module.
174
191
  *
175
192
  * @param {string} sessionId
176
193
  * @param {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}} state
@@ -180,21 +197,25 @@ function saveCacheState(sessionId, state) {
180
197
  const db = _db();
181
198
  if (!db) return;
182
199
  try {
183
- const json = JSON.stringify(state);
184
- const res = _stmt(
200
+ _stmt(
185
201
  db,
186
- "cache_state_update",
187
- "UPDATE session_pins SET cache_state = ? WHERE session_id = ?"
188
- ).run(json, sessionId);
189
- if (res.changes === 0) {
190
- _stmt(
191
- db,
192
- "cache_state_insert",
193
- `INSERT INTO session_pins (session_id, provider, model, ts, cache_state)
194
- VALUES (?, ?, ?, ?, ?)
195
- ON CONFLICT(session_id) DO UPDATE SET cache_state = excluded.cache_state`
196
- ).run(sessionId, state.provider, state.model ?? null, Date.now(), json);
197
- }
202
+ "cache_state_upsert",
203
+ `INSERT INTO session_cache_state (session_id, warm_prefix_tokens, provider, model, last_request_at, ttl_ms)
204
+ VALUES (?, ?, ?, ?, ?, ?)
205
+ ON CONFLICT(session_id) DO UPDATE SET
206
+ warm_prefix_tokens = excluded.warm_prefix_tokens,
207
+ provider = excluded.provider,
208
+ model = excluded.model,
209
+ last_request_at = excluded.last_request_at,
210
+ ttl_ms = excluded.ttl_ms`
211
+ ).run(
212
+ sessionId,
213
+ state.warmPrefixTokens ?? 0,
214
+ state.provider,
215
+ state.model ?? null,
216
+ state.lastRequestAt ?? Date.now(),
217
+ state.ttlMs ?? 5 * 60 * 1000
218
+ );
198
219
  } catch (err) {
199
220
  degradation.record("feedback", err);
200
221
  }
@@ -216,11 +237,16 @@ function loadCacheState(sessionId) {
216
237
  const row = _stmt(
217
238
  db,
218
239
  "cache_state_load",
219
- "SELECT cache_state FROM session_pins WHERE session_id = ?"
240
+ "SELECT warm_prefix_tokens, provider, model, last_request_at, ttl_ms FROM session_cache_state WHERE session_id = ?"
220
241
  ).get(sessionId);
221
- if (!row?.cache_state) return null;
222
- const parsed = JSON.parse(row.cache_state);
223
- return parsed && typeof parsed === "object" ? parsed : null;
242
+ if (!row) return null;
243
+ return {
244
+ warmPrefixTokens: Number(row.warm_prefix_tokens) || 0,
245
+ provider: row.provider,
246
+ model: row.model ?? null,
247
+ lastRequestAt: Number(row.last_request_at) || 0,
248
+ ttlMs: Number(row.ttl_ms) || 5 * 60 * 1000,
249
+ };
224
250
  } catch (err) {
225
251
  degradation.record("feedback", err);
226
252
  return null;