/* * ============================================================================= * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform * ----------------------------------------------------------------------------- * File: server/services/openaiService.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 { AVAILABLE_TOOLS } from './claude/toolDefinitions'; import { buildSystemPrompt } from './claude/systemPrompt'; import { estimateMessagesTokens, truncateToolResults, summarizeHistory } from './claude/tokenManagement'; // Using HTTP direct calls to OpenAI API (same pattern as claudeService). // Gemini models are served through Google's OpenAI-compatibility layer, // so both providers share this service. const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'; const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'; const GEMINI_API_KEY = process.env.GEMINI_API_KEY; function getProvider(model: string): { url: string; key: string | undefined; name: 'openai' | 'gemini' } { if (model.startsWith('gemini-')) return { url: GEMINI_API_URL, key: GEMINI_API_KEY, name: 'gemini' }; return { url: OPENAI_API_URL, key: OPENAI_API_KEY, name: 'openai' }; } // Gemini 3 stamps tool calls with an encrypted thought_signature that MUST be // echoed back on the next turn of a function-calling loop (400 otherwise). // routes.ts only carries id/name/input through its message history, so we // remember signatures here, keyed by tool-call id. const thoughtSignatures = new Map(); const MAX_SIGNATURES = 2000; function rememberSignature(id: string, sig: string) { if (thoughtSignatures.size >= MAX_SIGNATURES) { const oldest = thoughtSignatures.keys().next().value; if (oldest !== undefined) thoughtSignatures.delete(oldest); } thoughtSignatures.set(id, sig); } // USD per 1M tokens (input/output) + max completion tokens per model const OPENAI_MODELS: Record = { 'gpt-5.6-sol': { input: 5, output: 30, maxOut: 64000 }, 'gpt-5.6-terra': { input: 2.5, output: 15, maxOut: 64000 }, 'gpt-5.6-luna': { input: 1, output: 6, maxOut: 64000 }, 'gpt-5.5': { input: 5, output: 30, maxOut: 64000 }, 'gpt-5.4': { input: 2.5, output: 15, maxOut: 64000 }, 'gpt-5.2': { input: 1.75, output: 14, maxOut: 64000 }, 'gpt-5.1': { input: 1.25, output: 10, maxOut: 64000 }, 'gpt-5': { input: 1.25, output: 10, maxOut: 64000 }, 'gpt-5-mini': { input: 0.25, output: 2, maxOut: 64000 }, 'gpt-5-nano': { input: 0.05, output: 0.4, maxOut: 64000 }, 'gpt-4.1': { input: 2, output: 8, maxOut: 32000 }, 'gpt-4.1-mini': { input: 0.4, output: 1.6, maxOut: 32000 }, 'gemini-3.5-flash': { input: 1.5, output: 9, maxOut: 60000 }, 'gemini-3.1-pro-preview': { input: 2, output: 12, maxOut: 60000 }, 'gemini-3-flash-preview': { input: 0.5, output: 3, maxOut: 60000 }, 'gemini-3.1-flash-lite': { input: 0.25, output: 1.5, maxOut: 60000 }, }; const OPENAI_PRICING = OPENAI_MODELS; // Anthropic tool definitions -> OpenAI function tools. // OpenAI caps `tools` at 128 (vquant defines 237); AVAILABLE_TOOLS is ordered // core-first, so keep the first 128 — they cover every tool the system prompt names. const MAX_OPENAI_TOOLS = 128; const OPENAI_TOOLS = AVAILABLE_TOOLS.slice(0, MAX_OPENAI_TOOLS).map((t: any) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.input_schema, }, })); // Convert Anthropic-format messages (tool_use / tool_result / image blocks, // as built by routes.ts) into OpenAI chat format. function convertMessages(messages: any[]): any[] { const out: any[] = []; for (const msg of messages) { if (typeof msg.content === 'string') { out.push({ role: msg.role, content: msg.content }); continue; } if (!Array.isArray(msg.content)) { out.push(msg); continue; } if (msg.role === 'assistant') { const text = msg.content .filter((b: any) => b.type === 'text') .map((b: any) => b.text) .join(''); const toolUses = msg.content.filter((b: any) => b.type === 'tool_use'); const m: any = { role: 'assistant', content: text || null }; if (toolUses.length > 0) { m.tool_calls = toolUses.map((tu: any) => { const call: any = { id: tu.id, type: 'function', function: { name: tu.name, arguments: JSON.stringify(tu.input ?? {}) }, }; const sig = thoughtSignatures.get(tu.id); if (sig) call.extra_content = { google: { thought_signature: sig } }; return call; }); } out.push(m); } else { // user message: tool_result blocks become role:"tool" messages for (const block of msg.content) { if (block.type === 'tool_result') { out.push({ role: 'tool', tool_call_id: block.tool_use_id, content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content), }); } } const rest = msg.content.filter((b: any) => b.type !== 'tool_result'); if (rest.length > 0) { const parts = rest .map((b: any) => { if (b.type === 'text') return { type: 'text', text: b.text }; if (b.type === 'image' && b.source?.type === 'base64') { return { type: 'image_url', image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` }, }; } return null; }) .filter(Boolean); if (parts.length > 0) out.push({ role: 'user', content: parts }); } } } return out; } export async function callOpenAIStreaming( query: string, onChunk: (text: string) => void, messages: any[], model: string = 'gpt-5.5', onThinking?: (text: string) => void ): Promise<{ answer: string; toolCalls: any[]; usage: { inputTokens: number; outputTokens: number; cost: number } }> { try { // Same context-management pipeline as claudeService let processedMessages = truncateToolResults(messages, 5000); const estimatedTokens = estimateMessagesTokens(processedMessages); const TOKEN_LIMIT = 100000; if (estimatedTokens > TOKEN_LIMIT) { console.log(`Messages exceed token limit (${estimatedTokens} tokens). Summarizing old history...`); const recentMessages = processedMessages.slice(-5); const oldMessages = processedMessages.slice(0, -5); if (oldMessages.length > 0) { // summarizeHistory runs on the Anthropic API — use its default Claude model const summary = await summarizeHistory(oldMessages); processedMessages = [ { role: 'user', content: `[Previous conversation summary]: ${summary}` }, ...recentMessages ]; console.log(`History summarized. New token count: ${estimateMessagesTokens(processedMessages)}`); } } 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]; const provider = getProvider(model); const openaiRequestBody: any = { model, // gpt-5.x / gemini-3.x are reasoning models: max_completion_tokens (not // max_tokens), temperature left at default (any other value is rejected) max_completion_tokens: (OPENAI_MODELS[model] || OPENAI_MODELS['gpt-5.5']).maxOut, // gpt-5.6 on /v1/chat/completions rejects function tools unless // reasoning_effort is 'none' (tools+reasoning need /v1/responses) ...(model.startsWith('gpt-5.6') ? { reasoning_effort: 'none' } : {}), stream: true, // include_obfuscation:false intermittently 401s ("insufficient // permissions") on gpt-5.6-* — omit the flag for those models stream_options: provider.name === 'openai' && !model.startsWith('gpt-5.6') ? { include_usage: true, include_obfuscation: false } : { include_usage: true }, messages: [ { role: 'system', content: buildSystemPrompt(currentDateTime, isoDate) }, ...convertMessages(processedMessages), ], tools: OPENAI_TOOLS, }; const openaiRequestConfig: any = { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.key!}`, }, responseType: 'stream', }; // gpt-5.6 intermittently 401s ("insufficient permissions") server-side; // the error arrives before any stream bytes, so retrying here is safe let response: any; for (let attempt = 1; ; attempt++) { try { response = await axios.post(provider.url, openaiRequestBody, openaiRequestConfig); break; } catch (err: any) { const status = err.response?.status; if (attempt < 4 && (status === 401 || status === 429 || (status >= 500 && status < 600))) { console.error(`OpenAI transient ${status}, retrying (${attempt}/3)`); await new Promise((r) => setTimeout(r, 500 * attempt)); continue; } throw err; } } let fullAnswer = ''; const toolCallsByIndex: Record = {}; const idxByCallId: Record = {}; let nextAutoIdx = 0; let lastIdx = 0; let buffer = ''; let inputTokens = 0; let outputTokens = 0; let settled = false; const pricing = OPENAI_PRICING[model] || OPENAI_PRICING['gpt-5.5']; const calculateCost = (input: number, output: number): number => { return (input * pricing.input / 1_000_000) + (output * pricing.output / 1_000_000); }; const finalizeToolCalls = () => Object.keys(toolCallsByIndex) .sort((a, b) => Number(a) - Number(b)) .map((k) => { const tc = toolCallsByIndex[Number(k)]; let input: any = {}; try { input = tc.arguments ? JSON.parse(tc.arguments) : {}; } catch (e) { console.error(`Failed to parse tool arguments for ${tc.name}:`, tc.arguments); } return { id: tc.id, name: tc.name, input }; }); return new Promise((resolve, reject) => { const finish = () => { if (settled) return; settled = true; // Streaming output_tokens can be missing — fall back to a local count try { const localTokenCount = encode(fullAnswer).length; outputTokens = Math.max(outputTokens, localTokenCount); } catch {} resolve({ answer: fullAnswer, toolCalls: finalizeToolCalls(), usage: { inputTokens, outputTokens, cost: calculateCost(inputTokens, outputTokens) } }); }; 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: ')) continue; const data = line.substring(6).trim(); if (!data) continue; if (data === '[DONE]') { finish(); continue; } try { const event = JSON.parse(data); if (event.usage) { inputTokens = event.usage.prompt_tokens || inputTokens; outputTokens = event.usage.completion_tokens || outputTokens; } const delta = event.choices?.[0]?.delta; if (!delta) continue; if (delta.content) { fullAnswer += delta.content; onChunk(delta.content); } if (delta.tool_calls) { for (const tc of delta.tool_calls) { // OpenAI always sends an index; Gemini's compat layer omits it // and sends each complete call with its own id instead. let idx: number; if (typeof tc.index === 'number') { idx = tc.index; } else if (tc.id && idxByCallId[tc.id] !== undefined) { idx = idxByCallId[tc.id]; } else if (tc.id) { idx = nextAutoIdx++; idxByCallId[tc.id] = idx; } else { idx = lastIdx; } lastIdx = idx; if (!toolCallsByIndex[idx]) { toolCallsByIndex[idx] = { id: tc.id || '', name: '', arguments: '' }; } if (tc.id) toolCallsByIndex[idx].id = tc.id; if (tc.function?.name) toolCallsByIndex[idx].name = tc.function.name; if (tc.function?.arguments) toolCallsByIndex[idx].arguments += tc.function.arguments; // Gemini thought signature: must be echoed on the next turn const sig = tc.extra_content?.google?.thought_signature; if (sig && toolCallsByIndex[idx].id) rememberSignature(toolCallsByIndex[idx].id, sig); } } } catch (e) { console.error('Error parsing OpenAI SSE event:', e); } } }); response.data.on('end', finish); response.data.on('error', (error: Error) => { console.error('OpenAI stream error:', error); if (!settled) { settled = true; reject(error); } }); }); } catch (error: any) { const detail = error.response?.data ? await streamErrorBody(error.response.data).catch(() => '') : ''; console.error('OpenAI API error:', error.message, detail); throw new Error('Failed to call OpenAI API: ' + (detail || error.message)); } } // axios with responseType:'stream' delivers error bodies as streams too async function streamErrorBody(data: any): Promise { if (!data || typeof data.on !== 'function') return typeof data === 'string' ? data : JSON.stringify(data); const chunks: Buffer[] = []; for await (const c of data) chunks.push(Buffer.from(c)); return Buffer.concat(chunks).toString(); }