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:
parent
c487d9d1d2
commit
1e4f3302dc
4 changed files with 199 additions and 62 deletions
13
AGENTS.md
13
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
|
||||
|
|
|
|||
|
|
@ -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
133
netlify/functions/ai.mjs
Normal 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' };
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue