feat(sasquatch): align client models with the node + owner AI-balance nudge

- sasquatchBrain: PROXY_MODELS now match the Routstr node's slugs
  (gemini-3-6-flash, z-ai-glm-5-3, gemini-3-7-flash).
- ai.mjs: new owner-only POST /api/ai/status (NIP-98 + owner pubkey) returning the
  upstream balance + a 'low' flag (<500 sats); config.path now serves both routes.
- useSasquatchAiStatus + SasquatchAiStatusCard (owner-only, in Settings): shows the
  balance and warns when low — the top-up nudge, no webhook/notification key needed.
This commit is contained in:
SkateSpots 2026-09-27 15:27:32 +07:00
parent 972941d688
commit 62b395f3d2
5 changed files with 153 additions and 16 deletions

View file

@ -196,6 +196,30 @@ function json(body, status, headers) {
}); });
} }
/**
* Owner-only: report the upstream (Routstr) account balance, so the app can nudge
* the operator to top up before AI degrades to the keyless fallback. This is a
* simpler "low balance" signal than wiring a Routstr webhook, which would need a
* notification channel (a signing key) we don't otherwise have server-side.
*/
async function handleStatus(request, headers) {
const pubkey = verifyNip98(request.headers.get('x-sasquatch-auth'));
if (!pubkey) return json({ error: 'auth required' }, 401, headers);
if (!PLATFORM_OWNER_PUBKEYS.has(pubkey)) return json({ error: 'forbidden' }, 403, headers);
if (!AI_KEY) return json({ configured: false }, 200, headers);
try {
const r = await fetch(`${AI_BASE}/balance/info`, {
headers: { Authorization: `Bearer ${AI_KEY}` },
signal: AbortSignal.timeout(8000),
});
const j = await r.json().catch(() => ({}));
const balanceSats = Math.floor(Number(j.balance ?? 0) / 1000);
return json({ configured: true, balanceSats, low: balanceSats < 500, provider: AI_BASE }, 200, headers);
} catch {
return json({ configured: true, balanceSats: null, low: false }, 200, headers);
}
}
export default async function handler(request) { export default async function handler(request) {
const origin = request.headers.get('origin') || ''; const origin = request.headers.get('origin') || '';
const headers = corsHeaders(origin); const headers = corsHeaders(origin);
@ -203,6 +227,9 @@ export default async function handler(request) {
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers }); if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers });
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, headers); if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, headers);
// Owner-only balance status (for the low-balance nudge).
if (new URL(request.url).pathname === '/api/ai/status') return handleStatus(request, headers);
// Pro gate (env-gated): require a NIP-98-signed identity and, unless the // Pro gate (env-gated): require a NIP-98-signed identity and, unless the
// caller is a platform owner, an active Pro pass. // caller is a platform owner, an active Pro pass.
if (REQUIRE_PRO) { if (REQUIRE_PRO) {
@ -273,5 +300,5 @@ export default async function handler(request) {
return new Response(upstream.body, { status: upstream.status, headers: passthrough }); return new Response(upstream.body, { status: upstream.status, headers: passthrough });
} }
// Netlify Functions v2 routing — serve this at /api/ai. // Netlify Functions v2 routing — serve the proxy plus its status endpoint.
export const config = { path: '/api/ai' }; export const config = { path: ['/api/ai', '/api/ai/status'] };

View file

@ -0,0 +1,63 @@
import { AlertTriangle, Coins, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
import { cn } from '@/lib/utils';
/**
* Owner-only card: the Sasquatch AI (Routstr node) balance, with a warning when
* it's running low. This is the "top up before AI degrades" nudge — no webhook
* needed, since the operator can see it live in Settings.
*/
export function SasquatchAiStatusCard() {
const { user } = useCurrentUser();
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
const { data, isLoading, isError } = useSasquatchAiStatus();
if (!isOwner) return null;
const low = data?.low === true;
const balance = data?.balanceSats;
return (
<Card className={cn('overflow-hidden bg-white/2', low ? 'border-amber-500/40' : 'border-white/10')}>
<CardContent className="py-3.5">
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm font-bold text-white">
{low ? (
<AlertTriangle className="h-4 w-4 text-amber-400" />
) : (
<Coins className="h-4 w-4 text-red-400" />
)}
Sasquatch AI
<span className="rounded bg-red-500/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-red-400/70">
Owner
</span>
</p>
<span
className={cn(
'shrink-0 text-xs font-bold',
isError ? 'text-gray-500' : low ? 'text-amber-400' : 'text-green-400',
)}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : isError || data?.configured === false ? (
'not configured'
) : balance === null || balance === undefined ? (
'unavailable'
) : (
`${balance.toLocaleString()} sats`
)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{low
? `Running low — top up at ${data?.provider ?? 'the node'} or AI will fall back to the keyless provider.`
: 'Pay-per-request AI, funded in sats via Routstr. Tops up when low.'}
</p>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import { Capacitor } from '@capacitor/core';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { buildProAuthHeaders } from '@/lib/proAuth';
/** Report shape from the proxy's owner-only `/api/ai/status`. */
export interface SasquatchAiStatus {
configured: boolean;
balanceSats: number | null;
low: boolean;
provider?: string;
}
const AI_STATUS_URL = Capacitor.isNativePlatform()
? 'https://skate-spots.org/api/ai/status'
: '/api/ai/status';
/**
* Owner-only: the Sasquatch AI (upstream) account balance, so the operator can be
* nudged to top up before AI silently degrades to the keyless fallback. Polled
* every few minutes. Returns `undefined`/skips for non-owners.
*/
export function useSasquatchAiStatus() {
const { user } = useCurrentUser();
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
return useQuery<SasquatchAiStatus>({
queryKey: ['sasquatch-ai-status', user?.pubkey],
enabled: isOwner,
refetchInterval: 5 * 60 * 1000,
staleTime: 60 * 1000,
queryFn: async () => {
const authHeaders = await buildProAuthHeaders({
pubkey: user!.pubkey,
signEvent: (e) => user!.signer.signEvent(e),
});
const res = await fetch(AI_STATUS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: '{}',
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`AI status ${res.status}`);
return (await res.json()) as SasquatchAiStatus;
},
});
}

View file

@ -19,20 +19,17 @@ const AI_PROXY_URL = Capacitor.isNativePlatform()
? 'https://skate-spots.org/api/ai' ? 'https://skate-spots.org/api/ai'
: '/api/ai'; : '/api/ai';
// Models Sasquatch may request through the proxy, in preference order. These are // Models Sasquatch requests through the proxy, in preference order. These are
// verified to work via Netlify's AI Gateway — which enforces a Zero-Data- // the slugs served by our upstream (a Routstr node — see AGENTS.md), which are
// Retention policy, so not every OpenRouter model is routable (gemini/anthropic/ // OpenAI-style ids rather than OpenRouter's. If one is rate-limited (429) we back
// gpt-4o-mini are not). If one is rate-limited (429) we back off briefly then // off briefly then try the next; Pollinations is the keyless fallback.
// move to the next; Pollinations is the keyless fallback. // The server is authoritative: keep this list in sync with `SASQUATCH_MODELS`
// Keep this list in sync with ALLOWED_MODELS in netlify/functions/ai.mjs. // (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 = [ export const PROXY_MODELS = [
'meta-llama/llama-3.3-70b-instruct', 'gemini-3-6-flash',
'deepseek/deepseek-chat-v3-0324', 'z-ai-glm-5-3',
'x-ai/grok-4.5', 'gemini-3-7-flash',
'moonshotai/kimi-k2',
'qwen/qwen-2.5-72b-instruct',
'mistralai/mistral-nemo',
'meta-llama/llama-3.1-8b-instruct',
]; ];
export type ChatTurn = { role: 'system' | 'user' | 'assistant'; content: string }; export type ChatTurn = { role: 'system' | 'user' | 'assistant'; content: string };

View file

@ -8,6 +8,7 @@ import { WalletModal } from '@/components/WalletModal';
import { SecuritySettings } from '@/components/SecuritySettings'; import { SecuritySettings } from '@/components/SecuritySettings';
import { RelatrSettings } from '@/components/RelatrSettings'; import { RelatrSettings } from '@/components/RelatrSettings';
import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { CVMHealthCheck } from '@/components/CVMHealthCheck';
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
import { LanguageSelector } from '@/components/LanguageToggle'; import { LanguageSelector } from '@/components/LanguageToggle';
import { DesktopNavManager } from '@/components/DesktopNavManager'; import { DesktopNavManager } from '@/components/DesktopNavManager';
import { MyDataTab } from '@/components/MyDataTab'; import { MyDataTab } from '@/components/MyDataTab';
@ -586,8 +587,9 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
<BackHeader title="Advanced" onBack={onBack} /> <BackHeader title="Advanced" onBack={onBack} />
<div className="space-y-4"> <div className="space-y-4">
<RelatrSettings /> <RelatrSettings />
{/* Owner-only — renders null for everyone except platform owners. */} {/* Owner-only — render null for everyone except platform owners. */}
<CVMHealthCheck /> <CVMHealthCheck />
<SasquatchAiStatusCard />
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2"> <div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
<div className="px-4 py-3.5 border-b border-white/5"> <div className="px-4 py-3.5 border-b border-white/5">
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p> <p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>