SkateSpots/src/lib/sasquatchBrain.ts
SkateSpots 18cc8218b2 fix(sasquatch): bound AI latency, separate system prompt, verify relay events
- sasquatchBrain: abort each provider request after 15s and give up the whole
  multi-provider attempt after 35s, so a hung OpenRouter/Pollinations call can no
  longer wedge the chat (it now falls through to the static/local fallbacks).
- SasquatchGuide: send the system prompt as a real system message (both the
  first pass and the tool-synthesis pass) instead of smuggling it into a user
  turn, and mark tool output as untrusted — blunts prompt injection via event
  content.
- useCVMClient: signature-verify (verifyEvent) every relay result, matching the
  standalone server and the release-notes claim.
- useCVMServerHealth: stale threshold 1h -> 45m to match the server heartbeat.
2026-09-27 10:32:07 +07:00

178 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.
*/
// ─── 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';
// 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',
];
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 OpenRouter, so if OpenRouter's shared free key is fully
// rate-limited we still have a second brain to fall back to. No API key needed.
const POLLINATIONS_URL = 'https://text.pollinations.ai/openai';
const POLLINATIONS_MODELS = ['openai', 'mistral'];
// 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;
/** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */
async function tryOpenRouter(
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);
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(OPENROUTER_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://skate-spots.org',
'X-Title': 'SkateSpots',
},
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] OpenRouter ${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] OpenRouter ${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: 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).
*/
export async function callSasquatchAI(
messages: ChatTurn[],
maxTokens = 400,
totalTimeoutMs = TOTAL_TIMEOUT_MS,
): Promise<string> {
const deadline = Date.now() + totalTimeoutMs;
const remainingMs = () => deadline - Date.now();
const primary = await tryOpenRouter(messages, maxTokens, remainingMs);
if (primary) return primary;
console.warn('[Sasquatch] OpenRouter exhausted — trying backup provider');
const backup = await tryPollinations(messages, maxTokens, remainingMs);
if (backup) return backup;
throw new Error('All AI providers failed (OpenRouter + backup).');
}
/** 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;
}
}