- 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.
178 lines
7.1 KiB
TypeScript
178 lines
7.1 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.
|
||
*/
|
||
|
||
// ─── 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;
|
||
}
|
||
}
|