immersive2/server/services/toolConverter.js
Michael Mainguy 03217f3e65 Add Cloudflare Workers AI provider and multiple AI chat improvements
- Add Cloudflare Workers AI as third provider alongside Claude and Ollama
  - New cloudflare.js API handler with format conversion
  - Tool converter functions for Cloudflare's OpenAI-compatible format
  - Handle [TOOL_CALLS] and [Called tool:] text formats from Mistral
  - Robust parser that handles truncated JSON responses

- Add usage tracking with cost display
  - New usageTracker.js service for tracking token usage per session
  - UsageDetailModal component showing per-request breakdown
  - Cost display in ChatPanel header

- Add new diagram manipulation features
  - Entity scale and rotation support via modify_entity tool
  - Wikipedia search tool for researching topics before diagramming
  - Clear conversation tool to reset chat history
  - JSON import from hamburger menu (moved from ChatPanel)

- Fix connection label rotation in billboard mode
  - Labels no longer have conflicting local rotation when billboard enabled
  - Update rotation when rendering mode changes

- Improve tool calling reliability
  - Add MAX_TOOL_ITERATIONS safety limit
  - Break loop after model switch to prevent context issues
  - Increase max_tokens to 4096 to prevent truncation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 06:31:43 -06:00

664 lines
22 KiB
JavaScript

/**
* 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
};