spb/vquant Public MIT
VibeQuant — AI-powered institutional-grade financial intelligence platform.
TypeScript 84.3%
Python 11.7%
JavaScript 1.6%
CSS 1.5%
HTML 0.7%
1/*2 * =============================================================================3 * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform4 * -----------------------------------------------------------------------------5 * File: server/services/openaiService.ts6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 * Website: https://www.spboucher.ai10 * Demo: https://www.vquant.ai11 * License: MIT (see LICENSE)12 *13 * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.14 * =============================================================================15 */1617import axios from 'axios';18import { encode } from 'gpt-tokenizer';19import { AVAILABLE_TOOLS } from './claude/toolDefinitions';20import { buildSystemPrompt } from './claude/systemPrompt';21import {22 estimateMessagesTokens,23 truncateToolResults,24 summarizeHistory25} from './claude/tokenManagement';2627// Using HTTP direct calls to OpenAI API (same pattern as claudeService).28// Gemini models are served through Google's OpenAI-compatibility layer,29// so both providers share this service.30const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';31const OPENAI_API_KEY = process.env.OPENAI_API_KEY;32const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions';33const GEMINI_API_KEY = process.env.GEMINI_API_KEY;3435function getProvider(model: string): { url: string; key: string | undefined; name: 'openai' | 'gemini' } {36 if (model.startsWith('gemini-')) return { url: GEMINI_API_URL, key: GEMINI_API_KEY, name: 'gemini' };37 return { url: OPENAI_API_URL, key: OPENAI_API_KEY, name: 'openai' };38}3940// Gemini 3 stamps tool calls with an encrypted thought_signature that MUST be41// echoed back on the next turn of a function-calling loop (400 otherwise).42// routes.ts only carries id/name/input through its message history, so we43// remember signatures here, keyed by tool-call id.44const thoughtSignatures = new Map<string, string>();45const MAX_SIGNATURES = 2000;46function rememberSignature(id: string, sig: string) {47 if (thoughtSignatures.size >= MAX_SIGNATURES) {48 const oldest = thoughtSignatures.keys().next().value;49 if (oldest !== undefined) thoughtSignatures.delete(oldest);50 }51 thoughtSignatures.set(id, sig);52}5354// USD per 1M tokens (input/output) + max completion tokens per model55const OPENAI_MODELS: Record<string, { input: number; output: number; maxOut: number }> = {56 'gpt-5.6-sol': { input: 5, output: 30, maxOut: 64000 },57 'gpt-5.6-terra': { input: 2.5, output: 15, maxOut: 64000 },58 'gpt-5.6-luna': { input: 1, output: 6, maxOut: 64000 },59 'gpt-5.5': { input: 5, output: 30, maxOut: 64000 },60 'gpt-5.4': { input: 2.5, output: 15, maxOut: 64000 },61 'gpt-5.2': { input: 1.75, output: 14, maxOut: 64000 },62 'gpt-5.1': { input: 1.25, output: 10, maxOut: 64000 },63 'gpt-5': { input: 1.25, output: 10, maxOut: 64000 },64 'gpt-5-mini': { input: 0.25, output: 2, maxOut: 64000 },65 'gpt-5-nano': { input: 0.05, output: 0.4, maxOut: 64000 },66 'gpt-4.1': { input: 2, output: 8, maxOut: 32000 },67 'gpt-4.1-mini': { input: 0.4, output: 1.6, maxOut: 32000 },68 'gemini-3.5-flash': { input: 1.5, output: 9, maxOut: 60000 },69 'gemini-3.1-pro-preview': { input: 2, output: 12, maxOut: 60000 },70 'gemini-3-flash-preview': { input: 0.5, output: 3, maxOut: 60000 },71 'gemini-3.1-flash-lite': { input: 0.25, output: 1.5, maxOut: 60000 },72};73const OPENAI_PRICING = OPENAI_MODELS;7475// Anthropic tool definitions -> OpenAI function tools.76// OpenAI caps `tools` at 128 (vquant defines 237); AVAILABLE_TOOLS is ordered77// core-first, so keep the first 128 — they cover every tool the system prompt names.78const MAX_OPENAI_TOOLS = 128;79const OPENAI_TOOLS = AVAILABLE_TOOLS.slice(0, MAX_OPENAI_TOOLS).map((t: any) => ({80 type: 'function',81 function: {82 name: t.name,83 description: t.description,84 parameters: t.input_schema,85 },86}));8788// Convert Anthropic-format messages (tool_use / tool_result / image blocks,89// as built by routes.ts) into OpenAI chat format.90function convertMessages(messages: any[]): any[] {91 const out: any[] = [];92 for (const msg of messages) {93 if (typeof msg.content === 'string') {94 out.push({ role: msg.role, content: msg.content });95 continue;96 }97 if (!Array.isArray(msg.content)) {98 out.push(msg);99 continue;100 }101102 if (msg.role === 'assistant') {103 const text = msg.content104 .filter((b: any) => b.type === 'text')105 .map((b: any) => b.text)106 .join('');107 const toolUses = msg.content.filter((b: any) => b.type === 'tool_use');108 const m: any = { role: 'assistant', content: text || null };109 if (toolUses.length > 0) {110 m.tool_calls = toolUses.map((tu: any) => {111 const call: any = {112 id: tu.id,113 type: 'function',114 function: { name: tu.name, arguments: JSON.stringify(tu.input ?? {}) },115 };116 const sig = thoughtSignatures.get(tu.id);117 if (sig) call.extra_content = { google: { thought_signature: sig } };118 return call;119 });120 }121 out.push(m);122 } else {123 // user message: tool_result blocks become role:"tool" messages124 for (const block of msg.content) {125 if (block.type === 'tool_result') {126 out.push({127 role: 'tool',128 tool_call_id: block.tool_use_id,129 content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),130 });131 }132 }133 const rest = msg.content.filter((b: any) => b.type !== 'tool_result');134 if (rest.length > 0) {135 const parts = rest136 .map((b: any) => {137 if (b.type === 'text') return { type: 'text', text: b.text };138 if (b.type === 'image' && b.source?.type === 'base64') {139 return {140 type: 'image_url',141 image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` },142 };143 }144 return null;145 })146 .filter(Boolean);147 if (parts.length > 0) out.push({ role: 'user', content: parts });148 }149 }150 }151 return out;152}153154export async function callOpenAIStreaming(155 query: string,156 onChunk: (text: string) => void,157 messages: any[],158 model: string = 'gpt-5.5',159 onThinking?: (text: string) => void160): Promise<{ answer: string; toolCalls: any[]; usage: { inputTokens: number; outputTokens: number; cost: number } }> {161162 try {163 // Same context-management pipeline as claudeService164 let processedMessages = truncateToolResults(messages, 5000);165166 const estimatedTokens = estimateMessagesTokens(processedMessages);167 const TOKEN_LIMIT = 100000;168169 if (estimatedTokens > TOKEN_LIMIT) {170 console.log(`Messages exceed token limit (${estimatedTokens} tokens). Summarizing old history...`);171 const recentMessages = processedMessages.slice(-5);172 const oldMessages = processedMessages.slice(0, -5);173174 if (oldMessages.length > 0) {175 // summarizeHistory runs on the Anthropic API — use its default Claude model176 const summary = await summarizeHistory(oldMessages);177 processedMessages = [178 { role: 'user', content: `[Previous conversation summary]: ${summary}` },179 ...recentMessages180 ];181 console.log(`History summarized. New token count: ${estimateMessagesTokens(processedMessages)}`);182 }183 }184185 const now = new Date();186 const currentDateTime = now.toLocaleString('en-US', {187 weekday: 'long',188 year: 'numeric',189 month: 'long',190 day: 'numeric',191 hour: '2-digit',192 minute: '2-digit',193 second: '2-digit',194 timeZone: 'America/New_York',195 timeZoneName: 'short'196 });197 const isoDate = now.toISOString().split('T')[0];198199 const provider = getProvider(model);200 const openaiRequestBody: any = {201 model,202 // gpt-5.x / gemini-3.x are reasoning models: max_completion_tokens (not203 // max_tokens), temperature left at default (any other value is rejected)204 max_completion_tokens: (OPENAI_MODELS[model] || OPENAI_MODELS['gpt-5.5']).maxOut,205 // gpt-5.6 on /v1/chat/completions rejects function tools unless206 // reasoning_effort is 'none' (tools+reasoning need /v1/responses)207 ...(model.startsWith('gpt-5.6') ? { reasoning_effort: 'none' } : {}),208 stream: true,209 // include_obfuscation:false intermittently 401s ("insufficient210 // permissions") on gpt-5.6-* — omit the flag for those models211 stream_options: provider.name === 'openai' && !model.startsWith('gpt-5.6')212 ? { include_usage: true, include_obfuscation: false }213 : { include_usage: true },214 messages: [215 { role: 'system', content: buildSystemPrompt(currentDateTime, isoDate) },216 ...convertMessages(processedMessages),217 ],218 tools: OPENAI_TOOLS,219 };220 const openaiRequestConfig: any = {221 headers: {222 'Content-Type': 'application/json',223 'Authorization': `Bearer ${provider.key!}`,224 },225 responseType: 'stream',226 };227 // gpt-5.6 intermittently 401s ("insufficient permissions") server-side;228 // the error arrives before any stream bytes, so retrying here is safe229 let response: any;230 for (let attempt = 1; ; attempt++) {231 try {232 response = await axios.post(provider.url, openaiRequestBody, openaiRequestConfig);233 break;234 } catch (err: any) {235 const status = err.response?.status;236 if (attempt < 4 && (status === 401 || status === 429 || (status >= 500 && status < 600))) {237 console.error(`OpenAI transient ${status}, retrying (${attempt}/3)`);238 await new Promise((r) => setTimeout(r, 500 * attempt));239 continue;240 }241 throw err;242 }243 }244245 let fullAnswer = '';246 const toolCallsByIndex: Record<number, { id: string; name: string; arguments: string }> = {};247 const idxByCallId: Record<string, number> = {};248 let nextAutoIdx = 0;249 let lastIdx = 0;250 let buffer = '';251 let inputTokens = 0;252 let outputTokens = 0;253 let settled = false;254255 const pricing = OPENAI_PRICING[model] || OPENAI_PRICING['gpt-5.5'];256 const calculateCost = (input: number, output: number): number => {257 return (input * pricing.input / 1_000_000) + (output * pricing.output / 1_000_000);258 };259260 const finalizeToolCalls = () =>261 Object.keys(toolCallsByIndex)262 .sort((a, b) => Number(a) - Number(b))263 .map((k) => {264 const tc = toolCallsByIndex[Number(k)];265 let input: any = {};266 try {267 input = tc.arguments ? JSON.parse(tc.arguments) : {};268 } catch (e) {269 console.error(`Failed to parse tool arguments for ${tc.name}:`, tc.arguments);270 }271 return { id: tc.id, name: tc.name, input };272 });273274 return new Promise((resolve, reject) => {275 const finish = () => {276 if (settled) return;277 settled = true;278 // Streaming output_tokens can be missing — fall back to a local count279 try {280 const localTokenCount = encode(fullAnswer).length;281 outputTokens = Math.max(outputTokens, localTokenCount);282 } catch {}283 resolve({284 answer: fullAnswer,285 toolCalls: finalizeToolCalls(),286 usage: { inputTokens, outputTokens, cost: calculateCost(inputTokens, outputTokens) }287 });288 };289290 response.data.on('data', (chunk: Buffer) => {291 buffer += chunk.toString();292 const lines = buffer.split('\n');293 buffer = lines.pop() || '';294295 for (const line of lines) {296 if (!line.startsWith('data: ')) continue;297 const data = line.substring(6).trim();298 if (!data) continue;299 if (data === '[DONE]') {300 finish();301 continue;302 }303304 try {305 const event = JSON.parse(data);306307 if (event.usage) {308 inputTokens = event.usage.prompt_tokens || inputTokens;309 outputTokens = event.usage.completion_tokens || outputTokens;310 }311312 const delta = event.choices?.[0]?.delta;313 if (!delta) continue;314315 if (delta.content) {316 fullAnswer += delta.content;317 onChunk(delta.content);318 }319320 if (delta.tool_calls) {321 for (const tc of delta.tool_calls) {322 // OpenAI always sends an index; Gemini's compat layer omits it323 // and sends each complete call with its own id instead.324 let idx: number;325 if (typeof tc.index === 'number') {326 idx = tc.index;327 } else if (tc.id && idxByCallId[tc.id] !== undefined) {328 idx = idxByCallId[tc.id];329 } else if (tc.id) {330 idx = nextAutoIdx++;331 idxByCallId[tc.id] = idx;332 } else {333 idx = lastIdx;334 }335 lastIdx = idx;336 if (!toolCallsByIndex[idx]) {337 toolCallsByIndex[idx] = { id: tc.id || '', name: '', arguments: '' };338 }339 if (tc.id) toolCallsByIndex[idx].id = tc.id;340 if (tc.function?.name) toolCallsByIndex[idx].name = tc.function.name;341 if (tc.function?.arguments) toolCallsByIndex[idx].arguments += tc.function.arguments;342 // Gemini thought signature: must be echoed on the next turn343 const sig = tc.extra_content?.google?.thought_signature;344 if (sig && toolCallsByIndex[idx].id) rememberSignature(toolCallsByIndex[idx].id, sig);345 }346 }347 } catch (e) {348 console.error('Error parsing OpenAI SSE event:', e);349 }350 }351 });352353 response.data.on('end', finish);354355 response.data.on('error', (error: Error) => {356 console.error('OpenAI stream error:', error);357 if (!settled) {358 settled = true;359 reject(error);360 }361 });362 });363 } catch (error: any) {364 const detail = error.response?.data365 ? await streamErrorBody(error.response.data).catch(() => '')366 : '';367 console.error('OpenAI API error:', error.message, detail);368 throw new Error('Failed to call OpenAI API: ' + (detail || error.message));369 }370}371372// axios with responseType:'stream' delivers error bodies as streams too373async function streamErrorBody(data: any): Promise<string> {374 if (!data || typeof data.on !== 'function') return typeof data === 'string' ? data : JSON.stringify(data);375 const chunks: Buffer[] = [];376 for await (const c of data) chunks.push(Buffer.from(c));377 return Buffer.concat(chunks).toString();378}379