SkateSpots/netlify/functions/ai.mjs
SkateSpots 1e4f3302dc feat(sasquatch): server-side AI proxy (no key in the client bundle)
- netlify/functions/ai.mjs: POST /api/ai runs against Netlify's AI Gateway —
  Netlify injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into Functions, so no
  key is managed or shipped. Curated model allowlist, best-effort per-IP
  throttle, CORS for web + Capacitor, and SSE streamed straight through.
- sasquatchBrain: call the proxy instead of OpenRouter directly; drop
  VITE_OPENROUTER_API_KEY entirely; PROXY_MODELS set to the slugs that actually
  work via the gateway (its ZDR policy blocks gemini/anthropic/gpt-4o-mini).
- netlify.toml: [functions] directory.
- Verified in production: POST https://skate-spots.org/api/ai -> 200.
2026-09-27 12:24:46 +07:00

133 lines
4.8 KiB
JavaScript

// 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' };