SPB Git

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%
14.6 KB · 379 lines typescript
Raw Blame History
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