/** * Tool Format Converter * Converts between Claude and Ollama tool/function formats */ /** * Convert Claude tool definition to Ollama function format * * Claude format: * { name: "...", description: "...", input_schema: { type: "object", properties: {...} } } * * Ollama format: * { type: "function", function: { name: "...", description: "...", parameters: {...} } } * * @param {object} claudeTool - Tool in Claude format * @returns {object} Tool in Ollama format */ export function claudeToolToOllama(claudeTool) { return { type: "function", function: { name: claudeTool.name, description: claudeTool.description, parameters: claudeTool.input_schema } }; } /** * Convert array of Claude tools to Ollama format * @param {Array} claudeTools - Array of Claude tool definitions * @returns {Array} Array of Ollama function definitions */ export function claudeToolsToOllama(claudeTools) { if (!claudeTools || !Array.isArray(claudeTools)) { return []; } return claudeTools.map(claudeToolToOllama); } /** * Convert Ollama tool call to Claude format * * Ollama format (in message): * { tool_calls: [{ function: { name: "...", arguments: {...} } }] } * * Claude format: * { type: "tool_use", id: "...", name: "...", input: {...} } * * @param {object} ollamaToolCall - Tool call from Ollama response * @param {number} index - Index for generating unique ID * @returns {object} Tool call in Claude format */ export function ollamaToolCallToClaude(ollamaToolCall, index = 0) { const func = ollamaToolCall.function; // Parse arguments if it's a string let input = func.arguments; if (typeof input === 'string') { try { input = JSON.parse(input); } catch (e) { console.warn('[ToolConverter] Failed to parse tool arguments:', e); input = {}; } } return { type: "tool_use", id: `toolu_ollama_${Date.now()}_${index}`, name: func.name, input: input || {} }; } /** * Convert Claude tool result to Ollama format * * Claude format (in messages): * { role: "user", content: [{ type: "tool_result", tool_use_id: "...", content: "..." }] } * * Ollama format: * { role: "tool", content: "...", name: "..." } * * @param {object} claudeToolResult - Tool result in Claude format * @param {string} toolName - Name of the tool (from previous tool_use) * @returns {object} Tool result in Ollama message format */ export function claudeToolResultToOllama(claudeToolResult, toolName) { let content = claudeToolResult.content; // Stringify if it's an object if (typeof content === 'object') { content = JSON.stringify(content); } return { role: "tool", content: content, name: toolName }; } /** * Convert Claude messages array to Ollama format * Handles regular messages and tool result messages * * @param {Array} claudeMessages - Messages in Claude format * @param {string} systemPrompt - System prompt to prepend * @returns {Array} Messages in Ollama format */ export function claudeMessagesToOllama(claudeMessages, systemPrompt) { const ollamaMessages = []; // Add system message if provided if (systemPrompt) { ollamaMessages.push({ role: "system", content: systemPrompt }); } // Track tool names for tool results const toolNameMap = new Map(); for (const msg of claudeMessages) { if (msg.role === 'user') { // Check if it's a tool result message if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'tool_result') { const toolName = toolNameMap.get(block.tool_use_id) || 'unknown'; ollamaMessages.push(claudeToolResultToOllama(block, toolName)); } else if (block.type === 'text') { ollamaMessages.push({ role: "user", content: block.text }); } } } else { ollamaMessages.push({ role: "user", content: msg.content }); } } else if (msg.role === 'assistant') { // Handle assistant messages with potential tool calls if (Array.isArray(msg.content)) { let textContent = ''; const toolCalls = []; for (const block of msg.content) { if (block.type === 'text') { textContent += block.text; } else if (block.type === 'tool_use') { // Track tool name for later tool results toolNameMap.set(block.id, block.name); toolCalls.push({ function: { name: block.name, // Ollama expects arguments as object, not string arguments: block.input || {} } }); } } const assistantMsg = { role: "assistant", content: textContent || "" }; if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls; } ollamaMessages.push(assistantMsg); } else { ollamaMessages.push({ role: "assistant", content: msg.content }); } } } return ollamaMessages; } /** * Convert Ollama response to Claude format * * @param {object} ollamaResponse - Response from Ollama API * @returns {object} Response in Claude format */ export function ollamaResponseToClaude(ollamaResponse) { const content = []; const message = ollamaResponse.message; // Add text content if present if (message.content) { content.push({ type: "text", text: message.content }); } // Add tool calls if present if (message.tool_calls && message.tool_calls.length > 0) { for (let i = 0; i < message.tool_calls.length; i++) { content.push(ollamaToolCallToClaude(message.tool_calls[i], i)); } } // Determine stop reason let stopReason = "end_turn"; if (message.tool_calls && message.tool_calls.length > 0) { stopReason = "tool_use"; } else if (ollamaResponse.done_reason === "length") { stopReason = "max_tokens"; } return { id: `msg_ollama_${Date.now()}`, type: "message", role: "assistant", content: content, model: ollamaResponse.model, stop_reason: stopReason, usage: { input_tokens: ollamaResponse.prompt_eval_count || 0, output_tokens: ollamaResponse.eval_count || 0 } }; } // ============================================ // Cloudflare Workers AI Converters // ============================================ /** * Convert Claude tool definition to Cloudflare format * Cloudflare uses OpenAI-compatible format * * Claude format: * { name: "...", description: "...", input_schema: { type: "object", properties: {...} } } * * Cloudflare format: * { type: "function", function: { name: "...", description: "...", parameters: {...} } } * * @param {object} claudeTool - Tool in Claude format * @returns {object} Tool in Cloudflare format */ export function claudeToolToCloudflare(claudeTool) { return { type: "function", function: { name: claudeTool.name, description: claudeTool.description, parameters: claudeTool.input_schema } }; } /** * Convert array of Claude tools to Cloudflare format * @param {Array} claudeTools - Array of Claude tool definitions * @returns {Array} Array of Cloudflare function definitions */ export function claudeToolsToCloudflare(claudeTools) { if (!claudeTools || !Array.isArray(claudeTools)) { return []; } return claudeTools.map(claudeToolToCloudflare); } /** * Convert Cloudflare tool call to Claude format * * Cloudflare format: * { name: "...", arguments: {...} } * * Claude format: * { type: "tool_use", id: "...", name: "...", input: {...} } * * @param {object} cfToolCall - Tool call from Cloudflare response * @param {number} index - Index for generating unique ID * @returns {object} Tool call in Claude format */ export function cloudflareToolCallToClaude(cfToolCall, index = 0) { // Parse arguments if it's a string let input = cfToolCall.arguments; if (typeof input === 'string') { try { input = JSON.parse(input); } catch (e) { console.warn('[ToolConverter] Failed to parse Cloudflare tool arguments:', e); input = {}; } } return { type: "tool_use", id: `toolu_cf_${Date.now()}_${index}`, name: cfToolCall.name, input: input || {} }; } /** * Convert Claude messages array to Cloudflare format * Cloudflare uses OpenAI-compatible message format * * IMPORTANT: Cloudflare Workers AI does NOT support multi-turn tool conversations. * It crashes with error 3043 when conversation history contains tool_calls or tool results. * We must strip tool call history and only keep text content from past messages. * * @param {Array} claudeMessages - Messages in Claude format * @param {string} systemPrompt - System prompt to prepend * @returns {Array} Messages in Cloudflare format */ export function claudeMessagesToCloudflare(claudeMessages, systemPrompt) { const cfMessages = []; // Add system message if provided if (systemPrompt) { cfMessages.push({ role: "system", content: systemPrompt }); } // Cloudflare doesn't support tool call history in native format - convert to text // so the model knows what tools were called and their results for (const msg of claudeMessages) { if (msg.role === 'user') { if (Array.isArray(msg.content)) { // Convert tool_result blocks to text summaries const textParts = []; for (const block of msg.content) { if (block.type === 'text') { textParts.push(block.text); } else if (block.type === 'tool_result') { // Convert tool result to readable text so model knows it was executed textParts.push(`[Tool Result: ${block.content}]`); } } if (textParts.length > 0) { cfMessages.push({ role: "user", content: textParts.join('\n') }); } } else { cfMessages.push({ role: "user", content: msg.content }); } } else if (msg.role === 'assistant') { // For assistant messages, convert tool_use to text descriptions const textParts = []; if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'text') { textParts.push(block.text); } else if (block.type === 'tool_use') { // Convert tool call to readable text so model knows it called this const argsStr = JSON.stringify(block.input || {}); textParts.push(`[Called tool: ${block.name}(${argsStr})]`); } } } else { textParts.push(msg.content || ''); } // Also handle pre-converted messages that might have tool_calls property if (msg.tool_calls && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { const name = tc.function?.name || tc.name || 'unknown'; const args = tc.function?.arguments || tc.arguments || '{}'; textParts.push(`[Called tool: ${name}(${typeof args === 'string' ? args : JSON.stringify(args)})]`); } } const textContent = textParts.filter(t => t).join('\n'); if (textContent) { cfMessages.push({ role: "assistant", content: textContent }); } } else if (msg.role === 'tool') { // Convert tool messages to user messages with result text cfMessages.push({ role: "user", content: `[Tool Result (${msg.name || 'unknown'}): ${msg.content}]` }); } } return cfMessages; } /** * Try to repair and parse a potentially truncated JSON object * @param {string} jsonStr - Potentially incomplete JSON string * @returns {object|null} - Parsed object or null if unparseable */ function tryRepairAndParse(jsonStr) { // First try as-is try { return JSON.parse(jsonStr); } catch (e) { // Try adding closing brackets const repairs = [ jsonStr + '}', jsonStr + '"}', jsonStr + '}}', jsonStr + '"}}', jsonStr + ': null}}', jsonStr + '": null}}' ]; for (const attempt of repairs) { try { const parsed = JSON.parse(attempt); if (parsed.name) { // Must have a name to be valid return parsed; } } catch (e2) { // Continue trying } } return null; } } /** * Parse tool calls from text response * Handles multiple formats: * 1. Mistral native: [TOOL_CALLS][{"name": "...", "arguments": {...}}, ...] * 2. History format: [Called tool: name({args})] * * This parser is resilient to truncation - it will extract as many valid tool calls * as possible even if the JSON is incomplete. * * @param {string} text - Text response that may contain embedded tool calls * @returns {object} - { cleanText: string, toolCalls: array } */ function parseTextToolCalls(text) { if (!text) return { cleanText: '', toolCalls: [] }; const toolCalls = []; let cleanText = text; // Format 1: [TOOL_CALLS][...] (Mistral native format) const toolCallMatch = text.match(/\[TOOL_CALLS\]\s*(\[[\s\S]*)/); if (toolCallMatch) { const toolCallsJson = toolCallMatch[1]; // First try normal JSON.parse (for complete responses) try { const parsedCalls = JSON.parse(toolCallsJson); if (Array.isArray(parsedCalls)) { const validCalls = parsedCalls .filter(call => call && call.name) .map(call => ({ name: call.name, arguments: call.arguments || {} })); console.log(`[ToolConverter] Parsed ${validCalls.length} tool calls from [TOOL_CALLS] JSON`); cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim(); return { cleanText, toolCalls: validCalls }; } } catch (e) { console.log('[ToolConverter] [TOOL_CALLS] JSON incomplete, attempting to extract individual tool calls...'); } // JSON is truncated - extract individual tool calls using regex const toolCallStarts = []; const startPattern = /\{"name"\s*:\s*"/g; let match; while ((match = startPattern.exec(toolCallsJson)) !== null) { toolCallStarts.push(match.index); } console.log(`[ToolConverter] Found ${toolCallStarts.length} potential tool call starts in [TOOL_CALLS]`); for (let i = 0; i < toolCallStarts.length; i++) { const start = toolCallStarts[i]; const end = toolCallStarts[i + 1] || toolCallsJson.length; let segment = toolCallsJson.substring(start, end).replace(/,\s*$/, ''); const parsed = tryRepairAndParse(segment); if (parsed && parsed.name) { toolCalls.push({ name: parsed.name, arguments: parsed.arguments || {} }); console.log(`[ToolConverter] Extracted tool call from [TOOL_CALLS]: ${parsed.name}`); } } cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim(); if (toolCalls.length > 0) { console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [TOOL_CALLS] format`); return { cleanText, toolCalls }; } } // Format 2: [Called tool: name({args})] (history format the model might mimic) // Match patterns like: [Called tool: create_entity({"shape": "box", ...})] const calledToolPattern = /\[Called tool:\s*(\w+)\((\{[\s\S]*?\})\)\]/g; let calledMatch; const calledToolMatches = []; while ((calledMatch = calledToolPattern.exec(text)) !== null) { calledToolMatches.push({ fullMatch: calledMatch[0], name: calledMatch[1], argsStr: calledMatch[2] }); } if (calledToolMatches.length > 0) { console.log(`[ToolConverter] Found ${calledToolMatches.length} [Called tool:] format tool calls`); for (const match of calledToolMatches) { try { const args = JSON.parse(match.argsStr); toolCalls.push({ name: match.name, arguments: args }); console.log(`[ToolConverter] Extracted tool call from [Called tool:]: ${match.name}`); // Remove this match from clean text cleanText = cleanText.replace(match.fullMatch, ''); } catch (e) { console.warn(`[ToolConverter] Failed to parse [Called tool:] args for ${match.name}:`, e.message); // Try to repair the JSON const repaired = tryRepairAndParse(match.argsStr); if (repaired) { toolCalls.push({ name: match.name, arguments: repaired }); console.log(`[ToolConverter] Repaired and extracted tool call: ${match.name}`); cleanText = cleanText.replace(match.fullMatch, ''); } } } cleanText = cleanText.trim(); if (toolCalls.length > 0) { console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [Called tool:] format`); return { cleanText, toolCalls }; } } // No tool calls found return { cleanText: text, toolCalls: [] }; } /** * Convert Cloudflare response to Claude format * * Cloudflare response format: * { * result: { * response: "text output", * tool_calls: [{ name: "...", arguments: {...} }] * }, * success: true * } * * Note: Some models (like Mistral) output tool calls as text in format: * [TOOL_CALLS][{...}] * * @param {object} cfResponse - Response from Cloudflare Workers AI API * @param {string} model - Model name used * @returns {object} Response in Claude format */ export function cloudflareResponseToClaude(cfResponse, model) { const content = []; const result = cfResponse.result || cfResponse; // Get tool calls from proper field or parse from text let toolCalls = result.tool_calls || []; let textResponse = result.response || ''; // Log raw response for debugging console.log(`[ToolConverter] Raw response (first 500 chars): ${textResponse.substring(0, 500)}`); console.log(`[ToolConverter] Native tool_calls present: ${toolCalls.length}`); // Check if tool calls are embedded in text response (Mistral format or history format) if (toolCalls.length === 0 && textResponse) { console.log(`[ToolConverter] No native tool_calls, parsing text response...`); const parsed = parseTextToolCalls(textResponse); console.log(`[ToolConverter] Parsed ${parsed.toolCalls.length} tool calls from text`); if (parsed.toolCalls.length > 0) { toolCalls = parsed.toolCalls; textResponse = parsed.cleanText; } } // Add text content if present (after removing tool calls) if (textResponse) { content.push({ type: "text", text: textResponse }); } // Add tool calls if present if (toolCalls.length > 0) { for (let i = 0; i < toolCalls.length; i++) { content.push(cloudflareToolCallToClaude(toolCalls[i], i)); } } // Determine stop reason let stopReason = "end_turn"; if (toolCalls.length > 0) { stopReason = "tool_use"; } // Extract usage if available const usage = { input_tokens: result.usage?.prompt_tokens || result.usage?.input_tokens || 0, output_tokens: result.usage?.completion_tokens || result.usage?.output_tokens || 0 }; return { id: `msg_cf_${Date.now()}`, type: "message", role: "assistant", content: content, model: model, stop_reason: stopReason, usage: usage }; } export default { claudeToolToOllama, claudeToolsToOllama, ollamaToolCallToClaude, claudeToolResultToOllama, claudeMessagesToOllama, ollamaResponseToClaude, // Cloudflare converters claudeToolToCloudflare, claudeToolsToCloudflare, cloudflareToolCallToClaude, claudeMessagesToCloudflare, cloudflareResponseToClaude };