Files

1 line
12 KiB
JSON

{"repo": "bheeshamks/lumina-ai-js", "files": {"api/chat.js": {"1860f722b7fde6f81cd5d606c89aad6cef006821": {"t": 1785746019.517571, "n": 10561, "h": "614cc05232c56326", "c": "/**\n * /api/chat.js — Vercel Serverless Function\n *\n * All LLM calls go through here. API keys never reach the browser.\n *\n * Flow:\n * Browser → POST /api/chat (with JWT) → this function fetches key from Supabase\n * → calls LLM provider → returns { text }\n *\n * Required env vars (server-only, no VITE_ prefix):\n * SUPABASE_URL — same value as VITE_SUPABASE_URL\n * SUPABASE_ANON_KEY — same value as VITE_SUPABASE_ANON_KEY\n * OPENROUTER_KEY — guest key (replaces VITE_GUEST_API_KEY)\n * TAVILY_API_KEY — web search (replaces VITE_TAVILY_API_KEY)\n */\n\nimport { createClient } from \"@supabase/supabase-js\";\nimport { generateText, tool } from \"ai\";\nimport { createOpenAI } from \"@ai-sdk/openai\";\nimport { createGoogleGenerativeAI } from \"@ai-sdk/google\";\nimport { createGroq } from \"@ai-sdk/groq\";\nimport { createAnthropic } from \"@ai-sdk/anthropic\";\nimport { createOpenRouter } from \"@openrouter/ai-sdk-provider\";\nimport { z } from \"zod\";\n\n// ─── Constants (mirrors src/utils) ────────────────────────────────────────────\n\nconst OPENAI_COMPATIBLE_ENDPOINTS = {\n OpenAI: \"https://api.openai.com/v1\",\n DeepSeek: \"https://api.deepseek.com/v1\",\n Mistral: \"https://api.mistral.ai/v1\",\n xAI: \"https://api.x.ai/v1\",\n Perplexity: \"https://api.perplexity.ai\",\n TogetherAI: \"https://api.together.xyz/v1\",\n};\n\nconst CONTEXT_LIMITS = {\n Groq: 6,\n Google: 50,\n OpenRouter: 20,\n OpenAI: 40,\n DeepSeek: 40,\n Mistral: 40,\n xAI: 40,\n Perplexity: 20,\n TogetherAI: 20,\n Anthropic: 40,\n};\n\n// Providers that don't support tool calls reliably\nconst NO_TOOL_SUPPORT = [\"Groq\", \"Perplexity\", \"TogetherAI\"];\n\n// Model ID → provider mapping — keep in sync with src/utils/models.js\n// This lets us look up the provider without importing from src/\nconst GUEST_MODEL_ID = \"openrouter/auto\";\n\n\n\nconst LUMINA_SYSTEM_PROMPT = `You are Lumina, a helpful and intelligent AI assistant. You provide clear, accurate, and thoughtful responses. You are conversational, warm, and concise unless asked for detail.`;\n\n// ─── Tavily web search ────────────────────────────────────────────────────────\n\nconst tavilySearch = async (query) => {\n const apiKey = process.env.TAVILY_API_KEY;\n if (!apiKey) throw new Error(\"Tavily API key not configured on server.\");\n\n const response = await fetch(\"https://api.tavily.com/search\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n api_key: apiKey,\n query,\n search_depth: \"basic\",\n max_results: 5,\n include_answer: true,\n }),\n });\n\n if (!response.ok) throw new Error(`Tavily error: ${response.status}`);\n const data = await response.json();\n\n const results = data.results\n .map((r, i) => `[${i + 1}] ${r.title}\\n${r.url}\\n${r.content}`)\n .join(\"\\n\\n\");\n\n return data.answer\n ? `Summary: ${data.answer}\\n\\nSources:\\n${results}`\n : results;\n};\n\n// ─── Build AI model instance ──────────────────────────────────────────────────\n\nconst buildAiModel = (provider, modelId, apiKey) => {\n if (provider === \"Google\") {\n return createGoogleGenerativeAI({ apiKey })(modelId);\n }\n if (provider === \"Groq\") {\n return createGroq({ apiKey })(modelId);\n }\n if (provider === \"Anthropic\") {\n return createAnthropic({ apiKey })(modelId);\n }\n if (provider === \"OpenRouter\") {\n return createOpenRouter({ apiKey }).chat(modelId);\n }\n if (OPENAI_COMPATIBLE_ENDPOINTS[provider]) {\n return createOpenAI({\n baseURL: OPENAI_COMPATIBLE_ENDPOINTS[provider],\n apiKey,\n compatibility: \"compatible\",\n })(modelId);\n }\n throw new Error(`Provider \"${provider}\" is not configured.`);\n};\n\n// ─── Core LLM call ────────────────────────────────────────────────────────────\n\nconst callLLM = async (provider, modelId, apiKey, messages, isWebSearchEnabled) => {\n const aiModel = buildAiModel(provider, modelId, apiKey);\n const contextLimit = CONTEXT_LIMITS[provider] ?? 20;\n const truncatedMessages = messages.slice(-contextLimit);\n \n // 1. Web search OFF\n if (!isWebSearchEnabled) {\n const { text } = await generateText({\n model: aiModel,\n system: LUMINA_SYSTEM_PROMPT,\n messages: truncatedMessages,\n });\n return text;\n }\n \n // Define the tool for providers that DO support it\n const webSearchTool = tool({\n description:\n \"Search the web for current information. Use when you don't know something, when asked to search online, or when the question involves recent events, news, prices, or real-time data.\",\n parameters: z.object({\n query: z.string().describe(\"The search query to look up\"),\n }),\n execute: async ({ query }) => {\n if (!query) return \"Search failed: no query provided.\";\n try {\n return await tavilySearch(query);\n } catch (err) {\n return `Search failed: ${err.message}`;\n }\n },\n });\n \n // 2. Web search ON but provider doesn't support tools -> Use the Gatekeeper!\n if (NO_TOOL_SUPPORT.includes(provider)) {\n try {\n const lastUserMessage = truncatedMessages.findLast((m) => m.role === \"user\")?.content || \"\";\n \n // Ask the model first: does this need a web search?\n const { text: needsSearch } = await generateText({\n model: aiModel,\n system: \"You are a classifier. Reply with only 'YES' if the user's question requires current/real-time information from the web (news, prices, weather, recent events). Reply with only 'NO' if you can answer from general knowledge.\",\n messages: [{ role: \"user\", content: lastUserMessage }],\n });\n \n if (needsSearch.trim().toUpperCase().includes(\"YES\")) {\n const searchResults = await tavilySearch(lastUserMessage);\n const { text } = await generateText({\n model: aiModel,\n system: LUMINA_SYSTEM_PROMPT + `\\n\\nCurrent web search context:\\n\\n${searchResults}\\n\\nUse this where relevant.`,\n messages: truncatedMessages,\n });\n return text;\n } else {\n // Doesn't need search — answer normally! (This fixes the \"hi\" bug)\n const { text } = await generateText({\n model: aiModel,\n system: LUMINA_SYSTEM_PROMPT,\n messages: truncatedMessages,\n });\n return text;\n }\n } catch (err) {\n console.error(\"Pre-search fallback error:\", err);\n throw new Error(err.message || \"Search failed.\");\n }\n }\n \n // 3. Web search ON + tools supported natively\n try {\n const result = await generateText({\n model: aiModel,\n system:\n LUMINA_SYSTEM_PROMPT +\n \"\\n\\nYou have access to a web search tool. Use it when you're unsure about something, when asked to search online, or when the question involves recent or real-time information.\",\n messages: truncatedMessages,\n tools: { webSearch: webSearchTool },\n maxSteps: 5,\n });\n \n // Extract text from result or last step\n return (\n result.text ||\n result.steps?.findLast((s) => s.text?.trim())?.text ||\n \"\"\n );\n } catch (error) {\n const msg = error.message?.toLowerCase() || \"\";\n if (\n msg.includes(\"tool\") ||\n msg.includes(\"function\") ||\n msg.includes(\"not supported\") ||\n msg.includes(\"does not support\")\n ) {\n // 4. Ultimate Fallback: If OpenRouter lies about tool support, inject context manually\n try {\n const lastUserMessage = truncatedMessages.findLast((m) => m.role === \"user\")?.content || \"\";\n const searchResults = await tavilySearch(lastUserMessage);\n const { text } = await generateText({\n model: aiModel,\n system: LUMINA_SYSTEM_PROMPT + `\\n\\nCurrent web search context:\\n\\n${searchResults}\\n\\nUse this where relevant.`,\n messages: truncatedMessages,\n });\n return text;\n } catch {\n return `⚠️ **${provider}** doesn't support web search and the fallback failed. Try a different model.`;\n }\n }\n throw error;\n }\n };\n\n\n// ─── Main handler ─────────────────────────────────────────────────────────────\n\nexport default async function handler(req, res) {\n res.setHeader(\"Access-Control-Allow-Origin\", \"*\");\n res.setHeader(\"Access-Control-Allow-Methods\", \"POST, OPTIONS\");\n res.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization\");\n\n if (req.method === \"OPTIONS\") return res.status(200).end();\n if (req.method !== \"POST\") return res.status(405).json({ error: \"Method not allowed\" });\n\n try {\n // 🚨 WE NOW EXTRACT 'provider' DIRECTLY FROM THE FRONTEND\n const { messages, modelId, provider, isWebSearchEnabled = false } = req.body;\n\n if (!messages || !modelId || !provider) {\n return res.status(400).json({ error: \"Missing messages, modelId, or provider\" });\n }\n\n // ── Determine API key ──────────────────────────────────────────────────\n let apiKey = null;\n const authHeader = req.headers.authorization;\n const token = authHeader?.startsWith(\"Bearer \") ? authHeader.slice(7) : null;\n\n if (token) {\n const userSupabase = createClient(\n process.env.SUPABASE_URL,\n process.env.SUPABASE_ANON_KEY,\n { global: { headers: { Authorization: `Bearer ${token}` } } }\n );\n\n const { data: keys, error } = await userSupabase.rpc(\"get_secure_keys\");\n\n if (error) {\n console.error(\"Supabase key fetch error:\", error);\n return res.status(500).json({ error: \"Failed to retrieve API keys.\" });\n }\n\n const activeKey = (keys || []).find(\n (k) => k.provider === provider && k.is_active\n );\n apiKey = activeKey?.api_key ?? null;\n }\n\n // Guest fallback \n if (!apiKey && modelId === GUEST_MODEL_ID && provider === \"OpenRouter\") {\n apiKey = process.env.OPENROUTER_KEY;\n }\n\n if (!apiKey) {\n return res.status(401).json({\n error: token\n ? `No active API key found for ${provider}. Add one in Settings → Providers.`\n : \"Authentication required. Please sign in.\",\n });\n }\n\n // ── Call LLM ──────────────────────────────────────────────────────────\n\n const text = await callLLM(provider, modelId, apiKey, messages, isWebSearchEnabled);\n\n return res.status(200).json({ text });\n\n } catch (error) {\n console.error(\"Chat proxy error:\", error);\n return res.status(500).json({\n error: error.message || \"An unexpected error occurred.\",\n });\n }\n}"}}}}