SkateSpots/src/lib/sasquatchBrain.ts
SkateSpots ec06a63b01 feat(ai-proxy): run the AI proxy on the CVM host, ready to switch over
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.

- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
  allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
  (typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
  restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
  u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
  move it. Fully reversible.

Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
2026-09-28 13:32:05 +07:00

326 lines
12 KiB
TypeScript

/**
* Sasquatch brain — the shared AI caller used by both the global Sasquatch
* guide and the per-crew Crew Agent. Kept framework-agnostic (no React) so any
* surface can reuse the same model list + fallback behaviour.
*/
import { sasquatchAiBase } from '@/lib/sasquatchAi';
// ─── AI models, via our own server-side proxy ────────────────────────────────
// SECURITY: no API key is ever shipped to the client. Requests go to our proxy
// (`/api/ai`), which holds the Routstr key server-side and only allows a curated
// model list. Where that proxy lives is decided in one place — lib/sasquatchAi.ts
// (the Netlify Function by default, or our own ai-proxy/ service on the CVM host
// via VITE_SASQUATCH_AI_BASE). If it's unavailable we fall back to the keyless
// Pollinations provider below, so Sasquatch still works with zero secrets shipped.
const AI_PROXY_URL = sasquatchAiBase();
// Models Sasquatch requests through the proxy, in preference order. These are
// the slugs served by our upstream (a Routstr node — see AGENTS.md), which are
// OpenAI-style ids rather than OpenRouter's. If one is rate-limited (429) we back
// off briefly then try the next; Pollinations is the keyless fallback.
// The server is authoritative: keep this list in sync with `SASQUATCH_MODELS`
// (and ALLOWED_MODELS in netlify/functions/ai.mjs) — but it's not fatal if they
// drift, because the proxy substitutes its first allowed model.
export const PROXY_MODELS = [
'gemini-3-6-flash',
'z-ai-glm-5-3',
'gemini-3-7-flash',
];
export type ChatTurn = { role: 'system' | 'user' | 'assistant'; content: string };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Hard caps so a hung provider can never wedge the chat: every HTTP request is
// aborted after PER_REQUEST_TIMEOUT_MS, and the whole multi-provider attempt
// gives up once TOTAL_TIMEOUT_MS is spent (the caller then falls back to the
// static FAQ / glossary / local data paths).
const PER_REQUEST_TIMEOUT_MS = 15000;
const TOTAL_TIMEOUT_MS = 35000;
// ─── Backup provider — Pollinations (keyless, OpenAI-compatible) ─────────────
// 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 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 our AI proxy (OpenRouter free models), respecting 429 backoff. */
async function tryProxy(
messages: ChatTurn[],
maxTokens: number,
remainingMs: () => number,
authHeaders: Record<string, string> = {},
): Promise<string> {
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
// model rarely frees up quickly, so we'd rather move on than wait it out.
for (let attempt = 0; attempt < 2; attempt++) {
try {
const left = remainingMs();
if (left <= 1000) return '';
const response = await fetch(AI_PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
signal: AbortSignal.timeout(Math.min(PER_REQUEST_TIMEOUT_MS, left)),
});
if (response.status === 429 && attempt === 0) {
const retryAfter = Number(response.headers.get('retry-after'));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, 800)
: 400;
console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`);
await sleep(waitMs);
continue;
}
if (!response.ok) {
console.warn(`[Sasquatch] AI proxy ${model} failed ${response.status}`);
break;
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? '';
if (content) return content;
break;
} catch (err) {
console.warn(`[Sasquatch] AI proxy ${model} threw:`, err);
break;
}
}
}
return '';
}
/** Try the keyless Pollinations backup. Returns text or ''. */
async function tryPollinations(
messages: ChatTurn[],
maxTokens: number,
remainingMs: () => number,
): Promise<string> {
for (const model of POLLINATIONS_MODELS) {
try {
const left = remainingMs();
if (left <= 1000) return '';
const response = await fetch(POLLINATIONS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
signal: AbortSignal.timeout(Math.min(PER_REQUEST_TIMEOUT_MS, left)),
});
if (!response.ok) {
console.warn(`[Sasquatch] Pollinations ${model} failed ${response.status}`);
continue;
}
const data = await response.json();
const content =
data.choices?.[0]?.message?.content ??
(typeof data === 'string' ? data : '');
if (content) return content;
} catch (err) {
console.warn(`[Sasquatch] Pollinations ${model} threw:`, err);
}
}
return '';
}
/**
* Call the AI with multi-provider fallback + rate-limit backoff.
*
* 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[],
maxTokens = 400,
totalTimeoutMs = TOTAL_TIMEOUT_MS,
authHeaders: Record<string, string> = {},
): Promise<string> {
const deadline = Date.now() + totalTimeoutMs;
const remainingMs = () => deadline - Date.now();
const primary = await tryProxy(messages, maxTokens, remainingMs, authHeaders);
if (primary) return primary;
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 (proxy + backup).');
}
// ─── Streaming variant ───────────────────────────────────────────────────────
// Same provider order + budget as callSasquatchAI, but reads the OpenAI-style
// SSE stream and reports the growing text via `onDelta`. Used for the first
// pass so answers appear as they're written. Falls back to the non-streaming
// caller only if nothing at all was delivered.
/** Result of one provider's streaming attempt. */
interface StreamAttempt {
text: string;
/** True once at least one delta reached `onDelta`. */
delivered: boolean;
}
async function streamOpenAiCompatible(
url: string,
headers: Record<string, string>,
body: Record<string, unknown>,
onDelta: ((cumulative: string) => void) | undefined,
remainingMs: () => number,
): Promise<StreamAttempt> {
const left = remainingMs();
if (left <= 1000) return { text: '', delivered: false };
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ ...body, stream: true }),
signal: AbortSignal.timeout(Math.min(PER_REQUEST_TIMEOUT_MS, left)),
});
if (!response.ok || !response.body) return { text: '', delivered: false };
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
let text = '';
let delivered = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
let newline = sseBuffer.indexOf('\n');
while (newline !== -1) {
const line = sseBuffer.slice(0, newline).trim();
sseBuffer = sseBuffer.slice(newline + 1);
if (line.startsWith('data:')) {
const payload = line.slice(5).trim();
if (payload && payload !== '[DONE]') {
try {
const json = JSON.parse(payload);
const delta: string = json.choices?.[0]?.delta?.content ?? '';
if (delta) {
text += delta;
delivered = true;
onDelta?.(text);
}
} catch {
// Keep-alive / partial line — ignore.
}
}
}
newline = sseBuffer.indexOf('\n');
}
}
return { text, delivered };
}
async function streamProxy(
messages: ChatTurn[],
maxTokens: number,
onDelta: ((cumulative: string) => void) | undefined,
remainingMs: () => number,
authHeaders: Record<string, string> = {},
): Promise<StreamAttempt> {
const models = PROXY_MODELS.slice(0, MAX_PROXY_MODELS);
for (const model of models) {
if (remainingMs() <= 1000) break;
try {
const attempt = await streamOpenAiCompatible(
AI_PROXY_URL,
authHeaders,
{ model, messages, max_tokens: maxTokens },
onDelta,
remainingMs,
);
if (attempt.text || attempt.delivered) return attempt;
} catch (err) {
console.warn(`[Sasquatch] AI proxy stream ${model} threw:`, err);
}
}
return { text: '', delivered: false };
}
async function streamPollinations(
messages: ChatTurn[],
maxTokens: number,
onDelta: ((cumulative: string) => void) | undefined,
remainingMs: () => number,
): Promise<StreamAttempt> {
for (const model of POLLINATIONS_MODELS) {
if (remainingMs() <= 1000) break;
try {
const attempt = await streamOpenAiCompatible(
POLLINATIONS_URL,
{},
{ model, messages, max_tokens: maxTokens },
onDelta,
remainingMs,
);
if (attempt.text || attempt.delivered) return attempt;
} catch (err) {
console.warn(`[Sasquatch] Pollinations stream ${model} threw:`, err);
}
}
return { text: '', delivered: false };
}
/**
* Streaming version of {@link callSasquatchAI}. Reports the growing response
* via `onDelta` (always the *cumulative* text, so the caller can just replace
* the bubble) and resolves with the full text. Only if nothing was streamed at
* all does it fall back to the non-streaming path, so the user still gets an
* answer on environments that block streaming.
*/
export async function streamSasquatchAI(
messages: ChatTurn[],
maxTokens = 400,
handlers: { onDelta?: (cumulative: string) => void; authHeaders?: Record<string, string> } = {},
): Promise<string> {
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
const remainingMs = () => deadline - Date.now();
const primary = await streamProxy(
messages,
maxTokens,
handlers.onDelta,
remainingMs,
handlers.authHeaders,
);
if (primary.text) return primary.text;
const backup = await streamPollinations(messages, maxTokens, handlers.onDelta, remainingMs);
if (backup.text) return backup.text;
// A partial answer was already shown — don't restart with a fresh call.
if (primary.delivered || backup.delivered) return primary.text || backup.text;
console.warn('[Sasquatch] Streaming unavailable — falling back to non-streaming call');
return callSasquatchAI(messages, maxTokens, undefined, handlers.authHeaders);
}
/** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */
export function parseToolCall(aiText: string): { tool: string; args: Record<string, unknown> } | null {
const jsonMatch = aiText.match(/\{[\s\S]*"tool"\s*:\s*"(\w+)"[\s\S]*\}/);
if (!jsonMatch) return null;
try {
return JSON.parse(jsonMatch[0]);
} catch {
return null;
}
}