lynkr 9.14.12 → 9.14.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +4 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +64 -23
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -0
- package/src/orchestrator/index.js +47 -10
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +121 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +13 -0
- package/src/routing/passthrough-route.js +286 -0
- package/src/routing/quota-ledger.js +110 -0
- package/src/routing/risk-analyzer.js +17 -1
- package/src/routing/telemetry.js +47 -2
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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).
|
package/src/prompts/system.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
184
|
-
const res = _stmt(
|
|
200
|
+
_stmt(
|
|
185
201
|
db,
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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;
|