/** * 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 { // 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 { 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 { 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 } | 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; } }