diff --git a/netlify/functions/ai.mjs b/netlify/functions/ai.mjs index 1c30a24..1cc9b7f 100644 --- a/netlify/functions/ai.mjs +++ b/netlify/functions/ai.mjs @@ -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) { const origin = request.headers.get('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 !== '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 // caller is a platform owner, an active Pro pass. if (REQUIRE_PRO) { @@ -273,5 +300,5 @@ export default async function handler(request) { return new Response(upstream.body, { status: upstream.status, headers: passthrough }); } -// Netlify Functions v2 routing — serve this at /api/ai. -export const config = { path: '/api/ai' }; +// Netlify Functions v2 routing — serve the proxy plus its status endpoint. +export const config = { path: ['/api/ai', '/api/ai/status'] }; diff --git a/src/components/SasquatchAiStatusCard.tsx b/src/components/SasquatchAiStatusCard.tsx new file mode 100644 index 0000000..24311a9 --- /dev/null +++ b/src/components/SasquatchAiStatusCard.tsx @@ -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 ( + + +
+

+ {low ? ( + + ) : ( + + )} + Sasquatch AI + + Owner + +

+ + {isLoading ? ( + + ) : isError || data?.configured === false ? ( + 'not configured' + ) : balance === null || balance === undefined ? ( + 'unavailable' + ) : ( + `${balance.toLocaleString()} sats` + )} + +
+

+ {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.'} +

+
+
+ ); +} diff --git a/src/hooks/useSasquatchAiStatus.ts b/src/hooks/useSasquatchAiStatus.ts new file mode 100644 index 0000000..9a9cf9e --- /dev/null +++ b/src/hooks/useSasquatchAiStatus.ts @@ -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({ + 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; + }, + }); +} diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index bb6b7b7..e894d2f 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -19,20 +19,17 @@ const AI_PROXY_URL = Capacitor.isNativePlatform() ? 'https://skate-spots.org/api/ai' : '/api/ai'; -// Models Sasquatch may request through the proxy, in preference order. These are -// verified to work via Netlify's AI Gateway — which enforces a Zero-Data- -// Retention policy, so not every OpenRouter model is routable (gemini/anthropic/ -// gpt-4o-mini are not). If one is rate-limited (429) we back off briefly then -// move to the next; Pollinations is the keyless fallback. -// Keep this list in sync with ALLOWED_MODELS in netlify/functions/ai.mjs. +// 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 = [ - 'meta-llama/llama-3.3-70b-instruct', - 'deepseek/deepseek-chat-v3-0324', - 'x-ai/grok-4.5', - 'moonshotai/kimi-k2', - 'qwen/qwen-2.5-72b-instruct', - 'mistralai/mistral-nemo', - 'meta-llama/llama-3.1-8b-instruct', + 'gemini-3-6-flash', + 'z-ai-glm-5-3', + 'gemini-3-7-flash', ]; export type ChatTurn = { role: 'system' | 'user' | 'assistant'; content: string }; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e271fc8..e790c10 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -8,6 +8,7 @@ import { WalletModal } from '@/components/WalletModal'; import { SecuritySettings } from '@/components/SecuritySettings'; import { RelatrSettings } from '@/components/RelatrSettings'; import { CVMHealthCheck } from '@/components/CVMHealthCheck'; +import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { LanguageSelector } from '@/components/LanguageToggle'; import { DesktopNavManager } from '@/components/DesktopNavManager'; import { MyDataTab } from '@/components/MyDataTab'; @@ -586,8 +587,9 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
- {/* Owner-only — renders null for everyone except platform owners. */} + {/* Owner-only — render null for everyone except platform owners. */} +

🦧 Sasquatch Guide