- 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.
133 lines
4.8 KiB
JavaScript
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' };
|