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.
- 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/model-capability-seeds.json +32 -4
- package/package.json +1 -1
- package/src/api/middleware/session.js +10 -1
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/cache/prompt.js +40 -1
- package/src/cache/semantic.js +7 -0
- package/src/clients/databricks.js +219 -2
- package/src/clients/openrouter-utils.js +84 -14
- 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/logger/audit-logger.js +6 -0
- package/src/logger/deduplicator.js +10 -0
- package/src/orchestrator/index.js +28 -7
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/client-profiles.js +19 -0
- package/src/routing/index.js +45 -19
- package/src/routing/model-tiers.js +12 -11
- package/src/routing/risk-classifier.js +13 -1
- package/src/routing/tokenizer.js +22 -5
- package/src/routing/vision.js +246 -0
- package/src/training/trajectory-compressor.js +8 -0
- package/src/utils/tokens.js +3 -3
package/src/cache/prompt.js
CHANGED
|
@@ -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,
|
package/src/cache/semantic.js
CHANGED
|
@@ -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 => (
|
|
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:
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
194
|
+
// Regular message (may carry text + images)
|
|
147
195
|
else {
|
|
148
196
|
const textContent = textBlocks.map(block => block.text || '').join('\n');
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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
|
}
|