UNPKG

@flavoai/fastfold

Version:

Flavo frontend package

550 lines 22.6 kB
/** * FastFold AI Module * Auto-generated AI endpoints using Vercel AI SDK */ import { generateText, streamText, generateObject, embed, embedMany } from 'ai'; import { createOpenAI } from '@ai-sdk/openai'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { aiLogger } from '../lib/aiLogger'; import { trackAIError } from '../server/observability'; // ============ Provider Configuration ============ function getProviderConfig(provider, config) { const providerConfig = config?.providers?.[provider] || {}; // Merge with environment variables switch (provider) { case 'openai': return { apiKey: providerConfig.apiKey || process.env.OPENAI_API_KEY, baseURL: providerConfig.baseURL || process.env.OPENAI_BASE_URL, organization: providerConfig.organization || process.env.OPENAI_ORGANIZATION, }; case 'anthropic': return { apiKey: providerConfig.apiKey || process.env.ANTHROPIC_API_KEY, baseURL: providerConfig.baseURL || process.env.ANTHROPIC_BASE_URL, }; case 'google': return { apiKey: providerConfig.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY, }; default: return providerConfig; } } /** * Get fast/cheap model for simple tasks (embeddings, classification, summarization) */ function getFastModel(provider) { switch (provider) { case 'openai': return 'gpt-4o-mini'; case 'anthropic': return 'claude-haiku-4-5-20260101'; case 'google': return 'gemini-3-flash'; default: return 'gpt-4o-mini'; } } /** * Get powerful model for complex tasks (planning, reasoning, code generation) */ function getPowerfulModel(provider) { switch (provider) { case 'openai': return 'gpt-5.2'; case 'anthropic': return 'claude-opus-4-5-20260101'; case 'google': return 'gemini-3-pro'; default: return 'gpt-5.2'; } } /** * Get the default model for a provider (balanced choice) * User-specified models ALWAYS take priority over these defaults */ function getDefaultModel(provider) { switch (provider) { case 'openai': return 'gpt-5.2'; case 'anthropic': return 'claude-sonnet-4-5-20260101'; case 'google': return 'gemini-3-flash'; default: return 'gpt-5.2'; } } function getEmbeddingModel(provider) { switch (provider) { case 'openai': return 'text-embedding-3-small'; case 'google': return 'text-embedding-004'; default: return 'text-embedding-3-small'; } } function getProvider(providerName, config) { const providerConfig = getProviderConfig(providerName, config); switch (providerName) { case 'openai': return createOpenAI({ apiKey: providerConfig.apiKey, baseURL: providerConfig.baseURL, organization: providerConfig.organization, }); case 'anthropic': return createAnthropic({ apiKey: providerConfig.apiKey, baseURL: providerConfig.baseURL, }); case 'google': return createGoogleGenerativeAI({ apiKey: providerConfig.apiKey, }); default: return createOpenAI({ apiKey: providerConfig.apiKey, }); } } function getModel(provider, modelName, config) { const providerInstance = getProvider(provider, config); const model = modelName || config?.defaultModel || getDefaultModel(provider); return providerInstance(model); } function getEmbeddingModelInstance(provider, modelName, config) { const providerInstance = getProvider(provider, config); const model = modelName || getEmbeddingModel(provider); return providerInstance.embeddingModel(model); } function createRateLimiter(windowMs, maxRequests) { const store = {}; return (req, res, next) => { const ip = req.ip || req.connection.remoteAddress || 'unknown'; const now = Date.now(); if (!store[ip] || now > store[ip].resetTime) { store[ip] = { count: 1, resetTime: now + windowMs, }; return next(); } store[ip].count++; if (store[ip].count > maxRequests) { const retryAfter = Math.ceil((store[ip].resetTime - now) / 1000); res.setHeader('Retry-After', retryAfter.toString()); return res.status(429).json({ success: false, error: 'Too many requests. Please try again later.', retryAfter, }); } next(); }; } // ============ Setup AI Routes ============ export function setupAIRoutes(app, config) { const basePath = config.basePath || '/api/ai'; const defaultProvider = config.defaultProvider || 'openai'; const endpoints = config.endpoints || { chat: true, complete: true, embed: true, generate: true, stream: true }; // Validate at least one provider is configured const providerConfig = getProviderConfig(defaultProvider, config); if (!providerConfig.apiKey) { aiLogger.warn(`AI endpoints enabled but no API key found for ${defaultProvider}. Set environment variable or provide in config.`, { provider: defaultProvider }); } // Apply rate limiting if configured if (config.rateLimit) { const windowMs = config.rateLimit.windowMs || 60000; const maxRequests = config.rateLimit.maxRequests || 20; const rateLimiter = createRateLimiter(windowMs, maxRequests); app.use(basePath, rateLimiter); } aiLogger.info(`AI endpoints enabled at ${basePath}`, { basePath }); // POST /api/ai/chat - Chat completions if (endpoints.chat !== false) { app.post(`${basePath}/chat`, async (req, res) => { try { const { messages, model, provider, temperature, maxTokens, system } = req.body; if (!messages || !Array.isArray(messages)) { return res.status(400).json({ success: false, error: 'messages array is required', }); } const selectedProvider = provider || defaultProvider; const aiModel = getModel(selectedProvider, model, config); const result = await generateText({ model: aiModel, messages, system, temperature, maxOutputTokens: maxTokens, }); res.json({ success: true, data: { text: result.text, finishReason: result.finishReason, usage: result.usage, }, }); } catch (error) { const { model: reqModel, provider: reqProvider } = req.body; const provider = reqProvider || defaultProvider; aiLogger.logError(error, { operation: 'chat', endpoint: `${basePath}/chat`, provider, model: reqModel }); trackAIError(error, { provider, model: reqModel, operation: 'chat' }, { sessionId: req.headers['x-session-id'], visitorId: req.headers['x-visitor-id'], endpoint: req.originalUrl }); res.status(500).json({ success: false, error: error.message || 'AI request failed', }); } }); aiLogger.debug(`Registered endpoint POST ${basePath}/chat`, { endpoint: 'chat' }); } // POST /api/ai/complete - Text completions if (endpoints.complete !== false) { app.post(`${basePath}/complete`, async (req, res) => { try { const { prompt, model, provider, temperature, maxTokens, system } = req.body; if (!prompt) { return res.status(400).json({ success: false, error: 'prompt is required', }); } const selectedProvider = provider || defaultProvider; const aiModel = getModel(selectedProvider, model, config); const result = await generateText({ model: aiModel, prompt, system, temperature, maxOutputTokens: maxTokens, }); res.json({ success: true, data: { text: result.text, finishReason: result.finishReason, usage: result.usage, }, }); } catch (error) { const { model: reqModel, provider: reqProvider } = req.body; const provider = reqProvider || defaultProvider; aiLogger.logError(error, { operation: 'complete', endpoint: `${basePath}/complete`, provider, model: reqModel }); trackAIError(error, { provider, model: reqModel, operation: 'complete' }, { sessionId: req.headers['x-session-id'], visitorId: req.headers['x-visitor-id'], endpoint: req.originalUrl }); res.status(500).json({ success: false, error: error.message || 'AI request failed', }); } }); aiLogger.debug(`Registered endpoint POST ${basePath}/complete`, { endpoint: 'complete' }); } // POST /api/ai/embed - Generate embeddings if (endpoints.embed !== false) { app.post(`${basePath}/embed`, async (req, res) => { try { const { text, texts, model, provider } = req.body; if (!text && !texts) { return res.status(400).json({ success: false, error: 'text or texts is required', }); } const selectedProvider = provider || defaultProvider; const embeddingModel = getEmbeddingModelInstance(selectedProvider, model, config); if (texts && Array.isArray(texts)) { // Batch embeddings const result = await embedMany({ model: embeddingModel, values: texts, }); res.json({ success: true, data: { embeddings: result.embeddings, usage: result.usage, }, }); } else { // Single embedding const result = await embed({ model: embeddingModel, value: text, }); res.json({ success: true, data: { embedding: result.embedding, usage: result.usage, }, }); } } catch (error) { const { model: reqModel, provider: reqProvider } = req.body; const provider = reqProvider || defaultProvider; aiLogger.logError(error, { operation: 'embed', endpoint: `${basePath}/embed`, provider, model: reqModel }); trackAIError(error, { provider, model: reqModel, operation: 'embed' }, { sessionId: req.headers['x-session-id'], visitorId: req.headers['x-visitor-id'], endpoint: req.originalUrl }); res.status(500).json({ success: false, error: error.message || 'AI request failed', }); } }); aiLogger.debug(`Registered endpoint POST ${basePath}/embed`, { endpoint: 'embed' }); } // POST /api/ai/generate - Structured object generation if (endpoints.generate !== false) { app.post(`${basePath}/generate`, async (req, res) => { try { const { prompt, messages, schema, model, provider, temperature, maxTokens, system, mode } = req.body; if (!schema) { return res.status(400).json({ success: false, error: 'schema is required for structured generation', }); } if (!prompt && !messages) { return res.status(400).json({ success: false, error: 'prompt or messages is required', }); } const selectedProvider = provider || defaultProvider; const aiModel = getModel(selectedProvider, model, config); // Convert JSON schema to zod schema dynamically const { z } = await import('zod'); const zodSchema = jsonSchemaToZod(schema, z); // Use type assertion to avoid deep type instantiation with dynamic schema const result = await generateObject({ model: aiModel, schema: zodSchema, prompt, messages, system, mode, temperature, maxOutputTokens: maxTokens, }); res.json({ success: true, data: { object: result.object, finishReason: result.finishReason, usage: result.usage, }, }); } catch (error) { const { model: reqModel, provider: reqProvider } = req.body; const provider = reqProvider || defaultProvider; aiLogger.logError(error, { operation: 'generate', endpoint: `${basePath}/generate`, provider, model: reqModel }); trackAIError(error, { provider, model: reqModel, operation: 'generate' }, { sessionId: req.headers['x-session-id'], visitorId: req.headers['x-visitor-id'], endpoint: req.originalUrl }); res.status(500).json({ success: false, error: error.message || 'AI request failed', }); } }); aiLogger.debug(`Registered endpoint POST ${basePath}/generate`, { endpoint: 'generate' }); } // POST /api/ai/stream - Streaming text generation if (endpoints.stream !== false) { app.post(`${basePath}/stream`, async (req, res) => { try { const { messages, prompt, model, provider, temperature, maxTokens, system } = req.body; if (!messages && !prompt) { return res.status(400).json({ success: false, error: 'messages or prompt is required', }); } const selectedProvider = provider || defaultProvider; const aiModel = getModel(selectedProvider, model, config); // Set up SSE headers res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const result = await streamText({ model: aiModel, messages, prompt, system, temperature, maxOutputTokens: maxTokens, }); // Stream chunks to client for await (const chunk of result.textStream) { res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`); } // Send final message with usage const usage = await result.usage; const finishReason = await result.finishReason; res.write(`data: ${JSON.stringify({ done: true, usage, finishReason })}\n\n`); res.end(); } catch (error) { const { model: reqModel, provider: reqProvider } = req.body; const provider = reqProvider || defaultProvider; aiLogger.logError(error, { operation: 'stream', endpoint: `${basePath}/stream`, provider, model: reqModel }); trackAIError(error, { provider, model: reqModel, operation: 'stream' }, { sessionId: req.headers['x-session-id'], visitorId: req.headers['x-visitor-id'], endpoint: req.originalUrl }); if (!res.headersSent) { res.status(500).json({ success: false, error: error.message || 'AI request failed', }); } else { res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); res.end(); } } }); aiLogger.debug(`Registered endpoint POST ${basePath}/stream`, { endpoint: 'stream' }); } // GET /api/ai/models - List available models app.get(`${basePath}/models`, (req, res) => { const models = { openai: { fast: 'gpt-4o-mini', balanced: 'gpt-5.2', powerful: 'gpt-5.2', all: ['gpt-5.2', 'gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'], }, anthropic: { fast: 'claude-haiku-4-5-20260101', balanced: 'claude-sonnet-4-5-20260101', powerful: 'claude-opus-4-5-20260101', all: ['claude-opus-4-5-20260101', 'claude-sonnet-4-5-20260101', 'claude-haiku-4-5-20260101'], }, google: { fast: 'gemini-3-flash', balanced: 'gemini-3-flash', powerful: 'gemini-3-pro', all: ['gemini-3-pro', 'gemini-3-flash', 'gemini-2.0-flash'], }, xai: { fast: 'grok-3-mini', balanced: 'grok-3', powerful: 'grok-3', all: ['grok-3', 'grok-3-mini'], }, }; const configured = []; if (getProviderConfig('openai', config).apiKey) configured.push('openai'); if (getProviderConfig('anthropic', config).apiKey) configured.push('anthropic'); if (getProviderConfig('google', config).apiKey) configured.push('google'); res.json({ success: true, data: { defaultProvider, configuredProviders: configured, models, }, }); }); aiLogger.debug(`Registered endpoint GET ${basePath}/models`, { endpoint: 'models' }); } // ============ Helper: JSON Schema to Zod ============ function jsonSchemaToZod(schema, z) { if (!schema || typeof schema !== 'object') { return z.any(); } const type = schema.type; switch (type) { case 'string': let stringSchema = z.string(); if (schema.minLength !== undefined) stringSchema = stringSchema.min(schema.minLength); if (schema.maxLength !== undefined) stringSchema = stringSchema.max(schema.maxLength); if (schema.pattern) stringSchema = stringSchema.regex(new RegExp(schema.pattern)); if (schema.enum) return z.enum(schema.enum); return stringSchema; case 'number': case 'integer': let numberSchema = type === 'integer' ? z.number().int() : z.number(); if (schema.minimum !== undefined) numberSchema = numberSchema.min(schema.minimum); if (schema.maximum !== undefined) numberSchema = numberSchema.max(schema.maximum); return numberSchema; case 'boolean': return z.boolean(); case 'array': const itemSchema = schema.items ? jsonSchemaToZod(schema.items, z) : z.any(); let arraySchema = z.array(itemSchema); if (schema.minItems !== undefined) arraySchema = arraySchema.min(schema.minItems); if (schema.maxItems !== undefined) arraySchema = arraySchema.max(schema.maxItems); return arraySchema; case 'object': if (!schema.properties) { return z.object({}).passthrough(); } const shape = {}; const required = schema.required || []; for (const [key, propSchema] of Object.entries(schema.properties)) { let propZod = jsonSchemaToZod(propSchema, z); if (!required.includes(key)) { propZod = propZod.optional(); } shape[key] = propZod; } return z.object(shape); case 'null': return z.null(); default: // Handle union types if (schema.anyOf) { const schemas = schema.anyOf.map((s) => jsonSchemaToZod(s, z)); return z.union(schemas); } if (schema.oneOf) { const schemas = schema.oneOf.map((s) => jsonSchemaToZod(s, z)); return z.union(schemas); } return z.any(); } } // Export hooks export { AIHooksProcessor, createAIHooksProcessor } from './hooks'; export default setupAIRoutes; //# sourceMappingURL=index.js.map