/** * 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 = {}, ): Promise { 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 { 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 = {}, ): Promise { 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, body: Record, onDelta: ((cumulative: string) => void) | undefined, remainingMs: () => number, ): Promise { 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 = {}, ): Promise { 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 { 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 } = {}, ): Promise { 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 } | 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; } }