Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.
- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
(typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
move it. Fully reversible.
Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
326 lines
12 KiB
TypeScript
326 lines
12 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.
|
|
*/
|
|
|
|
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<string, string> = {},
|
|
): Promise<string> {
|
|
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<string> {
|
|
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<string, string> = {},
|
|
): Promise<string> {
|
|
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<string, string>,
|
|
body: Record<string, unknown>,
|
|
onDelta: ((cumulative: string) => void) | undefined,
|
|
remainingMs: () => number,
|
|
): Promise<StreamAttempt> {
|
|
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<string, string> = {},
|
|
): Promise<StreamAttempt> {
|
|
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<StreamAttempt> {
|
|
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<string, string> } = {},
|
|
): Promise<string> {
|
|
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<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;
|
|
}
|
|
}
|