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:
parent
972941d688
commit
62b395f3d2
5 changed files with 153 additions and 16 deletions
|
|
@ -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'] };
|
||||
|
|
|
|||
63
src/components/SasquatchAiStatusCard.tsx
Normal file
63
src/components/SasquatchAiStatusCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/hooks/useSasquatchAiStatus.ts
Normal file
48
src/hooks/useSasquatchAiStatus.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<BackHeader title="Advanced" onBack={onBack} />
|
||||
<div className="space-y-4">
|
||||
<RelatrSettings />
|
||||
{/* Owner-only — renders null for everyone except platform owners. */}
|
||||
{/* Owner-only — render null for everyone except platform owners. */}
|
||||
<CVMHealthCheck />
|
||||
<SasquatchAiStatusCard />
|
||||
<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">
|
||||
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue