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.
This commit is contained in:
SkateSpots 2026-09-27 12:24:46 +07:00
parent c487d9d1d2
commit 1e4f3302dc
4 changed files with 199 additions and 62 deletions

View file

@ -103,9 +103,16 @@ session with **no other memory of this project**. So:
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
- **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat
queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and
does **not** use the ContextVM server. AI is **keyless by default** —
`VITE_OPENROUTER_API_KEY` is opt-in, so production runs on the Pollinations
fallback. `sasquatchBrain.ts` caps each request (15 s) and the whole attempt
does **not** use the ContextVM server. AI goes through our own **server-side
proxy** — the Netlify Function `netlify/functions/ai.mjs` at `/api/ai` — which
runs against **Netlify's AI Gateway**. No key is managed or shipped:
Netlify injects `OPENROUTER_API_KEY` + `OPENROUTER_BASE_URL` into Functions,
and the function reads the base URL (so it also works if you later set your
own key). Model choice is a curated allowlist (the gateway enforces Zero Data
Retention, so gemini/anthropic/gpt-4o-mini are **not** routable) — keep
`ALLOWED_MODELS` in the function in sync with `PROXY_MODELS` in
`sasquatchBrain.ts`. Keyless **Pollinations** is the fallback.
`sasquatchBrain.ts` caps each request (15 s) and the whole attempt
(35 s); the system prompt is a real `system` message; relay results are
signature-verified (`verifyEvent`) before use. The first pass **streams**
(`streamSasquatchAI`, OpenAI-style SSE) into a live bubble — raw JSON tool

View file

@ -2,6 +2,10 @@
command = "npm run build"
publish = "dist"
# Server-side AI proxy for Sasquatch (holds the OpenRouter key).
[functions]
directory = "netlify/functions"
[[headers]]
for = "/*"
[headers.values]

133
netlify/functions/ai.mjs Normal file
View file

@ -0,0 +1,133 @@
// 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' };

View file

@ -4,28 +4,35 @@
* surface can reuse the same model list + fallback behaviour.
*/
// ─── OpenRouter free models ──────────────────────────────────────────────────
// SECURITY: never hardcode an API key here. A key committed to source is shipped
// in the production JS bundle and can be extracted + abused by anyone. Instead,
// OpenRouter is OPT-IN via an optional build-time env var. When it's not set
// (the default), we skip OpenRouter entirely and use the keyless Pollinations
// backup below — so Sasquatch still works for everyone with zero secrets shipped.
const OPENROUTER_API_KEY: string | undefined = import.meta.env.VITE_OPENROUTER_API_KEY;
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
import { Capacitor } from '@capacitor/core';
// Try multiple free models in order — if one is rate-limited, fall back.
// Slugs verified against OpenRouter's free tier. Ordered by reliability +
// availability so the first pick is usually the one that answers. If any is
// rate-limited (429) we back off briefly then move to the next model.
export const FREE_MODELS = [
'meta-llama/llama-3.3-70b-instruct:free',
'deepseek/deepseek-chat-v3-0324:free',
'google/gemini-2.0-flash-exp:free',
'qwen/qwen-2.5-72b-instruct:free',
'google/gemma-2-9b-it:free',
'mistralai/mistral-7b-instruct:free',
'qwen/qwen-2.5-7b-instruct:free',
'openai/gpt-oss-20b:free',
// ─── AI models, via our own server-side proxy ────────────────────────────────
// SECURITY: no API key is ever shipped to the client. Requests go to our Netlify
// Function at `/api/ai`, which runs against Netlify's AI Gateway server-side and
// only allows a curated model list (see netlify/functions/ai.mjs). If the proxy
// is unavailable we fall back to the keyless Pollinations provider below, so
// Sasquatch still works with zero secrets shipped.
//
// The native (Capacitor) build has no same-origin `/api/ai`, so it calls the
// absolute production URL; the web build stays same-origin.
const AI_PROXY_URL = Capacitor.isNativePlatform()
? 'https://skate-spots.org/api/ai'
: '/api/ai';
// Models Sasquatch may request through the proxy, in preference order. These are
// verified to work via Netlify's AI Gateway — which enforces a Zero-Data-
// Retention policy, so not every OpenRouter model is routable (gemini/anthropic/
// gpt-4o-mini are not). If one is rate-limited (429) we back off briefly then
// move to the next; Pollinations is the keyless fallback.
// Keep this list in sync with ALLOWED_MODELS in netlify/functions/ai.mjs.
export const PROXY_MODELS = [
'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',
];
export type ChatTurn = { role: 'system' | 'user' | 'assistant'; content: string };
@ -40,28 +47,25 @@ const PER_REQUEST_TIMEOUT_MS = 15000;
const TOTAL_TIMEOUT_MS = 35000;
// ─── Backup provider — Pollinations (keyless, OpenAI-compatible) ─────────────
// Independent of OpenRouter, so if OpenRouter's shared free key is fully
// Independent of the proxy, so if the proxy (or the key behind it) is down or
// rate-limited we still have a second brain to fall back to. No API key needed.
const POLLINATIONS_URL = 'https://text.pollinations.ai/openai';
// `mistral` was removed upstream (404 on the legacy endpoint), so keep only
// live slugs. `openai` currently maps to gpt-oss-20b server-side.
const POLLINATIONS_MODELS = ['openai'];
// Only try the top few OpenRouter models before failing over to the keyless
// backup. Grinding through all 8 on a busy free tier is the #1 cause of a
// 15–20s "why is this so slow" wait; the first couple of models answer the vast
// majority of the time, and Pollinations catches the rest fast.
const MAX_OPENROUTER_MODELS = 3;
// Only try the top few models before failing over to the keyless backup.
// Grinding through the whole list is the main cause of a "why is this so slow"
// wait; the first couple answer the vast majority of the time.
const MAX_PROXY_MODELS = 3;
/** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */
async function tryOpenRouter(
/** Try our AI proxy (OpenRouter free models), respecting 429 backoff. */
async function tryProxy(
messages: ChatTurn[],
maxTokens: number,
remainingMs: () => number,
): Promise<string> {
// No key configured → skip OpenRouter entirely (keyless backup handles it).
if (!OPENROUTER_API_KEY) return '';
const models = FREE_MODELS.slice(0, MAX_OPENROUTER_MODELS);
const models = PROXY_MODELS.slice(0, MAX_PROXY_MODELS);
for (const model of models) {
if (remainingMs() <= 1000) return '';
// Single 429 retry per model with a short, capped backoff — a rate-limited
@ -70,14 +74,9 @@ async function tryOpenRouter(
try {
const left = remainingMs();
if (left <= 1000) return '';
const response = await fetch(OPENROUTER_URL, {
const response = await fetch(AI_PROXY_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://skate-spots.org',
'X-Title': 'SkateSpots',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
signal: AbortSignal.timeout(Math.min(PER_REQUEST_TIMEOUT_MS, left)),
});
@ -93,7 +92,7 @@ async function tryOpenRouter(
}
if (!response.ok) {
console.warn(`[Sasquatch] OpenRouter ${model} failed ${response.status}`);
console.warn(`[Sasquatch] AI proxy ${model} failed ${response.status}`);
break;
}
@ -102,7 +101,7 @@ async function tryOpenRouter(
if (content) return content;
break;
} catch (err) {
console.warn(`[Sasquatch] OpenRouter ${model} threw:`, err);
console.warn(`[Sasquatch] AI proxy ${model} threw:`, err);
break;
}
}
@ -145,10 +144,9 @@ async function tryPollinations(
/**
* Call the AI with multi-provider fallback + rate-limit backoff.
*
* Order: OpenRouter free models (only if VITE_OPENROUTER_API_KEY is set, with
* 429 retry) → keyless Pollinations backup. Throws only if all available
* providers fail, so the caller can fall back to local knowledge
* (static FAQ / glossary).
* Order: our AI proxy (OpenRouter free models, 429 retry) → keyless
* Pollinations backup. Throws only if all available providers fail, so the
* caller can fall back to local knowledge (static FAQ / glossary).
*/
export async function callSasquatchAI(
messages: ChatTurn[],
@ -158,14 +156,14 @@ export async function callSasquatchAI(
const deadline = Date.now() + totalTimeoutMs;
const remainingMs = () => deadline - Date.now();
const primary = await tryOpenRouter(messages, maxTokens, remainingMs);
const primary = await tryProxy(messages, maxTokens, remainingMs);
if (primary) return primary;
console.warn('[Sasquatch] OpenRouter exhausted — trying backup provider');
console.warn('[Sasquatch] AI proxy exhausted — trying backup provider');
const backup = await tryPollinations(messages, maxTokens, remainingMs);
if (backup) return backup;
throw new Error('All AI providers failed (OpenRouter + backup).');
throw new Error('All AI providers failed (proxy + backup).');
}
// ─── Streaming variant ───────────────────────────────────────────────────────
@ -237,31 +235,26 @@ async function streamOpenAiCompatible(
return { text, delivered };
}
async function streamOpenRouter(
async function streamProxy(
messages: ChatTurn[],
maxTokens: number,
onDelta: ((cumulative: string) => void) | undefined,
remainingMs: () => number,
): Promise<StreamAttempt> {
if (!OPENROUTER_API_KEY) return { text: '', delivered: false };
const models = FREE_MODELS.slice(0, MAX_OPENROUTER_MODELS);
const models = PROXY_MODELS.slice(0, MAX_PROXY_MODELS);
for (const model of models) {
if (remainingMs() <= 1000) break;
try {
const attempt = await streamOpenAiCompatible(
OPENROUTER_URL,
{
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': 'https://skate-spots.org',
'X-Title': 'SkateSpots',
},
AI_PROXY_URL,
{},
{ model, messages, max_tokens: maxTokens },
onDelta,
remainingMs,
);
if (attempt.text || attempt.delivered) return attempt;
} catch (err) {
console.warn(`[Sasquatch] OpenRouter stream ${model} threw:`, err);
console.warn(`[Sasquatch] AI proxy stream ${model} threw:`, err);
}
}
return { text: '', delivered: false };
@ -306,7 +299,7 @@ export async function streamSasquatchAI(
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
const remainingMs = () => deadline - Date.now();
const primary = await streamOpenRouter(messages, maxTokens, handlers.onDelta, remainingMs);
const primary = await streamProxy(messages, maxTokens, handlers.onDelta, remainingMs);
if (primary.text) return primary.text;
const backup = await streamPollinations(messages, maxTokens, handlers.onDelta, remainingMs);