lynkr 9.14.11 → 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/README.md +1 -0
- package/bin/cli.js +1 -0
- package/bin/lynkr-connect-orcarouter.js +246 -0
- package/bin/lynkr-init.js +15 -1
- package/config/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +5 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +196 -22
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -0
- package/src/clients/orcarouter-catalog.js +330 -0
- package/src/clients/orcarouter-credentials.js +428 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +63 -2
- package/src/dashboard/api.js +1 -0
- package/src/orchestrator/index.js +49 -10
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +123 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +15 -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
|
|
|
@@ -2893,6 +2927,137 @@ function sanitizeSchemaForGemini(schema) {
|
|
|
2893
2927
|
return sanitized;
|
|
2894
2928
|
}
|
|
2895
2929
|
|
|
2930
|
+
/**
|
|
2931
|
+
* OrcaRouter Provider (OpenAI-compatible gateway)
|
|
2932
|
+
*
|
|
2933
|
+
* OrcaRouter exposes an OpenAI-compatible Chat Completions API at
|
|
2934
|
+
* https://api.orcarouter.ai/v1/chat/completions with Bearer-token auth.
|
|
2935
|
+
* Modeled on invokeFireworks/invokeAtlas: request side reuses the shared
|
|
2936
|
+
* openrouter-utils converters, streaming returns the raw OpenAI SSE stream
|
|
2937
|
+
* (orcarouter is in DEFAULT_OPENAI_SSE_PROVIDERS), buffered responses are
|
|
2938
|
+
* converted to Anthropic shape locally so the orchestrator branch is a plain
|
|
2939
|
+
* passthrough.
|
|
2940
|
+
*
|
|
2941
|
+
* The key comes from the credential seam (src/clients/orcarouter-credentials):
|
|
2942
|
+
* either a user-pasted sk-orca-… key or a key issued via OAuth 2.0 + PKCE.
|
|
2943
|
+
* Downstream code never knows which source produced it.
|
|
2944
|
+
*/
|
|
2945
|
+
async function invokeOrcaRouter(body, _incomingHeaders = {}) {
|
|
2946
|
+
const {
|
|
2947
|
+
convertAnthropicToolsToOpenRouter,
|
|
2948
|
+
convertAnthropicMessagesToOpenRouter
|
|
2949
|
+
} = require("./openrouter-utils");
|
|
2950
|
+
const { chatCompletionsUrl } = require("./orcarouter-catalog");
|
|
2951
|
+
|
|
2952
|
+
const apiKey = config.orcarouter?.apiKey;
|
|
2953
|
+
if (!apiKey) {
|
|
2954
|
+
throw new Error("OrcaRouter API key is not configured. Run `lynkr connect orcarouter` or set ORCAROUTER_API_KEY in your .env file.");
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// Defense in depth: never send the Bearer key over remote plaintext HTTP.
|
|
2958
|
+
// chatCompletionsUrl() already validates the base; validate an explicit
|
|
2959
|
+
// ORCAROUTER_ENDPOINT override here too (config is lazy by design).
|
|
2960
|
+
try {
|
|
2961
|
+
const { assertAllowedOrigin } = require("./orcarouter-credentials");
|
|
2962
|
+
const epOrigin = new URL(config.orcarouter?.endpoint || chatCompletionsUrl(config.orcarouter?.apiBaseUrl)).origin;
|
|
2963
|
+
assertAllowedOrigin(epOrigin, { allowHttpLoopback: true });
|
|
2964
|
+
} catch (err) {
|
|
2965
|
+
throw new Error(`OrcaRouter endpoint rejected: ${err.message}`);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
const endpoint = config.orcarouter?.endpoint || chatCompletionsUrl(config.orcarouter?.apiBaseUrl || "https://api.orcarouter.ai");
|
|
2969
|
+
|
|
2970
|
+
const requestedModel = body._tierModel || body.model || config.orcarouter?.model || "orcarouter/auto";
|
|
2971
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
2972
|
+
|
|
2973
|
+
if (body.system) {
|
|
2974
|
+
const systemContent = Array.isArray(body.system)
|
|
2975
|
+
? body.system.map(s => s.text || s).join("\n")
|
|
2976
|
+
: body.system;
|
|
2977
|
+
messages.unshift({ role: "system", content: systemContent });
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
const { resolveThinkingParam } = require("./provider-capabilities");
|
|
2981
|
+
const orcaBody = {
|
|
2982
|
+
model: requestedModel,
|
|
2983
|
+
messages,
|
|
2984
|
+
max_tokens: body.max_tokens || 16384,
|
|
2985
|
+
temperature: body.temperature ?? 0.7,
|
|
2986
|
+
top_p: body.top_p ?? 1.0,
|
|
2987
|
+
thinking: resolveThinkingParam(body),
|
|
2988
|
+
stream: body.stream ?? false,
|
|
2989
|
+
};
|
|
2990
|
+
|
|
2991
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
2992
|
+
orcaBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
|
|
2993
|
+
orcaBody.tool_choice = forwardToolChoice(body);
|
|
2994
|
+
orcaBody.parallel_tool_calls = false;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
const headers = {
|
|
2998
|
+
"Content-Type": "application/json",
|
|
2999
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
3000
|
+
};
|
|
3001
|
+
|
|
3002
|
+
logger.debug({
|
|
3003
|
+
endpoint,
|
|
3004
|
+
model: orcaBody.model,
|
|
3005
|
+
originalModel: requestedModel,
|
|
3006
|
+
messageCount: orcaBody.messages?.length || 0,
|
|
3007
|
+
hasTools: !!orcaBody.tools,
|
|
3008
|
+
toolCount: orcaBody.tools?.length || 0,
|
|
3009
|
+
}, "=== OrcaRouter REQUEST ===");
|
|
3010
|
+
|
|
3011
|
+
// Same pre-return 429 check as the other OpenAI-compatible providers:
|
|
3012
|
+
// performJsonRequest's streaming branch never throws on a bad status, so a
|
|
3013
|
+
// 429 would otherwise be silently handed back as a "successful" response
|
|
3014
|
+
// and tier-fallback would never climb. Like Atlas Cloud, chat completions
|
|
3015
|
+
// are billable POSTs and are never replayed automatically — callers can
|
|
3016
|
+
// retry explicitly with their own idempotency policy; only 429 escalates
|
|
3017
|
+
// to tier-fallback.
|
|
3018
|
+
const response = await performJsonRequest(endpoint, {
|
|
3019
|
+
headers,
|
|
3020
|
+
body: orcaBody,
|
|
3021
|
+
maxRetriesOverride: 0,
|
|
3022
|
+
retryableStatusesOverride: [],
|
|
3023
|
+
}, "OrcaRouter");
|
|
3024
|
+
|
|
3025
|
+
// A 401 means the durable key was revoked (there is no refresh grant).
|
|
3026
|
+
// Mark the exact credential generation needsReauth — the UI/status command
|
|
3027
|
+
// surfaces it and the user re-runs `lynkr connect orcarouter`. No retry
|
|
3028
|
+
// loop, no fake refresh.
|
|
3029
|
+
if (response && !response.ok && response.status === 401) {
|
|
3030
|
+
try {
|
|
3031
|
+
const { getCredentialStore } = require("./orcarouter-credentials");
|
|
3032
|
+
getCredentialStore().markRejected(apiKey, null);
|
|
3033
|
+
} catch { /* reauth marking is best-effort — never fail the request path */ }
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
if (!response.ok && response.status === 429) {
|
|
3037
|
+
const err = new Error(`OrcaRouter rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
|
|
3038
|
+
err.status = 429;
|
|
3039
|
+
throw err;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
if (response?.stream) {
|
|
3043
|
+
return response;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
if (response?.ok && response?.json) {
|
|
3047
|
+
const anthropicJson = convertOpenAIToAnthropic(response.json);
|
|
3048
|
+
return {
|
|
3049
|
+
ok: response.ok,
|
|
3050
|
+
status: response.status,
|
|
3051
|
+
json: anthropicJson,
|
|
3052
|
+
text: JSON.stringify(anthropicJson),
|
|
3053
|
+
contentType: "application/json",
|
|
3054
|
+
headers: response.headers,
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
return response;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
2896
3061
|
/**
|
|
2897
3062
|
* Vertex AI Provider - Gemini Models
|
|
2898
3063
|
*
|
|
@@ -3370,6 +3535,7 @@ const PROVIDER_INVOKERS = {
|
|
|
3370
3535
|
codex: invokeCodex,
|
|
3371
3536
|
baidu: invokeBaidu,
|
|
3372
3537
|
fireworks: invokeFireworks,
|
|
3538
|
+
orcarouter: invokeOrcaRouter,
|
|
3373
3539
|
};
|
|
3374
3540
|
|
|
3375
3541
|
function invokeProvider(provider, body, incomingHeaders) {
|
|
@@ -3635,6 +3801,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3635
3801
|
candidates: routingResult.candidates ?? null,
|
|
3636
3802
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3637
3803
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3804
|
+
...telemetry.jevFields(routingResult),
|
|
3638
3805
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
3639
3806
|
cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
|
|
3640
3807
|
cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
|
|
@@ -3870,6 +4037,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3870
4037
|
candidates: routingResult.candidates ?? null,
|
|
3871
4038
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3872
4039
|
switch_reason: routingResult.switch_reason ?? null,
|
|
4040
|
+
...telemetry.jevFields(routingResult),
|
|
3873
4041
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
3874
4042
|
});
|
|
3875
4043
|
|
|
@@ -3983,6 +4151,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3983
4151
|
candidates: routingResult.candidates ?? null,
|
|
3984
4152
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3985
4153
|
switch_reason: routingResult.switch_reason ?? null,
|
|
4154
|
+
...telemetry.jevFields(routingResult),
|
|
3986
4155
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
3987
4156
|
});
|
|
3988
4157
|
|
|
@@ -4080,6 +4249,7 @@ async function invokeModel(body, options = {}) {
|
|
|
4080
4249
|
candidates: routingResult.candidates ?? null,
|
|
4081
4250
|
pinned: routingResult.pinned ? 1 : 0,
|
|
4082
4251
|
switch_reason: routingResult.switch_reason ?? null,
|
|
4252
|
+
...telemetry.jevFields(routingResult),
|
|
4083
4253
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
4084
4254
|
cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
|
|
4085
4255
|
cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
|
|
@@ -4141,6 +4311,7 @@ async function invokeModel(body, options = {}) {
|
|
|
4141
4311
|
candidates: routingResult.candidates ?? null,
|
|
4142
4312
|
pinned: routingResult.pinned ? 1 : 0,
|
|
4143
4313
|
switch_reason: routingResult.switch_reason ?? null,
|
|
4314
|
+
...telemetry.jevFields(routingResult),
|
|
4144
4315
|
cache_decision: routingResult._cacheDecision ?? null,
|
|
4145
4316
|
});
|
|
4146
4317
|
|
|
@@ -4232,6 +4403,8 @@ function destroyHttpAgents() {
|
|
|
4232
4403
|
|
|
4233
4404
|
module.exports = {
|
|
4234
4405
|
invokeModel,
|
|
4406
|
+
toolsDeclined,
|
|
4407
|
+
forwardToolChoice,
|
|
4235
4408
|
computeCostUsd,
|
|
4236
4409
|
convertOpenAIToAnthropic,
|
|
4237
4410
|
invokeAzureAnthropic,
|
|
@@ -4241,6 +4414,7 @@ module.exports = {
|
|
|
4241
4414
|
invokeBaidu,
|
|
4242
4415
|
invokeFireworks,
|
|
4243
4416
|
invokeAtlas,
|
|
4417
|
+
invokeOrcaRouter,
|
|
4244
4418
|
PROVIDER_INVOKERS,
|
|
4245
4419
|
stripLynkrBadges,
|
|
4246
4420
|
destroyHttpAgents,
|
|
@@ -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 = [];
|