@flavoai/fastfold
Version:
Flavo frontend package
550 lines • 22.6 kB
JavaScript
/**
* 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