feat(ai-proxy): provider-agnostic upstream (enables a sats-only provider)

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.
This commit is contained in:
SkateSpots 2026-09-27 15:01:13 +07:00
parent f94297b196
commit a5a134c6c0
2 changed files with 34 additions and 17 deletions

View file

@ -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,

View file

@ -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;