feat(sasquatch-pro): server-side Pro gate on the AI proxy + client credentials
- netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1). Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the caller is a platform owner, an active Pro pass (x-sasquatch-pro = a provider-authored zap receipt) verified OFFLINE — signature, user binding, platform tag, and paid-amount → tier. Off by default. - src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per message) and attaches the Pro receipt. - sasquatchBrain: threads auth headers through the proxy (streaming + non). - useSasquatchPro: exposes the winning receipt event. - Verified: 200 with the gate off, 401 with it on (env reset afterwards).
This commit is contained in:
parent
6335965afc
commit
ccc04cc172
6 changed files with 246 additions and 19 deletions
|
|
@ -112,6 +112,13 @@ session with **no other memory of this project**. So:
|
|||
Retention, so gemini/anthropic/gpt-4o-mini are **not** routable) — keep
|
||||
`ALLOWED_MODELS` in the function in sync with `PROXY_MODELS` in
|
||||
`sasquatchBrain.ts`. Keyless **Pollinations** is the fallback.
|
||||
The proxy has an **env-gated Pro paywall**: set `SASQUATCH_REQUIRE_PRO=1` and
|
||||
it requires `x-sasquatch-auth` (a NIP-98 kind-27235 event) plus
|
||||
`x-sasquatch-pro` (the Pro zap receipt, verified offline incl. paid-amount→
|
||||
tier); platform owners bypass. Off by default. The client (`src/lib/proAuth.ts`)
|
||||
signs NIP-98 once per session and attaches the receipt — **but the per-crew
|
||||
agent (`useCrewAgentChat`) does not yet**, so don't enable the gate until it
|
||||
does. The pay-to address is single-sourced in `src/lib/platform.ts`.
|
||||
**AI answers are Pro-only**: free users get the static FAQ/glossary + local
|
||||
relay lookups only (`if (isPro)` gates the AI block in `SasquatchGuide`).
|
||||
Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
// Netlify Function: POST /api/ai — server-side AI proxy for Sasquatch.
|
||||
//
|
||||
// Why this exists: a `VITE_*` API key is compiled into the client bundle and is
|
||||
// therefore public. This function keeps the OpenRouter key server-side (Netlify
|
||||
// env var OPENROUTER_API_KEY) so it never ships to the browser/APK.
|
||||
// therefore public. This function talks to models server-side (Netlify's AI
|
||||
// Gateway injects the credentials) so no key ever ships to the browser/APK.
|
||||
//
|
||||
// Safety: only `:free` OpenRouter models are allowed, so even an abused endpoint
|
||||
// cannot spend credits — the worst case is exhausting the free quota (the app
|
||||
// then falls back to the keyless Pollinations provider).
|
||||
// Access: the model list is a curated allowlist so this can't be used as an open
|
||||
// proxy. Set SASQUATCH_REQUIRE_PRO=1 to additionally require a valid NIP-98 auth
|
||||
// header (`x-sasquatch-auth`) plus proof of an active Sasquatch Pro pass
|
||||
// (`x-sasquatch-pro` = a provider-authored zap receipt). Off by default so the
|
||||
// client can be updated before enforcement is switched on.
|
||||
//
|
||||
// Request body (OpenAI chat shape):
|
||||
// { model, messages, max_tokens?, stream? }
|
||||
// When stream:true, OpenRouter's SSE is passed straight through.
|
||||
// When stream:true, the upstream SSE is passed straight through.
|
||||
import { verifyEvent } from 'nostr-tools';
|
||||
|
||||
// Netlify's AI Gateway injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into
|
||||
// Functions (server-side only), so no key is managed or shipped by us. If you
|
||||
|
|
@ -37,6 +40,119 @@ const ALLOWED_MODELS = new Set([
|
|||
'meta-llama/llama-3.1-8b-instruct',
|
||||
]);
|
||||
|
||||
// ── Sasquatch Pro enforcement (env-gated) ────────────────────────────────────
|
||||
const REQUIRE_PRO = process.env.SASQUATCH_REQUIRE_PRO === '1';
|
||||
const PLATFORM_PRO_ADDRESS = 'skatespots@minibits.cash';
|
||||
/** Primary platform pubkey — zap requests must tag it; owners always pass. */
|
||||
const PLATFORM_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
||||
const PLATFORM_OWNER_PUBKEYS = new Set([
|
||||
PLATFORM_PUBKEY,
|
||||
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b',
|
||||
]);
|
||||
const DAY_SECONDS = 86_400;
|
||||
/** Tier days ↔ sats. Keep in sync with PRO_TIERS in src/lib/sasquatchPro.ts. */
|
||||
const PRO_TIERS = [
|
||||
{ days: 7, sats: 2000 },
|
||||
{ days: 30, sats: 5000 },
|
||||
{ days: 90, sats: 12000 },
|
||||
{ days: 365, sats: 36000 },
|
||||
];
|
||||
|
||||
let providerPubkeyCache;
|
||||
/** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */
|
||||
async function getProProviderPubkey() {
|
||||
if (providerPubkeyCache !== undefined) return providerPubkeyCache;
|
||||
try {
|
||||
const [user, domain] = PLATFORM_PRO_ADDRESS.split('@');
|
||||
const res = await fetch(`https://${domain}/.well-known/lnurlp/${user}`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
const j = await res.json();
|
||||
providerPubkeyCache = j.nostrPubkey || null;
|
||||
} catch {
|
||||
providerPubkeyCache = null;
|
||||
}
|
||||
return providerPubkeyCache;
|
||||
}
|
||||
|
||||
function decodeB64Json(value) {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function tagVal(tags, name) {
|
||||
return tags?.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
/** Satoshis from a BOLT11 amount. The receipt is provider-signed, so we only
|
||||
* need the amount (not full invoice validation). */
|
||||
function satsFromBolt11(pr) {
|
||||
const m = /^lnbc(\d+)([munp]?)/i.exec(String(pr).toLowerCase());
|
||||
if (!m) return 0;
|
||||
const n = Number(m[1]);
|
||||
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2]] ?? 1;
|
||||
return Math.round(n * mult * 1e8);
|
||||
}
|
||||
|
||||
/** Verify a NIP-98 auth header → pubkey, or null. A generous window lets one
|
||||
* signature act as a session (so Amber doesn't pop per request). */
|
||||
function verifyNip98(authHeader) {
|
||||
if (!authHeader) return null;
|
||||
const ev = decodeB64Json(authHeader);
|
||||
if (!ev || ev.kind !== 27235) return null;
|
||||
const method = tagVal(ev.tags, 'method');
|
||||
if (method && method !== 'POST') return null;
|
||||
const u = tagVal(ev.tags, 'u') ?? '';
|
||||
if (u && !u.endsWith('/api/ai')) return null;
|
||||
if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null;
|
||||
try {
|
||||
return verifyEvent(ev) ? ev.pubkey : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a Pro pass (provider-authored zap receipt bound to the caller, paying
|
||||
* the platform ≥ the smallest tier) → proUntil (unix sec), or null. */
|
||||
async function verifyPro(receiptHeader, userPubkey) {
|
||||
if (!receiptHeader) return null;
|
||||
const receipt = decodeB64Json(receiptHeader);
|
||||
if (!receipt || receipt.kind !== 9735) return null;
|
||||
const providerPubkey = await getProProviderPubkey();
|
||||
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
||||
try {
|
||||
if (!verifyEvent(receipt)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const desc = tagVal(receipt.tags, 'description');
|
||||
if (!desc) return null;
|
||||
let zapReq;
|
||||
try {
|
||||
zapReq = JSON.parse(desc);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!zapReq || zapReq.pubkey !== userPubkey) return null;
|
||||
try {
|
||||
if (!verifyEvent(zapReq)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;
|
||||
|
||||
let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11'));
|
||||
if (!paidSats) {
|
||||
const msat = Number(tagVal(zapReq.tags, 'amount'));
|
||||
if (Number.isFinite(msat) && msat > 0) paidSats = Math.floor(msat / 1000);
|
||||
}
|
||||
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
|
||||
if (!tier) return null;
|
||||
return receipt.created_at + tier.days * DAY_SECONDS;
|
||||
}
|
||||
|
||||
// Origins allowed to call the proxy (web + local dev + Capacitor native).
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
'https://skate-spots.org',
|
||||
|
|
@ -76,6 +192,19 @@ 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);
|
||||
|
||||
// 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) {
|
||||
const pubkey = verifyNip98(request.headers.get('x-sasquatch-auth'));
|
||||
if (!pubkey) return json({ error: 'auth required' }, 401, headers);
|
||||
if (!PLATFORM_OWNER_PUBKEYS.has(pubkey)) {
|
||||
const proUntil = await verifyPro(request.headers.get('x-sasquatch-pro'), pubkey);
|
||||
if (!proUntil || proUntil < Math.floor(Date.now() / 1000)) {
|
||||
return json({ error: 'pro required' }, 402, headers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = process.env.OPENROUTER_API_KEY;
|
||||
if (!key) return json({ error: 'AI proxy not configured' }, 503, headers);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { SasquatchProDialog } from '@/components/SasquatchProDialog';
|
|||
import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions';
|
||||
import { SasquatchActionCard } from '@/components/SasquatchActionCard';
|
||||
import { callSasquatchAI, streamSasquatchAI } from '@/lib/sasquatchBrain';
|
||||
import { buildProAuthHeaders } from '@/lib/proAuth';
|
||||
import { answerFromGlossary } from '@/lib/glossary';
|
||||
import { TripItineraryCard } from '@/components/TripItineraryCard';
|
||||
import { buildItinerary, type Itinerary, type PlannerSpot, type PlannerEvent, type PlannerCoach, type PlannerCrew, type WeatherLite } from '@/lib/tripPlanner';
|
||||
|
|
@ -634,7 +635,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
const { user } = useCurrentUser();
|
||||
const cvm = useCVMClient();
|
||||
const personal = usePersonalContext();
|
||||
const { isPro } = useSasquatchPro();
|
||||
const { isPro, receiptEvent } = useSasquatchPro();
|
||||
const { publishDraft } = useSasquatchActions();
|
||||
const [proDialogOpen, setProDialogOpen] = useState(false);
|
||||
|
||||
|
|
@ -964,6 +965,14 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
// AI answers are a Pro feature. Free users skip this entirely and fall
|
||||
// through to the static FAQ, glossary and local relay lookups below.
|
||||
if (isPro) try {
|
||||
// Prove identity + Pro pass to the proxy when its gate is enabled.
|
||||
// Signed once per session (see buildProAuthHeaders) so Amber doesn't pop
|
||||
// on every message.
|
||||
const authHeaders = await buildProAuthHeaders({
|
||||
pubkey: user?.pubkey,
|
||||
signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
|
||||
receiptEvent,
|
||||
});
|
||||
// The system prompt goes in a real `system` message rather than being
|
||||
// smuggled into a user turn — the model then treats the relay/derived
|
||||
// text below as lower-trust input, blunting prompt-injection via event
|
||||
|
|
@ -981,6 +990,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
// Streamed so the answer appears as it's written; raw JSON tool calls are
|
||||
// suppressed so partial JSON never shows up as text.
|
||||
const aiText = await streamAI(historyWithContext, 400, {
|
||||
authHeaders,
|
||||
onDelta: (cumulative) => {
|
||||
if (streamSuppressed) return;
|
||||
if (cumulative.trimStart().startsWith('{')) {
|
||||
|
|
@ -1214,7 +1224,7 @@ Instructions:
|
|||
- Keep it short and skater-friendly
|
||||
- Never make up data not in the results above`,
|
||||
},
|
||||
]);
|
||||
], 400, undefined, authHeaders);
|
||||
}
|
||||
} catch (toolErr) {
|
||||
setCvmConnected(false);
|
||||
|
|
@ -1359,7 +1369,7 @@ Instructions:
|
|||
setIsThinking(false);
|
||||
setStreamingText('');
|
||||
}
|
||||
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, appendMessage, runTripPlanner]);
|
||||
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, receiptEvent, user, appendMessage, runTripPlanner]);
|
||||
|
||||
// Listen for external events from FAQ/About pages
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface ProStatus {
|
|||
/** How access was granted. */
|
||||
source: 'owner' | 'zap' | null;
|
||||
isLoading: boolean;
|
||||
/** The zap receipt that granted access — sent to the AI proxy as proof. */
|
||||
receiptEvent?: NostrEvent | null;
|
||||
}
|
||||
|
||||
function tagVal(tags: string[][], name: string): string | undefined {
|
||||
|
|
@ -124,7 +126,13 @@ export function useSasquatchPro(): ProStatus {
|
|||
queryKey: ['sasquatch-pro', pubkey],
|
||||
enabled: !!pubkey && !isOwner,
|
||||
staleTime: 60_000,
|
||||
queryFn: async (c): Promise<{ expiresAt: number | null; source: ProStatus['source'] }> => {
|
||||
queryFn: async (
|
||||
c,
|
||||
): Promise<{
|
||||
expiresAt: number | null;
|
||||
source: ProStatus['source'];
|
||||
receipt?: NostrEvent;
|
||||
}> => {
|
||||
if (!pubkey) return { expiresAt: null, source: null };
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||
|
||||
|
|
@ -143,18 +151,26 @@ export function useSasquatchPro(): ProStatus {
|
|||
);
|
||||
|
||||
let best = 0;
|
||||
let bestReceipt: NostrEvent | undefined;
|
||||
for (const ev of events) {
|
||||
const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey);
|
||||
if (exp && exp > best) best = exp;
|
||||
if (exp && exp > best) {
|
||||
best = exp;
|
||||
bestReceipt = ev;
|
||||
}
|
||||
}
|
||||
|
||||
return { expiresAt: best || null, source: best ? 'zap' : null };
|
||||
return {
|
||||
expiresAt: best || null,
|
||||
source: best ? ('zap' as const) : null,
|
||||
receipt: bestReceipt,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo<ProStatus>(() => {
|
||||
if (isOwner) {
|
||||
return { isPro: true, expiresAt: null, source: 'owner', isLoading: false };
|
||||
return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, receiptEvent: null };
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = data?.expiresAt ?? null;
|
||||
|
|
@ -164,6 +180,7 @@ export function useSasquatchPro(): ProStatus {
|
|||
expiresAt,
|
||||
source: isPro ? data?.source ?? null : null,
|
||||
isLoading,
|
||||
receiptEvent: isPro ? data?.receipt ?? null : null,
|
||||
};
|
||||
}, [isOwner, data, isLoading]);
|
||||
}
|
||||
|
|
|
|||
55
src/lib/proAuth.ts
Normal file
55
src/lib/proAuth.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Pro auth for the AI proxy.
|
||||
*
|
||||
* When the proxy's Pro gate is enabled (`SASQUATCH_REQUIRE_PRO=1`) every
|
||||
* `/api/ai` request must carry:
|
||||
* • `x-sasquatch-auth` — a base64 NIP-98 (kind 27235) event proving the caller;
|
||||
* • `x-sasquatch-pro` — base64 of the Pro zap receipt proving an active pass.
|
||||
*
|
||||
* The NIP-98 event is signed ONCE per session and reused, so an interactive
|
||||
* signer (Amber) doesn't pop on every message.
|
||||
*/
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
type SignEvent = (event: {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
}) => Promise<NostrEvent>;
|
||||
|
||||
let cachedAuthHeader: string | null = null;
|
||||
let cachedAuthPubkey: string | null = null;
|
||||
|
||||
function toBase64(value: string): string {
|
||||
// Event JSON is ASCII (hex pubkeys/ids, tags), so btoa is safe here.
|
||||
return btoa(value);
|
||||
}
|
||||
|
||||
export async function buildProAuthHeaders(opts: {
|
||||
pubkey?: string;
|
||||
signEvent?: SignEvent;
|
||||
receiptEvent?: NostrEvent | null;
|
||||
}): Promise<Record<string, string>> {
|
||||
const { pubkey, signEvent, receiptEvent } = opts;
|
||||
const headers: Record<string, string> = {};
|
||||
if (!pubkey || !signEvent) return headers;
|
||||
|
||||
if (cachedAuthPubkey !== pubkey || !cachedAuthHeader) {
|
||||
const event = await signEvent({
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', 'https://skate-spots.org/api/ai'],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
});
|
||||
cachedAuthHeader = toBase64(JSON.stringify(event));
|
||||
cachedAuthPubkey = pubkey;
|
||||
}
|
||||
|
||||
headers['x-sasquatch-auth'] = cachedAuthHeader;
|
||||
if (receiptEvent) headers['x-sasquatch-pro'] = toBase64(JSON.stringify(receiptEvent));
|
||||
return headers;
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ 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) {
|
||||
|
|
@ -76,7 +77,7 @@ async function tryProxy(
|
|||
if (left <= 1000) return '';
|
||||
const response = await fetch(AI_PROXY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
||||
body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
|
||||
signal: AbortSignal.timeout(Math.min(PER_REQUEST_TIMEOUT_MS, left)),
|
||||
});
|
||||
|
|
@ -152,11 +153,12 @@ 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);
|
||||
const primary = await tryProxy(messages, maxTokens, remainingMs, authHeaders);
|
||||
if (primary) return primary;
|
||||
|
||||
console.warn('[Sasquatch] AI proxy exhausted — trying backup provider');
|
||||
|
|
@ -240,6 +242,7 @@ async function streamProxy(
|
|||
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) {
|
||||
|
|
@ -247,7 +250,7 @@ async function streamProxy(
|
|||
try {
|
||||
const attempt = await streamOpenAiCompatible(
|
||||
AI_PROXY_URL,
|
||||
{},
|
||||
authHeaders,
|
||||
{ model, messages, max_tokens: maxTokens },
|
||||
onDelta,
|
||||
remainingMs,
|
||||
|
|
@ -294,12 +297,18 @@ async function streamPollinations(
|
|||
export async function streamSasquatchAI(
|
||||
messages: ChatTurn[],
|
||||
maxTokens = 400,
|
||||
handlers: { onDelta?: (cumulative: string) => void } = {},
|
||||
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);
|
||||
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);
|
||||
|
|
@ -309,7 +318,7 @@ export async function streamSasquatchAI(
|
|||
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);
|
||||
return callSasquatchAI(messages, maxTokens, undefined, handlers.authHeaders);
|
||||
}
|
||||
|
||||
/** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue