SkateSpots/src/lib/sasquatchBrain.ts
shakespeare.diy d6b50de349 feat(sasquatch): Pro receipt DM + local Pro FAQ + tougher free-tier answers
1. Pro purchase confirmation: on a paid pass, send the buyer an encrypted
   NIP-17 receipt to their own inbox (tier, expiry, unlocked perks). Best-effort
   so it never blocks the purchase. Success toast now points to the DM.

2. Frictionless Pro answers: add local static-FAQ entries for "sasquatch pro",
   "pro perks/version", pricing, "go pro", "upgrade" — so those questions are
   answered instantly and never dead-end when the free AI model is rate-limited
   (the exact failure seen when asking "what perks has the pro version?").

3. FAQ matching now prefers the longest matching key so specific phrases win
   over broad ones ("sasquatch pro" > "pro").

4. Refresh the free-model fallback list (add deepseek-v3, gemini-2.0-flash-exp,
   qwen-2.5-72b) for better free-tier AI availability.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-30 00:21:34 +07:00

132 lines
5 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 — 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<string> {
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<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 (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<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;
}
}