lynkr
Version:
Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.
391 lines (357 loc) • 13.1 kB
JavaScript
const logger = require("../logger");
/**
* Convert Anthropic tool format to OpenAI/OpenRouter format
*
* Anthropic format:
* {
* name: "get_weather",
* description: "Get weather",
* input_schema: { type: "object", properties: {...}, required: [...] }
* }
*
* OpenRouter format:
* {
* type: "function",
* function: {
* name: "get_weather",
* description: "Get weather",
* parameters: { type: "object", properties: {...}, required: [...] }
* }
* }
*/
function convertAnthropicToolsToOpenRouter(anthropicTools) {
if (!Array.isArray(anthropicTools) || anthropicTools.length === 0) {
return [];
}
return anthropicTools.map(tool => ({
type: "function",
function: {
name: tool.name,
description: tool.description || "",
parameters: tool.input_schema || {
type: "object",
properties: {},
required: []
}
}
}));
}
/**
* Convert Anthropic messages to OpenAI/OpenRouter format
*
* Anthropic format:
* - Assistant messages with tool_use blocks → OpenRouter assistant with tool_calls
* - User messages with tool_result blocks → OpenRouter tool role messages
* - Regular text content → OpenRouter text content
*/
function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
if (!Array.isArray(anthropicMessages)) return [];
const logger = require("../logger");
const converted = [];
for (const msg of anthropicMessages) {
let content = msg.content;
// Handle array of content blocks
if (Array.isArray(content)) {
const textBlocks = content.filter(block => block.type === 'text');
const toolUseBlocks = content.filter(block => block.type === 'tool_use');
const toolResultBlocks = content.filter(block => block.type === 'tool_result');
logger.debug({
role: msg.role,
contentIsArray: true,
contentLength: content.length,
blockTypes: content.map(b => b.type),
textCount: textBlocks.length,
toolUseCount: toolUseBlocks.length,
toolResultCount: toolResultBlocks.length
}, "Processing Anthropic message");
// Assistant message with tool calls
if (msg.role === 'assistant' && toolUseBlocks.length > 0) {
const textContent = textBlocks.map(block => block.text || '').join('\n');
const tool_calls = toolUseBlocks.map(block => ({
id: block.id || `call_${Date.now()}`,
type: 'function',
function: {
name: block.name || 'unknown',
arguments: JSON.stringify(block.input || {})
}
}));
const message = {
role: 'assistant',
tool_calls
};
// Moonshot/Kimi and some OpenAI-compatible APIs require content to
// be null (not empty string) when tool_calls are present.
if (textContent && textContent.trim()) {
message.content = textContent;
} else {
message.content = null;
}
converted.push(message);
}
// User message with tool results
else if (msg.role === 'user' && toolResultBlocks.length > 0) {
// Add text content as user message first if present
const textContent = textBlocks.map(block => block.text || '').join('\n');
if (textContent) {
converted.push({
role: 'user',
content: textContent
});
}
// Add each tool result as a separate tool message
for (const toolResult of toolResultBlocks) {
converted.push({
role: 'tool',
tool_call_id: toolResult.tool_use_id || `call_${Date.now()}`,
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content || {})
});
}
}
// Regular message with just text
else {
const textContent = textBlocks.map(block => block.text || '').join('\n');
converted.push({
role: msg.role,
content: textContent || ''
});
}
}
// Simple string content
else {
logger.debug({
role: msg.role,
contentIsArray: false,
contentType: typeof content,
contentLength: content?.length || 0
}, "Processing Anthropic message (string content)");
converted.push({
role: msg.role,
content: content || ''
});
}
}
// Fix tool_call_id mismatches: ensure every tool message's tool_call_id
// matches the id in the preceding assistant's tool_calls array.
// IDs can drift when multiple conversion layers (Anthropic↔OpenAI) each
// generate their own IDs.
for (let i = 0; i < converted.length; i++) {
const msg = converted[i];
if (msg.role !== 'tool') continue;
// Find the nearest preceding assistant with tool_calls
for (let j = i - 1; j >= 0; j--) {
const prev = converted[j];
if (prev.role === 'user') break;
if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
if (!prev.tool_calls.some(tc => tc.id === msg.tool_call_id)) {
// Mismatch — pick the first unmatched tool_call id
const usedIds = new Set();
for (let k = j + 1; k < converted.length; k++) {
if (converted[k].role === 'tool' && k !== i) usedIds.add(converted[k].tool_call_id);
}
const available = prev.tool_calls.find(tc => !usedIds.has(tc.id));
if (available) {
logger.info({ from: msg.tool_call_id, to: available.id }, "Fixed tool_call_id mismatch");
msg.tool_call_id = available.id;
}
}
break;
}
}
}
// Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose
// content is an empty string with "Invalid request: tokenization failed".
// This happens when a turn had only non-text blocks (thinking / image /
// stripped content) and flattened to "". Replace empty/whitespace-only
// content with a single space — but never touch an assistant message that
// carries tool_calls, where content: null is intentional and required.
for (const m of converted) {
if (m.role === 'tool') continue;
const hasToolCalls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
if (hasToolCalls) continue;
if (typeof m.content !== 'string' || m.content.trim() === '') {
m.content = ' ';
}
}
// Log the converted messages for debugging
logger.debug({
inputCount: anthropicMessages.length,
outputCount: converted.length,
converted: converted.map((m, i) => ({
index: i,
role: m.role,
hasContent: !!m.content,
contentLength: m.content?.length || 0,
hasToolCalls: !!m.tool_calls,
toolCallsCount: m.tool_calls?.length || 0,
hasToolCallId: !!m.tool_call_id
}))
}, "OpenRouter message conversion");
return converted;
}
/**
* Convert OpenRouter response to Anthropic format
*
* OpenRouter format:
* {
* id: "chatcmpl-123",
* choices: [{
* message: {
* role: "assistant",
* content: "Hello",
* tool_calls: [{
* id: "call_abc123",
* type: "function",
* function: { name: "get_weather", arguments: "{...}" }
* }]
* },
* finish_reason: "stop"
* }],
* usage: { prompt_tokens: 10, completion_tokens: 20 }
* }
*
* Anthropic format:
* {
* id: "msg_123",
* type: "message",
* role: "assistant",
* content: [
* { type: "text", text: "Hello" },
* { type: "tool_use", id: "toolu_123", name: "get_weather", input: {...} }
* ],
* stop_reason: "tool_use",
* usage: { input_tokens: 10, output_tokens: 20 }
* }
*/
function convertOpenRouterResponseToAnthropic(openRouterResponse, requestedModel) {
const choice = openRouterResponse.choices?.[0];
if (!choice) {
throw new Error("No choices in OpenRouter response");
}
const message = choice.message || {};
const contentBlocks = [];
// Extract tool calls embedded as XML/text in content (Minimax, Qwen, GLM, etc.)
if (!message.tool_calls?.length && typeof message.content === "string" && message.content.trim()) {
const { extractToolCallsFromText } = require("./xml-tool-extractor");
const extracted = extractToolCallsFromText(message.content);
if (extracted.toolCalls.length > 0) {
message.tool_calls = extracted.toolCalls;
message.content = extracted.cleanedText;
choice.finish_reason = "tool_calls";
}
}
// Check if there are tool calls present
const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
// Helper function to detect if content is a JSON representation of a tool call
// Some models (like llama.cpp) may output tool calls in both content AND tool_calls
const isToolCallJson = (text) => {
if (!text) return false;
const trimmed = text.trim();
// Check if it looks like a JSON object containing tool/function call
// Matches various formats:
// - {"type": "function", "function": {"name": "X", "parameters": {...}}}
// - {"function": "X", "parameters": {...}}
// - {"tool": "X", "input": {...}}
return (trimmed.startsWith('{') || trimmed.startsWith('[')) &&
(trimmed.includes('"function"') || trimmed.includes('"tool"') ||
(trimmed.includes('"type"') && trimmed.includes('"parameters"'))) &&
(trimmed.includes('"parameters"') || trimmed.includes('"input"') ||
trimmed.includes('"arguments"'));
};
// Emit reasoning_content as a thinking block (not as fallback text)
let textContent = message.content || "";
if (message.reasoning_content && typeof message.reasoning_content === "string") {
contentBlocks.push({ type: "thinking", thinking: message.reasoning_content });
}
if (!textContent.trim() && !message.reasoning_content) {
// No content at all — will be handled below
}
// Add text content if present, but skip if it's a duplicate/malformed tool call JSON
if (textContent && textContent.trim()) {
const looksLikeToolJson = isToolCallJson(textContent);
// Skip content in two cases:
// 1. We have proper tool_calls AND content duplicates them (original fix)
// 2. Content looks like tool call JSON but we DON'T have tool_calls
// (model incorrectly output JSON instead of structured tool_calls)
if (looksLikeToolJson) {
if (hasToolCalls) {
// Case 1: Duplicate - model provided both content and tool_calls
logger.debug({
contentPreview: textContent.substring(0, 100),
toolCallCount: message.tool_calls.length
}, "Skipping text content that duplicates tool_calls (llama.cpp quirk)");
} else {
// Case 2: Malformed - model only provided JSON in content, not structured tool_calls
// This is a model error - it should have used tool_calls, not raw JSON
logger.warn({
contentPreview: textContent.substring(0, 200)
}, "Model output tool call as JSON text instead of structured tool_calls - filtering out malformed output");
}
// Skip this content block in both cases
} else {
// Normal text content - include it
contentBlocks.push({
type: "text",
text: textContent
});
}
}
// Add tool calls if present
if (hasToolCalls) {
for (const toolCall of message.tool_calls) {
const func = toolCall.function || {};
let input = {};
// Parse arguments string
if (func.arguments) {
try {
input = typeof func.arguments === "string"
? JSON.parse(func.arguments)
: func.arguments;
} catch (err) {
logger.warn({
error: err.message,
arguments: func.arguments
}, "Failed to parse OpenRouter tool arguments");
input = {};
}
}
contentBlocks.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: func.name || "unknown",
input
});
}
}
if (contentBlocks.length === 0) {
contentBlocks.push({ type: "text", text: "" });
}
// Determine stop reason
let stopReason = "end_turn";
if (hasToolCalls) {
stopReason = "tool_use";
} else if (choice.finish_reason === "length") {
stopReason = "max_tokens";
}
return {
id: openRouterResponse.id || `msg_${Date.now()}`,
type: "message",
role: "assistant",
model: requestedModel,
content: contentBlocks,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: openRouterResponse.usage?.prompt_tokens || 0,
output_tokens: openRouterResponse.usage?.completion_tokens || 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
};
}
module.exports = {
convertAnthropicToolsToOpenRouter,
convertAnthropicMessagesToOpenRouter,
convertOpenRouterResponseToAnthropic
};