/* * ============================================================================= * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform * ----------------------------------------------------------------------------- * File: server/services/claudeService.ts * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Website: https://www.spboucher.ai * Demo: https://www.vquant.ai * License: MIT (see LICENSE) * * Copyright © 2026 Simon-Pierre Boucher. All rights reserved. * ============================================================================= */ import axios from 'axios'; import { encode } from 'gpt-tokenizer'; import type { SearchResult } from '@shared/types'; import { AVAILABLE_TOOLS } from './claude/toolDefinitions'; import { estimateTokens, estimateMessagesTokens, truncateToolResults, summarizeHistory, calculateCost } from './claude/tokenManagement'; import { buildSystemPrompt } from './claude/systemPrompt'; // Using HTTP direct calls to Anthropic API (not SDK) as requested const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ANTHROPIC_VERSION = '2023-06-01'; const DEFAULT_MODEL = 'claude-fable-5'; const ADAPTIVE_THINKING_MODELS = ['claude-fable-5', 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-sonnet-4-6']; function supportsAdaptiveThinking(model: string): boolean { return ADAPTIVE_THINKING_MODELS.some((m) => model.startsWith(m)); } interface ClaudeStreamEvent { type: string; message?: any; delta?: any; content_block?: any; index?: number; } export async function callClaudeStreaming( query: string, onChunk: (text: string) => void, messages: any[], model: string = DEFAULT_MODEL, onThinking?: (text: string) => void ): Promise<{ answer: string; toolCalls: any[]; usage: { inputTokens: number; outputTokens: number; cost: number } }> { try { // Truncate large tool results first let processedMessages = truncateToolResults(messages, 5000); // Check if messages exceed token limit (using 100K as safe threshold for 200K context) const estimatedTokens = estimateMessagesTokens(processedMessages); const TOKEN_LIMIT = 100000; // More conservative limit if (estimatedTokens > TOKEN_LIMIT) { console.log(`Messages exceed token limit (${estimatedTokens} tokens). Summarizing old history...`); // Keep only the last 5 messages and summarize the rest const recentMessages = processedMessages.slice(-5); const oldMessages = processedMessages.slice(0, -5); if (oldMessages.length > 0) { const summary = await summarizeHistory(oldMessages, model); // Create new message array with summary + recent messages processedMessages = [ { role: 'user', content: `[Previous conversation summary]: ${summary}` }, ...recentMessages ]; console.log(`History summarized. New token count: ${estimateMessagesTokens(processedMessages)}`); } } // Get current date and time const now = new Date(); const currentDateTime = now.toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' }); const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD format const response = await axios.post( ANTHROPIC_API_URL, { model, max_tokens: 64000, temperature: 1, ...(supportsAdaptiveThinking(model) ? { thinking: { type: 'adaptive', display: 'summarized' } } : {}), stream: true, system: buildSystemPrompt(currentDateTime, isoDate), messages: processedMessages, tools: AVAILABLE_TOOLS, }, { headers: { 'Content-Type': 'application/json', 'x-api-key': ANTHROPIC_API_KEY!, 'anthropic-version': ANTHROPIC_VERSION, }, responseType: 'stream', } ); let fullAnswer = ''; let toolCalls: any[] = []; let buffer = ''; let inputTokens = 0; let outputTokens = 0; // Calculate cost: $3/million for input, $15/million for output const calculateCost = (input: number, output: number): number => { return (input * 3 / 1_000_000) + (output * 15 / 1_000_000); }; return new Promise((resolve, reject) => { response.data.on('data', (chunk: Buffer) => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.substring(6).trim(); if (data === '[DONE]' || !data) continue; try { const event: ClaudeStreamEvent = JSON.parse(data); // Log ALL events for debugging if (event.type === 'message_start' || event.type === 'message_delta' || event.type === 'message_stop') { console.log(`[TOKEN DEBUG] Event type: ${event.type}`, JSON.stringify(event, null, 2).substring(0, 500)); } // Capture token usage from message_start event if (event.type === 'message_start') { if (event.message?.usage) { inputTokens = event.message.usage.input_tokens || 0; outputTokens = event.message.usage.output_tokens || 0; console.log(`[TOKEN DEBUG] message_start: in=${inputTokens}, out=${outputTokens}`); } } // Update token usage from message_delta event if (event.type === 'message_delta') { console.log(`[TOKEN DEBUG] message_delta full event:`, JSON.stringify(event)); if (event.delta?.usage) { const oldOutputTokens = outputTokens; outputTokens = event.delta.usage.output_tokens || outputTokens; console.log(`[TOKEN DEBUG] message_delta: output ${oldOutputTokens} → ${outputTokens}`); } else { console.log(`[TOKEN DEBUG] message_delta: NO usage in delta - event.delta =`, event.delta); } } // Check message_stop for usage too if (event.type === 'message_stop') { console.log(`[TOKEN DEBUG] message_stop: in=${inputTokens}, out=${outputTokens}`); console.log(`[TOKEN DEBUG] fullAnswer length: ${fullAnswer.length} chars`); // FIX: Count tokens locally since streaming API doesn't return accurate output_tokens try { const localTokenCount = encode(fullAnswer).length; console.log(`[TOKEN FIX] API reported: ${outputTokens}, Local count: ${localTokenCount}`); // Use the maximum to ensure accuracy const finalOutputTokens = Math.max(outputTokens, localTokenCount); outputTokens = finalOutputTokens; console.log(`[TOKEN FIX] Using corrected output tokens: ${finalOutputTokens}`); } catch (encodeError) { console.error(`[TOKEN FIX] Error counting tokens locally:`, encodeError); // Fallback to API value if encoding fails } } if (event.type === 'content_block_start') { if (event.content_block?.type === 'tool_use') { toolCalls.push({ id: event.content_block.id, name: event.content_block.name, input: {}, }); } } if (event.type === 'content_block_delta') { if (event.delta?.type === 'text_delta') { const text = event.delta.text; fullAnswer += text; onChunk(text); } if (event.delta?.type === 'thinking_delta') { if (onThinking) onThinking(event.delta.thinking); } if (event.delta?.type === 'input_json_delta') { // Accumulate tool input const lastTool = toolCalls[toolCalls.length - 1]; if (lastTool) { lastTool.partial_json = (lastTool.partial_json || '') + event.delta.partial_json; } } } if (event.type === 'content_block_stop') { // Finalize tool input const lastTool = toolCalls[toolCalls.length - 1]; if (lastTool && lastTool.partial_json) { lastTool.input = JSON.parse(lastTool.partial_json); delete lastTool.partial_json; } } if (event.type === 'message_stop') { const cost = calculateCost(inputTokens, outputTokens); resolve({ answer: fullAnswer, toolCalls, usage: { inputTokens, outputTokens, cost } }); } } catch (e) { console.error('Error parsing SSE event:', e); } } } }); response.data.on('end', () => { const cost = calculateCost(inputTokens, outputTokens); resolve({ answer: fullAnswer, toolCalls, usage: { inputTokens, outputTokens, cost } }); }); response.data.on('error', (error: Error) => { console.error('Stream error:', error); reject(error); }); }); } catch (error) { console.error('Claude API error:', error); throw new Error('Failed to call Claude API: ' + (error as Error).message); } }