From a5a134c6c00d866ed4165768d2e55f50baf00f9e Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 15:01:13 +0700 Subject: [PATCH] feat(ai-proxy): provider-agnostic upstream (enables a sats-only provider) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy now reads its upstream from SASQUATCH_AI_BASE + SASQUATCH_AI_KEY (falling back to Netlify's injected OPENROUTER_* vars), and the model allowlist is overridable via SASQUATCH_MODELS. Default behaviour is unchanged (gateway). This makes switching to a sats-only provider a config change, e.g. Routstr: SASQUATCH_AI_BASE=https://api.routstr.com/v1 SASQUATCH_AI_KEY=cashuA… (raw token) or sk-… (session) NOTE: api.routstr.com was returning 404 (empty models) on 2026-09-27, so we are NOT switching yet. Documented in AGENTS.md. --- AGENTS.md | 6 ++++++ netlify/functions/ai.mjs | 45 +++++++++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 784130c..c0c3a6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,12 @@ session with **no other memory of this project**. So: (`useCrewAgentChat`). Callers without credentials 401 and fall back to keyless **Pollinations**. The pay-to address is single-sourced in `src/lib/platform.ts`. + The upstream is **provider-agnostic**: `SASQUATCH_AI_BASE` + `SASQUATCH_AI_KEY` + override the Netlify gateway (any OpenAI-compatible endpoint). For a + **sats-only** provider use Routstr (`SASQUATCH_AI_BASE=https://api.routstr.com/v1`, + key = a cashu…/sk- session token; `SASQUATCH_MODELS` must match `PROXY_MODELS`). + ⚠️ As of 2026-09-27 **`api.routstr.com` returned 404 for every endpoint and its + model list was empty** — do not switch to it until it serves models. **AI answers are Pro-only**: free users get the static FAQ/glossary + local relay lookups only (`if (isPro)` gates the AI block in `SasquatchGuide`). Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt, diff --git a/netlify/functions/ai.mjs b/netlify/functions/ai.mjs index d60515d..e3debac 100644 --- a/netlify/functions/ai.mjs +++ b/netlify/functions/ai.mjs @@ -15,22 +15,27 @@ // When stream:true, the upstream SSE is passed straight through. import { verifyEvent } from 'nostr-tools'; -// 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`; +// The upstream is provider-agnostic (any OpenAI-compatible endpoint): +// • default: Netlify's AI Gateway (it injects OPENROUTER_API_KEY + +// OPENROUTER_BASE_URL server-side, so no key is managed or shipped); +// • or set your own key: SASQUATCH_AI_BASE + SASQUATCH_AI_KEY (overrides). +// This is how you switch to a sats-only provider (e.g. Routstr: +// SASQUATCH_AI_BASE=https://api.routstr.com/v1, SASQUATCH_AI_KEY=cashuA… or +// sk-…) without touching code — just remember the model list must match. +const AI_BASE = ( + process.env.SASQUATCH_AI_BASE || + process.env.OPENROUTER_BASE_URL || + 'https://openrouter.ai/api/v1' +).replace(/\/+$/, ''); +const AI_URL = `${AI_BASE}/chat/completions`; +const AI_KEY = process.env.SASQUATCH_AI_KEY || process.env.OPENROUTER_API_KEY; // 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([ +// arbitrary (potentially expensive) models. Override with SASQUATCH_MODELS +// (comma-separated) when switching provider — it must match PROXY_MODELS in +// sasquatchBrain.ts. +const DEFAULT_MODELS = [ 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat-v3-0324', 'x-ai/grok-4.5', @@ -38,7 +43,13 @@ const ALLOWED_MODELS = new Set([ 'qwen/qwen-2.5-72b-instruct', 'mistralai/mistral-nemo', 'meta-llama/llama-3.1-8b-instruct', -]); +]; +const ALLOWED_MODELS = new Set( + (process.env.SASQUATCH_MODELS || DEFAULT_MODELS.join(',')) + .split(',') + .map((s) => s.trim()) + .filter(Boolean), +); // ── Sasquatch Pro enforcement (env-gated) ──────────────────────────────────── const REQUIRE_PRO = process.env.SASQUATCH_REQUIRE_PRO === '1'; @@ -205,7 +216,7 @@ export default async function handler(request) { } } - const key = process.env.OPENROUTER_API_KEY; + const key = AI_KEY; if (!key) return json({ error: 'AI proxy not configured' }, 503, headers); // Throttle per client IP (Netlify sets x-nf-client-connection-ip). @@ -235,7 +246,7 @@ export default async function handler(request) { let upstream; try { - upstream = await fetch(OPENROUTER_URL, { + upstream = await fetch(AI_URL, { method: 'POST', headers: { Authorization: `Bearer ${key}`, @@ -250,7 +261,7 @@ export default async function handler(request) { return json({ error: `Upstream failed: ${String(err)}` }, 502, headers); } - // Pass OpenRouter's response straight through (JSON or SSE). + // Pass the upstream response straight through (JSON or SSE). const passthrough = { ...headers }; const contentType = upstream.headers.get('content-type'); if (contentType) passthrough['Content-Type'] = contentType;