- 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>
214 lines
8.7 KiB
JavaScript
214 lines
8.7 KiB
JavaScript
import { Router } from "express";
|
|
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
|
|
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
|
|
import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js";
|
|
import {
|
|
claudeToolsToCloudflare,
|
|
claudeMessagesToCloudflare,
|
|
cloudflareResponseToClaude
|
|
} from "../services/toolConverter.js";
|
|
|
|
const router = Router();
|
|
|
|
/**
|
|
* Build entity context string for the system prompt
|
|
*/
|
|
function buildEntityContext(entities) {
|
|
if (!entities || entities.length === 0) {
|
|
return "\n\nThe diagram is currently empty.";
|
|
}
|
|
|
|
const entityList = entities.map(e => {
|
|
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
|
|
const pos = e.position || { x: 0, y: 0, z: 0 };
|
|
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
|
|
}).join('\n');
|
|
|
|
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
|
|
}
|
|
|
|
// Express 5 uses named parameters for wildcards
|
|
router.post("/*path", async (req, res) => {
|
|
const requestStart = Date.now();
|
|
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
|
|
|
|
const accountId = getCloudflareAccountId();
|
|
const apiToken = getCloudflareApiToken();
|
|
|
|
if (!accountId) {
|
|
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
|
|
return res.status(500).json({ error: "Cloudflare account ID not configured" });
|
|
}
|
|
|
|
if (!apiToken) {
|
|
console.error(`[Cloudflare API] ERROR: API token not configured`);
|
|
return res.status(500).json({ error: "Cloudflare API token not configured" });
|
|
}
|
|
|
|
// Check for session-based request
|
|
const { sessionId, ...requestBody } = req.body;
|
|
let modifiedBody = { ...requestBody };
|
|
const model = requestBody.model;
|
|
|
|
console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`);
|
|
console.log(`[Cloudflare API] Model: ${model}`);
|
|
console.log(`[Cloudflare API] Messages count: ${requestBody.messages?.length || 0}`);
|
|
|
|
// Build system prompt with entity context
|
|
let systemPrompt = modifiedBody.system || '';
|
|
|
|
if (sessionId) {
|
|
const session = getSession(sessionId);
|
|
if (session) {
|
|
console.log(`[Cloudflare API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
|
|
|
|
// Inject entity context into system prompt
|
|
const entityContext = buildEntityContext(session.entities);
|
|
console.log(`[Cloudflare API] Entity context added (${entityContext.length} chars)`);
|
|
systemPrompt += entityContext;
|
|
|
|
// Get conversation history and merge with current messages
|
|
const historyMessages = getConversationForAPI(sessionId);
|
|
if (historyMessages.length > 0 && modifiedBody.messages) {
|
|
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
|
|
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
|
|
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
|
|
console.log(`[Cloudflare API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
|
|
}
|
|
} else {
|
|
console.log(`[Cloudflare API] WARNING: Session ${sessionId} not found`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Convert to Cloudflare format
|
|
const cfMessages = claudeMessagesToCloudflare(modifiedBody.messages || [], systemPrompt);
|
|
const cfTools = modifiedBody.tools ? claudeToolsToCloudflare(modifiedBody.tools) : undefined;
|
|
|
|
// Build Cloudflare request body
|
|
const cfRequestBody = {
|
|
messages: cfMessages,
|
|
max_tokens: modifiedBody.max_tokens || 1024
|
|
};
|
|
|
|
// Only include tools if the model supports them
|
|
if (cfTools && cfTools.length > 0) {
|
|
cfRequestBody.tools = cfTools;
|
|
}
|
|
|
|
// Cloudflare endpoint: https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}
|
|
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
|
|
|
|
console.log(`[Cloudflare API] Sending request to: ${endpoint}`);
|
|
console.log(`[Cloudflare API] Request body messages: ${cfMessages.length}, tools: ${cfTools?.length || 0}`);
|
|
const requestBodyJson = JSON.stringify(cfRequestBody);
|
|
console.log(`[Cloudflare API] Full request body (${requestBodyJson.length} bytes):`);
|
|
console.log(requestBodyJson);
|
|
const fetchStart = Date.now();
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${apiToken}`,
|
|
},
|
|
body: JSON.stringify(cfRequestBody),
|
|
});
|
|
|
|
const fetchDuration = Date.now() - fetchStart;
|
|
console.log(`[Cloudflare API] Response received in ${fetchDuration}ms, status: ${response.status}`);
|
|
|
|
console.log(`[Cloudflare API] Parsing response JSON...`);
|
|
const cfData = await response.json();
|
|
|
|
if (!cfData.success) {
|
|
console.error(`[Cloudflare API] API returned error:`, cfData.errors);
|
|
return res.status(response.status).json({
|
|
error: cfData.errors?.[0]?.message || "Cloudflare API error",
|
|
details: cfData.errors
|
|
});
|
|
}
|
|
|
|
// Convert Cloudflare response to Claude format
|
|
const data = cloudflareResponseToClaude(cfData, model);
|
|
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
|
|
|
|
// Track and log token usage
|
|
if (data.usage) {
|
|
// Extract content for detailed tracking
|
|
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
|
|
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
|
|
|
|
const outputText = data.content
|
|
?.filter(c => c.type === 'text')
|
|
.map(c => c.text)
|
|
.join('\n') || null;
|
|
|
|
const toolCalls = data.content
|
|
?.filter(c => c.type === 'tool_use')
|
|
.map(c => ({ name: c.name, input: c.input })) || [];
|
|
|
|
const usageRecord = trackUsage(sessionId, model, data.usage, {
|
|
inputText,
|
|
outputText,
|
|
toolCalls
|
|
});
|
|
console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
|
|
|
|
// Log cumulative session usage if session exists
|
|
if (sessionId) {
|
|
const sessionStats = getSessionUsage(sessionId);
|
|
if (sessionStats) {
|
|
console.log(`[Cloudflare API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
|
|
console.log(`[Cloudflare API] Total input: ${sessionStats.totalInputTokens} tokens`);
|
|
console.log(`[Cloudflare API] Total output: ${sessionStats.totalOutputTokens} tokens`);
|
|
console.log(`[Cloudflare API] Total cost: ${formatCost(sessionStats.totalCost)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If session exists and response is successful, store messages
|
|
if (sessionId && response.ok && data.content) {
|
|
const session = getSession(sessionId);
|
|
if (session) {
|
|
// Store the user message if it was new (only if it's a string, not tool results)
|
|
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
|
|
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
|
|
addMessage(sessionId, {
|
|
role: 'user',
|
|
content: userMessage.content
|
|
});
|
|
console.log(`[Cloudflare API] Stored user message to session`);
|
|
}
|
|
|
|
// Store the assistant response (text only, not tool use blocks)
|
|
const assistantContent = data.content
|
|
.filter(c => c.type === 'text')
|
|
.map(c => c.text)
|
|
.join('\n');
|
|
|
|
if (assistantContent) {
|
|
addMessage(sessionId, {
|
|
role: 'assistant',
|
|
content: assistantContent
|
|
});
|
|
console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalDuration = Date.now() - requestStart;
|
|
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
|
|
res.status(response.status).json(data);
|
|
} catch (error) {
|
|
const totalDuration = Date.now() - requestStart;
|
|
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
|
|
console.error(`[Cloudflare API] Error:`, error);
|
|
console.error(`[Cloudflare API] Error message:`, error.message);
|
|
console.error(`[Cloudflare API] Error stack:`, error.stack);
|
|
res.status(500).json({ error: "Failed to proxy request to Cloudflare API", details: error.message });
|
|
}
|
|
});
|
|
|
|
export default router;
|