From 1e4f3302dc23363508cae7592907b24dcf56d151 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 12:24:46 +0700 Subject: [PATCH] feat(sasquatch): server-side AI proxy (no key in the client bundle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 13 +++- netlify.toml | 4 ++ netlify/functions/ai.mjs | 133 ++++++++++++++++++++++++++++++++++++++ src/lib/sasquatchBrain.ts | 111 +++++++++++++++---------------- 4 files changed, 199 insertions(+), 62 deletions(-) create mode 100644 netlify/functions/ai.mjs diff --git a/AGENTS.md b/AGENTS.md index 9ca1819..adeecc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/netlify.toml b/netlify.toml index fc0e969..257f49f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -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] diff --git a/netlify/functions/ai.mjs b/netlify/functions/ai.mjs new file mode 100644 index 0000000..5b6afc5 --- /dev/null +++ b/netlify/functions/ai.mjs @@ -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' }; diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index b27398e..2712a79 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -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 { - // 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 { - 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);