// Netlify Function: POST /api/ai — server-side AI proxy for Sasquatch. // // Why this exists: a `VITE_*` API key is compiled into the client bundle and is // therefore public. This function keeps the OpenRouter key server-side (Netlify // env var OPENROUTER_API_KEY) so it never ships to the browser/APK. // // Safety: only `:free` OpenRouter models are allowed, so even an abused endpoint // cannot spend credits — the worst case is exhausting the free quota (the app // then falls back to the keyless Pollinations provider). // // Request body (OpenAI chat shape): // { model, messages, max_tokens?, stream? } // When stream:true, OpenRouter's SSE is passed straight through. // Netlify's AI Gateway injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into // Functions (server-side only), so no key is managed or shipped by us. If you // later set your OWN OPENROUTER_API_KEY in the project env, Netlify stops // injecting both, and this falls back to the public OpenRouter endpoint — so it // works either way. const OPENROUTER_BASE = (process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1').replace( /\/+$/, '', ); const OPENROUTER_URL = `${OPENROUTER_BASE}/chat/completions`; // Models Sasquatch may request. Curated on purpose: the client sends the model // slug, so an allowlist stops anyone using this endpoint as an open proxy for // arbitrary (potentially expensive) models. These are fast, cheap, capable slugs // served through Netlify's AI Gateway. const ALLOWED_MODELS = new Set([ 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat-v3-0324', 'x-ai/grok-4.5', 'moonshotai/kimi-k2', 'qwen/qwen-2.5-72b-instruct', 'mistralai/mistral-nemo', 'meta-llama/llama-3.1-8b-instruct', ]); // Origins allowed to call the proxy (web + local dev + Capacitor native). const ALLOWED_ORIGINS = new Set([ 'https://skate-spots.org', 'http://localhost:8080', 'http://localhost:5173', 'capacitor://localhost', 'http://localhost', 'https://localhost', ]); // Best-effort in-memory throttle. Serverless instances are ephemeral, so this is // a blunt abuse damper, not a hard guarantee. const WINDOW_MS = 60_000; const LIMIT = 20; const hits = new Map(); function corsHeaders(origin) { return { 'Access-Control-Allow-Origin': ALLOWED_ORIGINS.has(origin) ? origin : 'https://skate-spots.org', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', Vary: 'Origin', }; } function json(body, status, headers) { return new Response(JSON.stringify(body), { status, headers: { ...headers, 'Content-Type': 'application/json' }, }); } export default async function handler(request) { const origin = request.headers.get('origin') || ''; const headers = corsHeaders(origin); if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers }); if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, headers); const key = process.env.OPENROUTER_API_KEY; if (!key) return json({ error: 'AI proxy not configured' }, 503, headers); // Throttle per client IP (Netlify sets x-nf-client-connection-ip). const ip = request.headers.get('x-nf-client-connection-ip') || request.headers.get('x-forwarded-for') || 'unknown'; const now = Date.now(); const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS); if (recent.length >= LIMIT) return json({ error: 'Too many requests' }, 429, headers); recent.push(now); hits.set(ip, recent); let body; try { body = await request.json(); } catch { return json({ error: 'Invalid JSON' }, 400, headers); } const model = String(body.model ?? ''); if (!ALLOWED_MODELS.has(model)) return json({ error: 'Model not allowed' }, 400, headers); const messages = Array.isArray(body.messages) ? body.messages.slice(-20) : []; const maxTokens = Math.min(Number(body.max_tokens) || 400, 800); const stream = body.stream === true; let upstream; try { upstream = await fetch(OPENROUTER_URL, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://skate-spots.org', 'X-Title': 'SkateSpots', }, body: JSON.stringify({ model, messages, max_tokens: maxTokens, stream }), signal: AbortSignal.timeout(30_000), }); } catch (err) { return json({ error: `Upstream failed: ${String(err)}` }, 502, headers); } // Pass OpenRouter's response straight through (JSON or SSE). const passthrough = { ...headers }; const contentType = upstream.headers.get('content-type'); if (contentType) passthrough['Content-Type'] = contentType; return new Response(upstream.body, { status: upstream.status, headers: passthrough }); } // Netlify Functions v2 routing — serve this at /api/ai. export const config = { path: '/api/ai' };