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.
@@ -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
 
@@ -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
- 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 = [];