lynkr 9.14.10 → 9.14.12

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.
@@ -37,6 +37,45 @@ function stableStringify(value) {
37
37
  return JSON.stringify(normaliseObject(value));
38
38
  }
39
39
 
40
+ /**
41
+ * Replace image bytes with short hashes for cache keys.
42
+ * Prevents multi-MB base64 from entering SQLite keys/values index path and
43
+ * gives stable keys for identical images without hashing megabytes per request.
44
+ */
45
+ function stripImageBytesForKey(messages) {
46
+ if (!Array.isArray(messages)) return messages;
47
+ let hashImageData = null;
48
+ try {
49
+ hashImageData = require('../routing/vision').hashImageData;
50
+ } catch { hashImageData = null; }
51
+ const shortHash = (s) => {
52
+ if (!hashImageData || typeof s !== 'string') return `[image:${String(s ?? '').length}]`;
53
+ try { return `[image:${hashImageData(s)}]`; } catch { return `[image:${String(s).length}]`; }
54
+ };
55
+ return messages.map((msg) => {
56
+ if (!msg || !Array.isArray(msg.content)) return msg;
57
+ return {
58
+ ...msg,
59
+ content: msg.content.map((block) => {
60
+ if (!block || typeof block !== 'object') return block;
61
+ if (block.type === 'image' && typeof block.source?.data === 'string') {
62
+ return { ...block, source: { ...block.source, data: shortHash(block.source.data) } };
63
+ }
64
+ if (typeof block.image_url?.url === 'string' && block.image_url.url.startsWith('data:image')) {
65
+ return { ...block, image_url: { ...block.image_url, url: shortHash(block.image_url.url) } };
66
+ }
67
+ if (typeof block.inlineData?.data === 'string') {
68
+ return { ...block, inlineData: { ...block.inlineData, data: shortHash(block.inlineData.data) } };
69
+ }
70
+ if (block.type === 'tool_result' && Array.isArray(block.content)) {
71
+ return { ...block, content: stripImageBytesForKey([{ content: block.content }])[0].content };
72
+ }
73
+ return block;
74
+ }),
75
+ };
76
+ });
77
+ }
78
+
40
79
  class PromptCache {
41
80
  constructor(options = {}) {
42
81
  this.enabled = options.enabled === true;
@@ -158,7 +197,7 @@ class PromptCache {
158
197
  const canonical = {
159
198
  model: payload.model ?? null,
160
199
  input: payload.input ?? null,
161
- messages: payload.messages ? normaliseObject(payload.messages) : null,
200
+ messages: payload.messages ? normaliseObject(stripImageBytesForKey(payload.messages)) : null,
162
201
  tools: payload.tools ? normaliseObject(payload.tools) : null,
163
202
  tool_choice: payload.tool_choice ? normaliseObject(payload.tool_choice) : null,
164
203
  temperature: payload.temperature ?? null,
@@ -162,6 +162,13 @@ class SemanticCache {
162
162
  }
163
163
  return count;
164
164
  }, 0),
165
+ // Vision hash: same text + different image must NOT hit. Null when no
166
+ // images (backwards compatible with existing entries).
167
+ visionHash: (() => {
168
+ try {
169
+ return require('../routing/vision').visionContextHash(messages);
170
+ } catch { return null; }
171
+ })(),
165
172
  };
166
173
 
167
174
  hash.update(JSON.stringify(conversationState));
@@ -1810,6 +1810,62 @@ async function invokeLMStudio(body, _incomingHeaders = {}) {
1810
1810
  return performJsonRequest(endpoint, { headers, body: lmstudioBody }, "LM Studio");
1811
1811
  }
1812
1812
 
1813
+ /**
1814
+ * Map a single Anthropic content block to Bedrock Converse content.
1815
+ * Text -> {text}; image base64 -> {image:{format, source:{bytes}}};
1816
+ * image URL / unsupported -> text placeholder (Converse has no URL fetch).
1817
+ * Returns null for cache_control-only / empty blocks.
1818
+ *
1819
+ * @param {object|string} block
1820
+ * @returns {object|null}
1821
+ */
1822
+ function anthropicBlockToConverseContent(block) {
1823
+ if (block == null) return null;
1824
+ if (typeof block === 'string') return block ? { text: block } : null;
1825
+ if (block.type === 'text' && typeof block.text === 'string') {
1826
+ return block.text ? { text: block.text } : null;
1827
+ }
1828
+ if (block.type === 'image' && block.source) {
1829
+ const src = block.source;
1830
+ if (src.type === 'base64' && src.data) {
1831
+ const mediaType = (src.media_type || 'image/jpeg').toLowerCase();
1832
+ const format = mediaType.includes('png') ? 'png'
1833
+ : mediaType.includes('gif') ? 'gif'
1834
+ : mediaType.includes('webp') ? 'webp'
1835
+ : 'jpeg';
1836
+ try {
1837
+ return { image: { format, source: { bytes: src.data } } };
1838
+ } catch { return null; }
1839
+ }
1840
+ if (src.type === 'url' && src.url) {
1841
+ return { text: `[Image URL: ${src.url}]` };
1842
+ }
1843
+ }
1844
+ // OpenAI-shaped image_url (may already be converted upstream)
1845
+ const imageUrl = block.image_url?.url || (typeof block.url === 'string' ? block.url : null);
1846
+ if ((block.type === 'image_url' || block.type === 'input_image' || imageUrl) && imageUrl) {
1847
+ const dataMatch = String(imageUrl).match(/^data:(image\/[^;]+);base64,(.+)$/);
1848
+ if (dataMatch) {
1849
+ const mediaType = dataMatch[1].toLowerCase();
1850
+ const format = mediaType.includes('png') ? 'png'
1851
+ : mediaType.includes('gif') ? 'gif'
1852
+ : mediaType.includes('webp') ? 'webp'
1853
+ : 'jpeg';
1854
+ return { image: { format, source: { bytes: dataMatch[2] } } };
1855
+ }
1856
+ return { text: `[Image URL: ${imageUrl}]` };
1857
+ }
1858
+ if (block.type === 'tool_use' || block.type === 'tool_result' || block.type === 'thinking') {
1859
+ // Tool/thinking blocks are handled by the toolConfig path or dropped for
1860
+ // pure-text turns — preserve text content when present.
1861
+ const text = block.text || (typeof block.content === 'string' ? block.content : null);
1862
+ return text ? { text } : null;
1863
+ }
1864
+ if (typeof block.text === 'string' && block.text) return { text: block.text };
1865
+ if (typeof block.content === 'string' && block.content) return { text: block.content };
1866
+ return null;
1867
+ }
1868
+
1813
1869
  /**
1814
1870
  * Flatten an Anthropic-style content value into a plain string for the
1815
1871
  * Bedrock Converse API.
@@ -1919,9 +1975,10 @@ async function invokeBedrock(body, _incomingHeaders = {}) {
1919
1975
  .map(msg => ({
1920
1976
  role: msg.role,
1921
1977
  content: Array.isArray(msg.content)
1922
- ? msg.content.map(c => ({ text: c.text || c.content || "" }))
1923
- : [{ text: msg.content }]
1978
+ ? msg.content.map(c => anthropicBlockToConverseContent(c)).filter(Boolean)
1979
+ : [{ text: String(msg.content ?? '') }]
1924
1980
  }))
1981
+ .map(m => ({ ...m, content: m.content.length > 0 ? m.content : [{ text: ' ' }] }))
1925
1982
  };
1926
1983
 
1927
1984
  // Add system prompt (from Anthropic system field OR extracted from messages)
@@ -2836,6 +2893,137 @@ function sanitizeSchemaForGemini(schema) {
2836
2893
  return sanitized;
2837
2894
  }
2838
2895
 
2896
+ /**
2897
+ * OrcaRouter Provider (OpenAI-compatible gateway)
2898
+ *
2899
+ * OrcaRouter exposes an OpenAI-compatible Chat Completions API at
2900
+ * https://api.orcarouter.ai/v1/chat/completions with Bearer-token auth.
2901
+ * Modeled on invokeFireworks/invokeAtlas: request side reuses the shared
2902
+ * openrouter-utils converters, streaming returns the raw OpenAI SSE stream
2903
+ * (orcarouter is in DEFAULT_OPENAI_SSE_PROVIDERS), buffered responses are
2904
+ * converted to Anthropic shape locally so the orchestrator branch is a plain
2905
+ * passthrough.
2906
+ *
2907
+ * The key comes from the credential seam (src/clients/orcarouter-credentials):
2908
+ * either a user-pasted sk-orca-… key or a key issued via OAuth 2.0 + PKCE.
2909
+ * Downstream code never knows which source produced it.
2910
+ */
2911
+ async function invokeOrcaRouter(body, _incomingHeaders = {}) {
2912
+ const {
2913
+ convertAnthropicToolsToOpenRouter,
2914
+ convertAnthropicMessagesToOpenRouter
2915
+ } = require("./openrouter-utils");
2916
+ const { chatCompletionsUrl } = require("./orcarouter-catalog");
2917
+
2918
+ const apiKey = config.orcarouter?.apiKey;
2919
+ if (!apiKey) {
2920
+ throw new Error("OrcaRouter API key is not configured. Run `lynkr connect orcarouter` or set ORCAROUTER_API_KEY in your .env file.");
2921
+ }
2922
+
2923
+ // Defense in depth: never send the Bearer key over remote plaintext HTTP.
2924
+ // chatCompletionsUrl() already validates the base; validate an explicit
2925
+ // ORCAROUTER_ENDPOINT override here too (config is lazy by design).
2926
+ try {
2927
+ const { assertAllowedOrigin } = require("./orcarouter-credentials");
2928
+ const epOrigin = new URL(config.orcarouter?.endpoint || chatCompletionsUrl(config.orcarouter?.apiBaseUrl)).origin;
2929
+ assertAllowedOrigin(epOrigin, { allowHttpLoopback: true });
2930
+ } catch (err) {
2931
+ throw new Error(`OrcaRouter endpoint rejected: ${err.message}`);
2932
+ }
2933
+
2934
+ const endpoint = config.orcarouter?.endpoint || chatCompletionsUrl(config.orcarouter?.apiBaseUrl || "https://api.orcarouter.ai");
2935
+
2936
+ const requestedModel = body._tierModel || body.model || config.orcarouter?.model || "orcarouter/auto";
2937
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
2938
+
2939
+ if (body.system) {
2940
+ const systemContent = Array.isArray(body.system)
2941
+ ? body.system.map(s => s.text || s).join("\n")
2942
+ : body.system;
2943
+ messages.unshift({ role: "system", content: systemContent });
2944
+ }
2945
+
2946
+ const { resolveThinkingParam } = require("./provider-capabilities");
2947
+ const orcaBody = {
2948
+ model: requestedModel,
2949
+ messages,
2950
+ max_tokens: body.max_tokens || 16384,
2951
+ temperature: body.temperature ?? 0.7,
2952
+ top_p: body.top_p ?? 1.0,
2953
+ thinking: resolveThinkingParam(body),
2954
+ stream: body.stream ?? false,
2955
+ };
2956
+
2957
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
2958
+ orcaBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2959
+ orcaBody.tool_choice = "auto";
2960
+ orcaBody.parallel_tool_calls = false;
2961
+ }
2962
+
2963
+ const headers = {
2964
+ "Content-Type": "application/json",
2965
+ "Authorization": `Bearer ${apiKey}`,
2966
+ };
2967
+
2968
+ logger.debug({
2969
+ endpoint,
2970
+ model: orcaBody.model,
2971
+ originalModel: requestedModel,
2972
+ messageCount: orcaBody.messages?.length || 0,
2973
+ hasTools: !!orcaBody.tools,
2974
+ toolCount: orcaBody.tools?.length || 0,
2975
+ }, "=== OrcaRouter REQUEST ===");
2976
+
2977
+ // Same pre-return 429 check as the other OpenAI-compatible providers:
2978
+ // performJsonRequest's streaming branch never throws on a bad status, so a
2979
+ // 429 would otherwise be silently handed back as a "successful" response
2980
+ // and tier-fallback would never climb. Like Atlas Cloud, chat completions
2981
+ // are billable POSTs and are never replayed automatically — callers can
2982
+ // retry explicitly with their own idempotency policy; only 429 escalates
2983
+ // to tier-fallback.
2984
+ const response = await performJsonRequest(endpoint, {
2985
+ headers,
2986
+ body: orcaBody,
2987
+ maxRetriesOverride: 0,
2988
+ retryableStatusesOverride: [],
2989
+ }, "OrcaRouter");
2990
+
2991
+ // A 401 means the durable key was revoked (there is no refresh grant).
2992
+ // Mark the exact credential generation needsReauth — the UI/status command
2993
+ // surfaces it and the user re-runs `lynkr connect orcarouter`. No retry
2994
+ // loop, no fake refresh.
2995
+ if (response && !response.ok && response.status === 401) {
2996
+ try {
2997
+ const { getCredentialStore } = require("./orcarouter-credentials");
2998
+ getCredentialStore().markRejected(apiKey, null);
2999
+ } catch { /* reauth marking is best-effort — never fail the request path */ }
3000
+ }
3001
+
3002
+ if (!response.ok && response.status === 429) {
3003
+ const err = new Error(`OrcaRouter rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
3004
+ err.status = 429;
3005
+ throw err;
3006
+ }
3007
+
3008
+ if (response?.stream) {
3009
+ return response;
3010
+ }
3011
+
3012
+ if (response?.ok && response?.json) {
3013
+ const anthropicJson = convertOpenAIToAnthropic(response.json);
3014
+ return {
3015
+ ok: response.ok,
3016
+ status: response.status,
3017
+ json: anthropicJson,
3018
+ text: JSON.stringify(anthropicJson),
3019
+ contentType: "application/json",
3020
+ headers: response.headers,
3021
+ };
3022
+ }
3023
+
3024
+ return response;
3025
+ }
3026
+
2839
3027
  /**
2840
3028
  * Vertex AI Provider - Gemini Models
2841
3029
  *
@@ -2997,6 +3185,33 @@ IMPORTANT TOOL USAGE RULES:
2997
3185
  for (const block of msg.content) {
2998
3186
  if (block.type === "text") {
2999
3187
  parts.push({ text: block.text });
3188
+ } else if (block.type === "image" && block.source?.type === "base64" && block.source?.data) {
3189
+ parts.push({
3190
+ inlineData: {
3191
+ mimeType: block.source.media_type || "image/jpeg",
3192
+ data: block.source.data,
3193
+ },
3194
+ });
3195
+ } else if (block.type === "image" && block.source?.type === "url" && block.source?.url) {
3196
+ parts.push({ text: `[Image URL: ${block.source.url}]` });
3197
+ } else if ((block.type === "image_url" || block.type === "input_image") && block.image_url?.url) {
3198
+ const url = block.image_url.url;
3199
+ const dataMatch = String(url).match(/^data:(image\/[^;]+);base64,(.+)$/);
3200
+ if (dataMatch) {
3201
+ parts.push({ inlineData: { mimeType: dataMatch[1], data: dataMatch[2] } });
3202
+ } else {
3203
+ parts.push({ text: `[Image URL: ${url}]` });
3204
+ }
3205
+ } else if (block.image_url?.url) {
3206
+ const url = block.image_url.url;
3207
+ const dataMatch = String(url).match(/^data:(image\/[^;]+);base64,(.+)$/);
3208
+ if (dataMatch) {
3209
+ parts.push({ inlineData: { mimeType: dataMatch[1], data: dataMatch[2] } });
3210
+ } else {
3211
+ parts.push({ text: `[Image URL: ${url}]` });
3212
+ }
3213
+ } else if (block.inlineData?.data) {
3214
+ parts.push({ inlineData: block.inlineData });
3000
3215
  } else if (block.type === "tool_use") {
3001
3216
  // Assistant's tool call
3002
3217
  parts.push({
@@ -3286,6 +3501,7 @@ const PROVIDER_INVOKERS = {
3286
3501
  codex: invokeCodex,
3287
3502
  baidu: invokeBaidu,
3288
3503
  fireworks: invokeFireworks,
3504
+ orcarouter: invokeOrcaRouter,
3289
3505
  };
3290
3506
 
3291
3507
  function invokeProvider(provider, body, incomingHeaders) {
@@ -4157,6 +4373,7 @@ module.exports = {
4157
4373
  invokeBaidu,
4158
4374
  invokeFireworks,
4159
4375
  invokeAtlas,
4376
+ invokeOrcaRouter,
4160
4377
  PROVIDER_INVOKERS,
4161
4378
  stripLynkrBadges,
4162
4379
  destroyHttpAgents,
@@ -55,6 +55,25 @@ function convertAnthropicToolsToOpenRouter(anthropicTools) {
55
55
  * - User messages with tool_result blocks → OpenRouter tool role messages
56
56
  * - Regular text content → OpenRouter text content
57
57
  */
58
+ function anthropicImageToOpenAIUrl(block) {
59
+ if (!block || typeof block !== 'object') return null;
60
+ // Anthropic: {type:'image', source:{type:'base64', media_type, data}}
61
+ if (block.type === 'image' && block.source?.type === 'base64' && block.source?.data) {
62
+ const mediaType = block.source.media_type || 'image/jpeg';
63
+ return `data:${mediaType};base64,${block.source.data}`;
64
+ }
65
+ if (block.type === 'image' && block.source?.type === 'url' && block.source?.url) {
66
+ return block.source.url;
67
+ }
68
+ // Already OpenAI-shaped
69
+ if ((block.type === 'image_url' || block.type === 'input_image') && block.image_url?.url) {
70
+ return block.image_url.url;
71
+ }
72
+ if (block.image_url?.url) return block.image_url.url;
73
+ if (typeof block.url === 'string' && block.url.startsWith('data:image')) return block.url;
74
+ return null;
75
+ }
76
+
58
77
  function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
59
78
  if (!Array.isArray(anthropicMessages)) return [];
60
79
 
@@ -124,32 +143,80 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
124
143
  // providers 400 the unpaired function_call.
125
144
  else if (msg.role === 'user' && toolResultBlocks.length > 0) {
126
145
  // Tool results first — adjacent to their assistant tool_calls.
146
+ // Preserve nested screenshots: emit text to the tool message and
147
+ // re-attach images as a following user message with image_url parts.
148
+ const pendingImages = [];
127
149
  for (const toolResult of toolResultBlocks) {
150
+ let toolText = '';
151
+ const trContent = toolResult.content;
152
+ if (typeof trContent === 'string') {
153
+ toolText = trContent;
154
+ } else if (Array.isArray(trContent)) {
155
+ const texts = [];
156
+ for (const b of trContent) {
157
+ if (b?.type === 'text' && b.text) texts.push(b.text);
158
+ else {
159
+ const url = anthropicImageToOpenAIUrl(b);
160
+ if (url) pendingImages.push(url);
161
+ else if (b) texts.push(typeof b === 'string' ? b : JSON.stringify(b));
162
+ }
163
+ }
164
+ toolText = texts.join('\n');
165
+ } else {
166
+ try { toolText = JSON.stringify(trContent || {}); } catch { toolText = ''; }
167
+ }
128
168
  converted.push({
129
169
  role: 'tool',
130
170
  tool_call_id: toolResult.tool_use_id || `call_${Date.now()}`,
131
- content: typeof toolResult.content === 'string'
132
- ? toolResult.content
133
- : JSON.stringify(toolResult.content || {})
171
+ content: toolText
134
172
  });
135
173
  }
136
174
 
137
- // Then any user-authored/injected text as a user message.
175
+ // Then any user-authored/injected text + images as a user message.
138
176
  const textContent = textBlocks.map(block => block.text || '').join('\n');
139
- if (textContent) {
140
- converted.push({
141
- role: 'user',
142
- content: textContent
143
- });
177
+ for (const b of content) {
178
+ const url = anthropicImageToOpenAIUrl(b);
179
+ if (url) pendingImages.push(url);
180
+ }
181
+ if (textContent || pendingImages.length > 0) {
182
+ if (pendingImages.length === 0) {
183
+ converted.push({ role: 'user', content: textContent });
184
+ } else {
185
+ const parts = [];
186
+ if (textContent) parts.push({ type: 'text', text: textContent });
187
+ for (const url of pendingImages) {
188
+ parts.push({ type: 'image_url', image_url: { url } });
189
+ }
190
+ converted.push({ role: 'user', content: parts });
191
+ }
144
192
  }
145
193
  }
146
- // Regular message with just text
194
+ // Regular message (may carry text + images)
147
195
  else {
148
196
  const textContent = textBlocks.map(block => block.text || '').join('\n');
149
- const message = {
150
- role: msg.role,
151
- content: textContent || ''
152
- };
197
+ const imageUrls = [];
198
+ for (const b of content) {
199
+ const url = anthropicImageToOpenAIUrl(b);
200
+ if (url) imageUrls.push(url);
201
+ }
202
+ // Document blocks with embedded base64 images
203
+ for (const b of content) {
204
+ if (b?.type === 'document' && b.source?.data && imageUrls.length === 0) {
205
+ // PDFs aren't image_url-compatible — keep a text placeholder so the
206
+ // turn isn't silently emptied (becomes ' ' downstream if nothing else).
207
+ }
208
+ }
209
+ let message;
210
+ if (imageUrls.length === 0) {
211
+ message = { role: msg.role, content: textContent || '' };
212
+ } else {
213
+ const parts = [];
214
+ if (textContent) parts.push({ type: 'text', text: textContent });
215
+ for (const url of imageUrls) {
216
+ parts.push({ type: 'image_url', image_url: { url } });
217
+ }
218
+ message = { role: msg.role, content: parts };
219
+ }
153
220
  if (msg.role === 'assistant' && thinkingText) {
154
221
  message.reasoning_content = thinkingText;
155
222
  }
@@ -188,6 +255,9 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
188
255
  if (m.role === 'tool') continue;
189
256
  const hasToolCalls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
190
257
  if (hasToolCalls) continue;
258
+ // Array content (text + image_url parts) must be preserved verbatim —
259
+ // only string content gets the empty-string guard.
260
+ if (Array.isArray(m.content)) continue;
191
261
  if (typeof m.content !== 'string' || m.content.trim() === '') {
192
262
  m.content = ' ';
193
263
  }