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>
149 lines
6.2 KiB
TypeScript
149 lines
6.2 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: '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;
|
||
}
|
||
}
|