SkateSpots/src/lib/sasquatchBrain.ts
shakespeare.diy ada4bfee5e Accelerate Sasquatch custom questions (live-data queries)
Three low-risk speedups focused on the slowest path — questions that hit live
relay data:

1. Relay timeout 10s → 4.5s (6s for the fan-out community stats). The useful
   relays answer in ~2s; the old 10s just let one slow/dead relay stall the
   whole reply.

2. Results-first: for finder tools (spots/events/coaches/gear/crews/shops/
   tours/stats/weather) format the CVM result locally and SKIP the second
   "prettify" AI call. Removes a full model round-trip (~2–4s) — users want the
   list, not extra prose. New toolCallToIntent() maps a tool call to the existing
   formatLocalResults() path. Personal/Pro tools (get_my_*) still use AI synthesis.

3. Faster AI fallback: cap OpenRouter attempts to the top 3 free models before
   the keyless backup, and shorten 429 backoff (≤800ms vs ≤2500ms). Avoids the
   worst-case 15–20s grind through 8 models on a busy free tier.

Conversational/knowledge answers (single AI call) keep Sasquatch's personality
and are untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 23:11:14 +07:00

149 lines
6.2 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: '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'];
// 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): 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) {
// 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 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, 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): Promise<string> {
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 (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): Promise<string> {
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<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;
}
}