/** * 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 — no credits, no NIP-98, works for everyone ─────── const OPENROUTER_API_KEY = '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'; 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: 'user' | 'assistant'; content: string }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // ─── 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']; /** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */ async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise { for (const model of FREE_MODELS) { for (let attempt = 0; attempt < 2; attempt++) { try { 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 }), }); 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, 2500) : 600; 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): Promise { for (const model of POLLINATIONS_MODELS) { try { const response = await fetch(POLLINATIONS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages, max_tokens: maxTokens }), }); 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 (with 429 retry) → keyless Pollinations backup. * Throws only if BOTH providers fail, so the caller can fall back to local * knowledge (static FAQ / glossary). */ export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise { const primary = await tryOpenRouter(messages, maxTokens); if (primary) return primary; console.warn('[Sasquatch] OpenRouter exhausted — trying backup provider'); const backup = await tryPollinations(messages, maxTokens); 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; } }