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) {
|
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'] };
|
||||||
|
|
|
||||||
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'
|
? '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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue