From b4a05a8069fdbabc2100d1fbee7ed583d6a71f32 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 05:55:32 +0100 Subject: [PATCH 1/7] feat(btl): integrate the BTL Runtime as HexKit's AI backbone Route every AI feature through the BTL Runtime (an OpenAI-compatible gateway) and add a tool-calling agent that simulates real transactions. - Repoint both LLM proxies (api/llm-recommend.ts, vite dev plugin) from Gemini to BTL: inject the Bearer key server-side and forward the per-request cost headers to the browser. - Shared BTL client (src/lib/btl): OpenAI-shape request/response + cost-meta parsing, a non-streaming tool-calling loop (runBtlAgent), and a freeform "explain" hook. - Concierge (LI.FI Earn): intent parsing + vault ranking on BTL, a live per-call cost chip, and a one-click OpenAI/DeepSeek provider toggle. - Deposit preflight: a tool-calling agent runs the real REVM simulator and narrates the deposit. - Four explainer weaves: revert explainer, decoded-calldata explainer, contract-upgrade auditor (verified-source diff to a risk table), and a storage-slot annotator (structured JSON to hoverable chips). - Cost-transparency UI: AiCostChip, BtlRuntimePanel, a foldable BtlExplanation panel, and a "Powered by BTL" badge across surfaces. The rules-based fallback and the LLM_MODE=off kill switch keep the app crash-proof. --- README.md | 12 +- api/llm-recommend.ts | 44 ++- public/logos/btl-runtime.svg | 8 + src/components/BtlBadge.css | 31 ++ src/components/BtlBadge.tsx | 41 +++ src/components/btl/AiCostChip.tsx | 35 +++ src/components/btl/BtlExplanation.tsx | 267 ++++++++++++++++++ src/components/btl/SlotAnnotationChips.tsx | 101 +++++++ src/components/btl/ThinkingIndicator.tsx | 84 ++++++ src/components/explorer/ContractDiff.tsx | 125 +++++++- .../explorer/StorageLayoutViewer.tsx | 107 +++++++ .../integrations/lifi-earn/DepositFlow.tsx | 126 ++++++++- .../integrations/lifi-earn/buildDepositTx.ts | 33 +++ .../lifi-earn/concierge/BtlRuntimePanel.tsx | 32 +++ .../lifi-earn/concierge/IdleSweepPanel.tsx | 8 + .../lifi-earn/concierge/LlmErrorAlert.tsx | 10 +- .../concierge/VaultRecommendations.tsx | 2 + .../hooks/useVaultRecommendations.ts | 86 +----- .../concierge/intent/IntentPanel.tsx | 72 ++++- .../concierge/intent/hooks/useIntentParser.ts | 87 +----- .../intent/hooks/useIntentRecommendation.ts | 86 +----- .../integrations/lifi-earn/concierge/types.ts | 2 + .../integrations/lifi-earn/earnApi.ts | 5 +- .../simulation-results/SummaryTab.tsx | 51 ++++ src/components/smart-decoder/SmartDecoder.tsx | 85 ++++++ src/lib/btl/agent.ts | 37 +++ src/lib/btl/client.ts | 80 ++++++ src/lib/btl/models.ts | 25 ++ src/lib/btl/useBtlExplain.ts | 47 +++ vite.config.ts | 27 +- 30 files changed, 1490 insertions(+), 266 deletions(-) create mode 100644 public/logos/btl-runtime.svg create mode 100644 src/components/BtlBadge.css create mode 100644 src/components/BtlBadge.tsx create mode 100644 src/components/btl/AiCostChip.tsx create mode 100644 src/components/btl/BtlExplanation.tsx create mode 100644 src/components/btl/SlotAnnotationChips.tsx create mode 100644 src/components/btl/ThinkingIndicator.tsx create mode 100644 src/components/integrations/lifi-earn/buildDepositTx.ts create mode 100644 src/components/integrations/lifi-earn/concierge/BtlRuntimePanel.tsx create mode 100644 src/lib/btl/agent.ts create mode 100644 src/lib/btl/client.ts create mode 100644 src/lib/btl/models.ts create mode 100644 src/lib/btl/useBtlExplain.ts diff --git a/README.md b/README.md index becc606a..648c815a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The application pairs a React frontend with a local Rust-powered EDB (EVM Debugg | Styling | Tailwind CSS v4, shadcn/ui | | Web3 | ethers v5, viem, wagmi, RainbowKit | | Simulation | REVM (Rust), EDB engine, WebSocket bridge | -| Integrations | LI.FI SDK (Earn API, Composer), Gemini LLM | +| Integrations | LI.FI SDK (Earn API, Composer), BTL Runtime | | API Proxies | Vercel Serverless Functions | | Testing | Vitest, Testing Library | @@ -83,7 +83,7 @@ A full yield management layer powered by the LI.FI Earn API: An AI assistant that translates natural language yield goals into actionable vault recommendations: -- **Intent Parser** -- Gemini LLM converts free-text prompts ("safest USDC vault above 5% on Arbitrum") into structured filters (token, chain, APY range, objective, protocol allow/deny lists). +- **Intent Parser** -- BTL Runtime converts free-text prompts ("safest USDC vault above 5% on Arbitrum") into structured filters (token, chain, APY range, objective, protocol allow/deny lists). - **My Assets Mode** -- Say "best vaults for my assets" and the concierge fans out per-asset recommendations for every idle token in the connected wallet. - **Consolidate Mode** -- Say "best vault for my assets" (singular) and the concierge finds the top vault candidates to funnel all holdings into a single position via cross-chain swaps. - **Idle Sweep** -- Detects wallet tokens sitting idle (not earning yield) and suggests the best vault for each, with one-click deposit. @@ -170,9 +170,9 @@ For the LI.FI Earn integration and AI concierge, set the following in `.env`: | Variable | Purpose | |----------|---------| | `LIFI_API_KEY` | LI.FI API key for Earn and Composer endpoints | -| `GEMINI_API_KEY` | Google AI Studio API key for the yield concierge LLM | -| `GEMINI_MODEL` | Primary Gemini model (default: `gemini-3.1-pro-preview`) | -| `GEMINI_FALLBACK_MODEL` | Fallback on 429/503 (default: `gemini-2.5-flash`) | +| `BTL_API_KEY` | BTL Runtime API key for the yield concierge LLM | +| `BTL_MODEL` | BTL model (default: `deepseek-v4-flash`) | +| `BTL_BASE_URL` | BTL Runtime base URL (default: `https://api.badtheorylabs.com`) | | `PROXY_SECRET` | Shared secret for API proxy authentication (production) | | `ALLOWED_ORIGINS` | Comma-separated allowed CORS origins (production) | @@ -229,7 +229,7 @@ src/ lib/ Shared libraries api/ - llm-recommend.ts Gemini LLM proxy with model fallback + llm-recommend.ts BTL Runtime proxy with model fallback lifi-composer.ts LI.FI Composer quote/execute proxy edb/ EDB simulation API routes diff --git a/api/llm-recommend.ts b/api/llm-recommend.ts index 90f9cf7d..3a26cc10 100644 --- a/api/llm-recommend.ts +++ b/api/llm-recommend.ts @@ -6,8 +6,9 @@ export const config = { maxDuration: 60, }; -const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-2.5-flash-lite"; -const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; +const BTL_MODEL = process.env.BTL_MODEL || "deepseek-v4-flash"; +const BTL_API_KEY = process.env.BTL_API_KEY || ""; +const BTL_BASE_URL = process.env.BTL_BASE_URL || "https://api.badtheorylabs.com"; const ALLOWED_METHODS = new Set(["POST", "OPTIONS"]); const ALLOWED_ORIGINS = new Set( @@ -68,8 +69,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(400).json({ error: "Missing JSON body" }); } - if (!Array.isArray((body as any).contents)) { - return res.status(400).json({ error: "Body must include `contents` array" }); + if (!Array.isArray((body as any).messages)) { + return res.status(400).json({ error: "Body must include `messages` array" }); } const serialized = JSON.stringify(body); @@ -77,32 +78,47 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(413).json({ error: "Request body too large" }); } - if (!GEMINI_API_KEY) { - return res.status(500).json({ error: "No GEMINI_API_KEY configured" }); + if (!BTL_API_KEY) { + return res.status(500).json({ error: "No BTL_API_KEY configured" }); } - const geminiHeaders: Record = { + const btlHeaders: Record = { "Content-Type": "application/json", - "x-goog-api-key": GEMINI_API_KEY, + Authorization: `Bearer ${BTL_API_KEY}`, }; - const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; + const url = `${BTL_BASE_URL}/v1/chat/completions`; try { const upstreamRes = await fetch(url, { method: "POST", - headers: geminiHeaders, + headers: btlHeaders, body: serialized, signal: AbortSignal.timeout(55_000), }); const text = await upstreamRes.text(); - if (allowedOrigin) { - res.setHeader("Access-Control-Allow-Origin", allowedOrigin); - } + if (allowedOrigin) res.setHeader("Access-Control-Allow-Origin", allowedOrigin); res.setHeader("Content-Type", "application/json"); - res.setHeader("X-Gemini-Model", GEMINI_MODEL); + + // Forward BTL cost/routing headers so the browser can render AiCostChip. + const BTL_HEADERS = [ + "x-btl-benchmark-cost", + "x-btl-customer-charge", + "x-btl-saved", + "x-gateway-fee-pct", + "x-gateway-cost", + "x-request-id", + ]; + for (const h of BTL_HEADERS) { + const v = upstreamRes.headers.get(h); + if (v) res.setHeader(h, v); + } + const requestedModel = (body as any)?.model || BTL_MODEL; + res.setHeader("X-BTL-Model", String(requestedModel)); + res.setHeader("Access-Control-Expose-Headers", [...BTL_HEADERS, "x-btl-model"].join(", ")); + return res.status(upstreamRes.status).send(text); } catch (err: any) { console.error("[llm-recommend] upstream error:", err); diff --git a/public/logos/btl-runtime.svg b/public/logos/btl-runtime.svg new file mode 100644 index 00000000..8e1ad4e2 --- /dev/null +++ b/public/logos/btl-runtime.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/BtlBadge.css b/src/components/BtlBadge.css new file mode 100644 index 00000000..1814fdc9 --- /dev/null +++ b/src/components/BtlBadge.css @@ -0,0 +1,31 @@ +.btl-badge { + display: inline-flex; + align-items: center; + gap: 7px; + text-decoration: none; + color: #b8b8b8; + transition: color 0.15s ease, opacity 0.15s ease; + line-height: 1; +} +.btl-badge:hover { + color: #f2f2f0; +} +.btl-badge-logo { + display: block; + flex-shrink: 0; + border-radius: 5px; + /* The mark is a near-black rounded square; a hairline ring defines its + edge on HexKit's dark background so it reads as a real logo, not a blob. */ + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16); +} +.btl-badge:hover .btl-badge-logo { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32); +} +.btl-badge-label { + font-family: ui-monospace, "JetBrains Mono", "Geist Mono", Menlo, monospace; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 600; + white-space: nowrap; +} diff --git a/src/components/BtlBadge.tsx b/src/components/BtlBadge.tsx new file mode 100644 index 00000000..4171a0d8 --- /dev/null +++ b/src/components/BtlBadge.tsx @@ -0,0 +1,41 @@ +import "./BtlBadge.css"; + +const BTL_URL = "https://runtime.badtheorylabs.com"; + +interface BtlBadgeProps { + className?: string; + showLabel?: boolean; +} + +/** + * "Powered by BTL" strip — shown wherever the app calls the BTL Runtime, + * mirroring EdbBadge. Uses the official BTL Runtime mark (public/logos). + */ +export function BtlBadge({ className = "", showLabel = true }: BtlBadgeProps) { + return ( + + + {showLabel && ( + + Powered by BTL + + )} + + ); +} + +export default BtlBadge; diff --git a/src/components/btl/AiCostChip.tsx b/src/components/btl/AiCostChip.tsx new file mode 100644 index 00000000..b22cd1ef --- /dev/null +++ b/src/components/btl/AiCostChip.tsx @@ -0,0 +1,35 @@ +import { Receipt } from "@phosphor-icons/react"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; + +/** + * A per-call cost receipt — the cost-transparency differentiator. The $ amount + * is the headline (green "≈ $0" for the free routes); model + fee are metadata. + */ +export function AiCostChip({ meta }: { meta?: BtlRuntimeMeta | null }) { + if (!meta || meta.customerChargeUsd == null) return null; + const free = meta.customerChargeUsd < 0.0001; + const cost = free ? "≈ $0" : `$${meta.customerChargeUsd.toFixed(4)}`; + return ( + + + + {cost} + + {meta.model && ( + <> + · + {meta.model} + + )} + {meta.feePct != null && ( + <> + · + {meta.feePct}% gw + + )} + + ); +} diff --git a/src/components/btl/BtlExplanation.tsx b/src/components/btl/BtlExplanation.tsx new file mode 100644 index 00000000..44f84b58 --- /dev/null +++ b/src/components/btl/BtlExplanation.tsx @@ -0,0 +1,267 @@ +import { useState, type ReactNode } from "react"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import { CaretDown, Sparkle, CircleNotch } from "@phosphor-icons/react"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; +import { AiCostChip } from "@/components/btl/AiCostChip"; +import BtlBadge from "@/components/BtlBadge"; +import ThinkingIndicator from "@/components/btl/ThinkingIndicator"; + +interface BtlExplanationProps { + text: string | null; + meta: BtlRuntimeMeta | null; + loading?: boolean; + error?: string | null; + /** Header label, e.g. "AI explanation" / "Upgrade audit" / "Slot annotations". */ + title?: string; + /** Retry handler; renders a small retry affordance on error when provided. */ + onRetry?: () => void; + defaultOpen?: boolean; + /** Custom body (e.g. slot chips). When set, rendered instead of the markdown. */ + children?: ReactNode; +} + +/** + * Shared, collapsible panel for any BTL freeform explanation (the weave + * outputs). Renders the model's markdown (headers, bold, lists, tables) and + * folds up/down. Carries the cost chip in the header and the "AI-assisted" + * note + BTL badge in the footer. + */ +export function BtlExplanation({ + text, + meta, + loading = false, + error = null, + title = "AI explanation", + onRetry, + defaultOpen = true, + children, +}: BtlExplanationProps) { + const [open, setOpen] = useState(defaultOpen); + if (!loading && !error && !text && !children) return null; + + return ( + + + + + {title} + + {loading && ( + + )} + + {meta && } + + + + +
+ {loading && !text && !children ? ( + + ) : error && !text && !children ? ( +

+ {error}{" "} + {onRetry && ( + + )} +

+ ) : children ? ( + children + ) : ( + + )} + +
+ AI-assisted — verify. + +
+
+
+
+ ); +} + +// ── Lightweight markdown renderer ──────────────────────────────────────────── +// Handles what the BTL weaves actually emit: ## / ### headers, **bold**, +// `inline code`, "- " bullet lists (incl. 2-space nesting), and | pipe tables |. +// Not a full markdown engine — intentionally small and dependency-free. + +function Markdown({ source }: { source: string }): ReactNode { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const blocks: ReactNode[] = []; + let i = 0; + let key = 0; + + const isTableRow = (l: string) => /^\s*\|.*\|\s*$/.test(l); + const isDivider = (l: string) => /^\s*\|?[\s:|-]+\|?\s*$/.test(l) && l.includes("-"); + + while (i < lines.length) { + const line = lines[i]; + + // Blank line + if (line.trim() === "") { + i++; + continue; + } + + // Table: header row + divider + body rows + if (isTableRow(line) && i + 1 < lines.length && isDivider(lines[i + 1])) { + const header = splitRow(line); + const rows: string[][] = []; + i += 2; + while (i < lines.length && isTableRow(lines[i])) { + rows.push(splitRow(lines[i])); + i++; + } + blocks.push( +
+ + + + {header.map((h, hi) => ( + + ))} + + + + {rows.map((r, ri) => ( + + {r.map((c, ci) => ( + + ))} + + ))} + +
+ {inline(h)} +
+ {inline(c)} +
+
, + ); + continue; + } + + // Headers + const h = line.match(/^(#{1,6})\s+(.*)$/); + if (h) { + const level = h[1].length; + blocks.push( +

+ {inline(h[2])} +

, + ); + i++; + continue; + } + + // Bullet list + if (/^\s*[-*]\s+/.test(line)) { + const items: { depth: number; text: string }[] = []; + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { + const m = lines[i].match(/^(\s*)[-*]\s+(.*)$/)!; + items.push({ depth: Math.floor(m[1].length / 2), text: m[2] }); + i++; + } + blocks.push( +
    + {items.map((it, ii) => ( +
  • + + {inline(it.text)} +
  • + ))} +
, + ); + continue; + } + + // Horizontal rule + if (/^\s*---+\s*$/.test(line)) { + blocks.push(
); + i++; + continue; + } + + // Paragraph + blocks.push( +

+ {inline(line)} +

, + ); + i++; + } + + return
{blocks}
; +} + +function splitRow(row: string): string[] { + return row + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((c) => c.trim()); +} + +/** Inline formatting: **bold** and `code`. */ +function inline(text: string): ReactNode { + const parts: ReactNode[] = []; + const re = /(\*\*[^*]+\*\*|`[^`]+`)/g; + let last = 0; + let m: RegExpExecArray | null; + let k = 0; + while ((m = re.exec(text)) !== null) { + if (m.index > last) parts.push(text.slice(last, m.index)); + const tok = m[0]; + if (tok.startsWith("**")) { + parts.push( + + {tok.slice(2, -2)} + , + ); + } else { + parts.push( + + {tok.slice(1, -1)} + , + ); + } + last = m.index + tok.length; + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + +export default BtlExplanation; diff --git a/src/components/btl/SlotAnnotationChips.tsx b/src/components/btl/SlotAnnotationChips.tsx new file mode 100644 index 00000000..c5d8d0f6 --- /dev/null +++ b/src/components/btl/SlotAnnotationChips.tsx @@ -0,0 +1,101 @@ +import { Warning } from "@phosphor-icons/react"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, +} from "@/components/ui/tooltip"; + +export interface SlotAnnotation { + slot?: string | null; + label?: string | null; + note: string; + unusual?: boolean; +} + +function shortSlot(slot?: string | null): string { + if (!slot) return "?"; + if (slot.startsWith("0x") && slot.length > 12) { + return `${slot.slice(0, 6)}…${slot.slice(-4)}`; + } + return slot; +} + +/** + * Storage slot annotations as hoverable chips — one per slot, the model's note + * revealed on hover. "Unusual" slots get an amber accent so anomalies pop. + */ +export function SlotAnnotationChips({ + slots, + summary, +}: { + slots: SlotAnnotation[]; + summary?: string | null; +}) { + if (!slots || slots.length === 0) { + return

No slot annotations.

; + } + const unusualCount = slots.filter((s) => s.unusual).length; + // Surface anomalies first so a judge sees them the instant the panel renders. + const ordered = [...slots].sort((a, b) => Number(!!b.unusual) - Number(!!a.unusual)); + + return ( +
+

+ Hover a slot for its annotation. + {unusualCount > 0 && ( + + {unusualCount} flagged unusual. + + )} +

+ + +
+ {ordered.map((s, i) => ( + + + + + +
+
+ slot {s.slot || "?"} + {s.label ? ` · ${s.label}` : ""} +
+ {s.unusual && ( +
+ Flagged unusual +
+ )} +

{s.note}

+
+
+
+ ))} +
+
+ + {summary && ( +

+ Summary: {summary} +

+ )} +
+ ); +} + +export default SlotAnnotationChips; diff --git a/src/components/btl/ThinkingIndicator.tsx b/src/components/btl/ThinkingIndicator.tsx new file mode 100644 index 00000000..0204f4d7 --- /dev/null +++ b/src/components/btl/ThinkingIndicator.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; + +const THINKING_WORDS = [ + "Pondering", + "Analyzing", + "Searching", + "Evaluating", + "Reasoning", + "Inspecting", +]; +const STAR_COLORS = ["#e87461", "#d4a054", "#c084fc", "#60a5fa", "#34d399", "#f472b6"]; + +/** Typewriter hook: types out the current word, pauses, erases, moves on. */ +function useThinkingLabel(): string { + const [wordIndex, setWordIndex] = useState(0); + const [charIndex, setCharIndex] = useState(0); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + const word = THINKING_WORDS[wordIndex]; + if (!deleting) { + if (charIndex < word.length) { + const id = setTimeout(() => setCharIndex((c) => c + 1), 60); + return () => clearTimeout(id); + } + const id = setTimeout(() => setDeleting(true), 1400); + return () => clearTimeout(id); + } + if (charIndex > 0) { + const id = setTimeout(() => setCharIndex((c) => c - 1), 30); + return () => clearTimeout(id); + } + setDeleting(false); + setWordIndex((i) => (i + 1) % THINKING_WORDS.length); + }, [wordIndex, charIndex, deleting]); + + return THINKING_WORDS[wordIndex].slice(0, charIndex); +} + +/** Twinkling ✦ star field — small color-cycling glyphs. */ +function TwinklingField() { + const stars = [ + { x: 2, y: 3, s: 0 }, { x: 10, y: 1, s: 0.3 }, { x: 6, y: 8, s: 0.6 }, + { x: 14, y: 5, s: 0.9 }, { x: 1, y: 10, s: 0.4 }, { x: 12, y: 11, s: 0.7 }, + ]; + return ( + + {stars.map((s, i) => ( + + ✦ + + ))} + + + ); +} + +/** + * Animated "AI is working" indicator — a twinkling star field + a typewriter + * word cycling through synonyms for thinking. Shared by the BTL weave panels. + */ +export function ThinkingIndicator() { + const label = useThinkingLabel(); + return ( + + + + {label || "Thinking"} + + + + ); +} + +export default ThinkingIndicator; diff --git a/src/components/explorer/ContractDiff.tsx b/src/components/explorer/ContractDiff.tsx index 4a31d606..5ca5df4f 100644 --- a/src/components/explorer/ContractDiff.tsx +++ b/src/components/explorer/ContractDiff.tsx @@ -3,14 +3,34 @@ import { useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { GitDiff, CircleNotch, Copy, Check } from '@phosphor-icons/react'; +import { GitDiff, CircleNotch, Copy, Check, Sparkle } from '@phosphor-icons/react'; import { getChainById } from '@/utils/chains'; import { getSharedProvider } from '@/utils/providerPool'; import { prepareBytecode, diffHexChars, type NormalizeMode, type DiffChar } from '@/utils/bytecodeDiff'; import NetworkSelector, { EXTENDED_NETWORKS, type ExtendedChain } from '@/components/shared/NetworkSelector'; +import { resolveContractContext, type ContractContext } from '@/utils/resolver/contractContext'; +import { useBtlExplain } from '@/lib/btl/useBtlExplain'; +import BtlExplanation from '@/components/btl/BtlExplanation'; import { isAddress } from 'ethers/lib/utils'; import '@/styles/ContractDiff.css'; +const LLM_MODE = + (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? + "live"; + +// Per-side source cap: keeps the combined V1+V2 body well under the 64KB +// proxy limit even for large contracts (ABI signatures aren't capped here, +// only the raw source text). +const SOURCE_CHAR_CAP = 8_000; + +function buildSourceSnippet(ctx: ContractContext): string { + if (!ctx.metadata?.sources) return ''; + return Object.entries(ctx.metadata.sources) + .map(([path, content]) => `// ${path}\n${content}`) + .join('\n\n') + .slice(0, SOURCE_CHAR_CAP); +} + interface BytecodeSide { address: string; network: ExtendedChain; @@ -160,6 +180,75 @@ const ContractDiff: React.FC = () => { fetchBytecode(right.address, right.network, setRight); }; + const [auditFetching, setAuditFetching] = useState(false); + const { + explain: explainAudit, + text: auditText, + meta: auditMeta, + loading: auditExplainLoading, + error: auditError, + } = useBtlExplain(); + const auditLoading = auditFetching || auditExplainLoading; + + const handleAudit = useCallback(async () => { + if (!leftPrepared || !rightPrepared || !diff) return; + setAuditFetching(true); + try { + const leftChain = getChainById(left.network.id); + const rightChain = getChainById(right.network.id); + + const [leftCtx, rightCtx] = await Promise.all([ + leftChain + ? resolveContractContext(left.address, leftChain, { abi: true }) + : Promise.resolve(null), + rightChain + ? resolveContractContext(right.address, rightChain, { abi: true }) + : Promise.resolve(null), + ]); + + const bothVerified = + !!leftCtx?.verified && !!leftCtx.metadata?.sources && + !!rightCtx?.verified && !!rightCtx.metadata?.sources; + + let userText: string; + if (bothVerified && leftCtx && rightCtx) { + const payload = { + sourceAvailable: true, + v1: { + name: leftCtx.name, + readFunctions: leftCtx.functions.read.map((f) => f.signature), + writeFunctions: leftCtx.functions.write.map((f) => f.signature), + source: buildSourceSnippet(leftCtx), + }, + v2: { + name: rightCtx.name, + readFunctions: rightCtx.functions.read.map((f) => f.signature), + writeFunctions: rightCtx.functions.write.map((f) => f.signature), + source: buildSourceSnippet(rightCtx), + }, + }; + userText = JSON.stringify(payload).slice(0, 60_000); + } else { + const payload = { + sourceAvailable: false, + diffCount: diff.diffCount, + v1ByteLength: leftPrepared.byteLength, + v2ByteLength: rightPrepared.byteLength, + v1StrippedMetadataBytes: leftPrepared.strippedMetadataBytes, + v2StrippedMetadataBytes: rightPrepared.strippedMetadataBytes, + }; + userText = JSON.stringify(payload); + } + + await explainAudit( + "You are a smart-contract upgrade auditor. Compare V1 and V2 and summarize what changed, flagging risky changes (access-control regressions, fee/param bumps, new privileged or pausable functions, changed ownership). Present the risky changes as a markdown table with columns: Risk | Evidence | Why it matters | Action. If only bytecode is available, say so and keep it structural. Always end with 'AI-assisted — verify before trusting.'", + userText, + ); + } finally { + setAuditFetching(false); + } + }, [leftPrepared, rightPrepared, diff, left.address, left.network.id, right.address, right.network.id, explainAudit]); + return (
@@ -240,6 +329,40 @@ const ContractDiff: React.FC = () => { same
+ + {LLM_MODE !== 'off' && ( +
+ + + {(auditText || auditLoading || auditError) && ( + + )} +
+ )} )} diff --git a/src/components/explorer/StorageLayoutViewer.tsx b/src/components/explorer/StorageLayoutViewer.tsx index b893b53f..b250d92b 100644 --- a/src/components/explorer/StorageLayoutViewer.tsx +++ b/src/components/explorer/StorageLayoutViewer.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { CheckCircle, EyeSlash, + Sparkle, + CircleNotch, } from '@phosphor-icons/react'; import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; import ContractAddressInput from '../contract/ContractAddressInput'; import { getExplorerChains } from '../../utils/chains'; import StorageSlotGraph from './storage-viewer/StorageSlotGraph'; @@ -12,6 +15,18 @@ import { StorageToolbar } from './StorageToolbar'; import { StorageTableView } from './StorageTableView'; import { TreePanel } from './TreePanel'; import { useStorageViewerState } from './useStorageViewerState'; +import { useBtlExplain } from '@/lib/btl/useBtlExplain'; +import { safeParseJson } from '@/lib/btl/client'; +import BtlExplanation from '@/components/btl/BtlExplanation'; +import { SlotAnnotationChips, type SlotAnnotation } from '@/components/btl/SlotAnnotationChips'; + +const LLM_MODE = + (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? + "live"; + +// Cap the annotated slot count so the request body stays well under the 64KB +// proxy limit even for contracts with hundreds of resolved slots. +const ANNOTATE_ROW_CAP = 30; const StorageLayoutViewer: React.FC = () => { const state = useStorageViewerState(); @@ -19,6 +34,52 @@ const StorageLayoutViewer: React.FC = () => { // source/ABI data and would otherwise fall over with "No … API available". const explorerChains = React.useMemo(() => getExplorerChains(), []); + const { + explain: explainSlots, + text: slotAnnotations, + meta: slotAnnotationsMeta, + loading: annotateLoading, + error: annotateError, + } = useBtlExplain({ jsonMode: true, maxTokens: 2000 }); + + // BTL returns structured per-slot JSON; parse it into chips. On a parse + // miss we fall back to showing the raw text so nothing is lost. + const parsedAnnotations = React.useMemo((): { slots: SlotAnnotation[]; summary: string | null } | null => { + if (!slotAnnotations) return null; + const json = safeParseJson(slotAnnotations) as { slots?: unknown; summary?: unknown } | null; + if (!json || !Array.isArray(json.slots)) return null; + const slots = json.slots + .filter((s): s is Record => !!s && typeof s === 'object' && typeof (s as { note?: unknown }).note === 'string') + .map((s) => ({ + slot: (s.slot as string) ?? null, + label: (s.label as string) ?? null, + note: s.note as string, + unusual: !!s.unusual, + })); + return { slots, summary: typeof json.summary === 'string' ? json.summary : null }; + }, [slotAnnotations]); + + const handleAnnotateSlots = () => { + const rows = state.displayRows.slice(0, ANNOTATE_ROW_CAP).map((row) => ({ + slot: row.slot, + label: row.label ?? null, + typeLabel: row.typeLabel ?? null, + value: row.value ?? null, + decodedFields: row.decodedFields ?? null, + })); + const payload = { + contractName: state.contractMeta?.name ?? null, + slots: rows, + }; + const userText = JSON.stringify(payload).slice(0, 60_000); + void explainSlots( + 'You are a storage-layout analyst. Given a contract\'s resolved storage slots, return ONLY a JSON object of this exact shape: ' + + '{ "slots": [ { "slot": string (the slot key), "label": string (the variable name, or "" if none), "note": string (ONE concise plain-English sentence: what this slot holds), "unusual": boolean (true only if the slot is odd, mislabeled, suspect, or noteworthy) } ], "summary": string (one sentence overall) }. ' + + "Include exactly one entry per input slot, in the same order. Return JSON only — no prose, no code fences.", + userText, + ); + }; + return ( <>
@@ -65,6 +126,52 @@ const StorageLayoutViewer: React.FC = () => { setSlotGraphOpen={state.setSlotGraphOpen} /> )} + + {state.hasData && state.displayRows.length > 0 && LLM_MODE !== 'off' && ( +
+ + + {(slotAnnotations || annotateLoading || annotateError) && ( + + {parsedAnnotations ? ( + + ) : slotAnnotations ? ( + // Parse miss — show raw text so nothing is lost. +

+ {slotAnnotations} +

+ ) : null} +
+ )} +
+ )}
{state.filter === 'resolved' && state.stats.unknown > 0 && ( diff --git a/src/components/integrations/lifi-earn/DepositFlow.tsx b/src/components/integrations/lifi-earn/DepositFlow.tsx index d2817b78..7437ca85 100644 --- a/src/components/integrations/lifi-earn/DepositFlow.tsx +++ b/src/components/integrations/lifi-earn/DepositFlow.tsx @@ -29,13 +29,23 @@ import type { AssetMovementResult } from "../../../utils/transaction-simulation/ import { getCachedTokenMetadata, fetchTokenMetadata } from "../../../utils/tokenMovements"; import { networkConfigManager } from "../../../config/networkConfig"; import { useComposerQuote } from "./hooks/useComposerQuote"; -import { fetchComposerQuote } from "./earnApi"; +import { fetchComposerQuote, postLlmRecommend } from "./earnApi"; +import { buildDepositTx } from "./buildDepositTx"; import { useTokenAllowance } from "./hooks/useTokenAllowance"; import { useTokenBalance } from "./hooks/useTokenBalance"; import { TokenIcon } from "./TokenIcon"; import type { EarnToken, EarnVault } from "./types"; import { formatTxError, shortAddress, isNativeToken } from "./txUtils"; import EdbBadge from "../../EdbBadge"; +import { runBtlAgent, type BtlTool } from "@/lib/btl/agent"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; +import BtlExplanation from "@/components/btl/BtlExplanation"; + +// "live"/"fixture" call the LLM (or read fixtures); "off" hides the AI +// preflight entirely so the deposit flow degrades to manual-only. +const LLM_MODE = + (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? + "live"; type FlowState = | "idle" @@ -234,6 +244,10 @@ export function DepositFlow({ }); const [simulateFirst, setSimulateFirst] = useState(false); const [twoStepLabel, setTwoStepLabel] = useState(null); + const [preflightText, setPreflightText] = useState(null); + const [preflightMeta, setPreflightMeta] = useState(null); + const [preflightRan, setPreflightRan] = useState(false); + const [preflightLoading, setPreflightLoading] = useState(false); // Reset state when vault/override changes so reopening the drawer for a // different vault doesn't leak stale state. @@ -254,6 +268,10 @@ export function DepositFlow({ setSpenderCheck({ status: "idle" }); setSimulateFirst(false); setTwoStepLabel(null); + setPreflightText(null); + setPreflightMeta(null); + setPreflightRan(false); + setPreflightLoading(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ vault.slug, @@ -615,6 +633,62 @@ export function DepositFlow({ flowState, ]); + // Tool the AI preflight agent calls — mirrors handleSimulate's path exactly + // (refetch quote → build tx → simulate) so the agent's numbers are always + // backed by a real REVM sim of the live, in-scope quote. + const simulateDepositTool: BtlTool = { + name: "simulate_deposit", + description: + "Simulate the currently-quoted deposit on the real REVM engine and return asset movements + gas.", + parameters: { type: "object", properties: {}, additionalProperties: false }, + run: async () => { + const { data: freshQuote } = await refetchQuote(); + const q = freshQuote ?? quote; + if (!q || !supportedChain || !address) return { error: "no quote available yet" }; + const tx = buildDepositTx(q); + const result = await simulateAssetMovements(tx, supportedChain, address); + return { + success: result.success, + gasUsed: result.gasUsed, + movements: result.movements, + error: result.error, + }; + }, + }; + + async function handlePreflight() { + if (!quote) return; + setPreflightLoading(true); + try { + const { finalText, metas, toolRuns } = await runBtlAgent({ + system: + "You are HexKit's deposit co-pilot. CALL simulate_deposit, then explain in one or two sentences: is a token approval needed?, the net asset movement, and gas. Be concrete; never invent numbers the sim didn't return.", + userText: `Preflight my deposit of ${amount} ${selectedToken?.symbol ?? ""} into ${vault.name} (${vault.slug}).`, + tools: [simulateDepositTool], + callBtl: postLlmRecommend, + }); + if (finalText === null) { + setPreflightText( + "Couldn't complete the AI preflight — you can still run a manual simulation.", + ); + setPreflightMeta(null); + setPreflightRan(false); + } else { + setPreflightText(finalText); + setPreflightMeta(metas.at(-1) ?? null); + setPreflightRan(toolRuns.some((r) => r.name === "simulate_deposit")); + } + } catch { + setPreflightText( + "Couldn't complete the AI preflight — you can still run a manual simulation.", + ); + setPreflightMeta(null); + setPreflightRan(false); + } finally { + setPreflightLoading(false); + } + } + async function handleSimulate() { if (!quote || !supportedChain || !address) return; @@ -626,13 +700,7 @@ export function DepositFlow({ const { data: freshQuote } = await refetchQuote(); const q = freshQuote ?? quote; - const tx = { - to: q.transactionRequest.to, - data: q.transactionRequest.data, - value: q.transactionRequest.value, - gasLimit: q.transactionRequest.gasLimit, - gasPrice: q.transactionRequest.gasPrice, - }; + const tx = buildDepositTx(q); const result = await simulateAssetMovements(tx, supportedChain, address); setSimResult(result); @@ -1469,6 +1537,48 @@ export function DepositFlow({ )} + {LLM_MODE !== "off" && ( +
+ + + {(preflightText || preflightLoading) && ( + + {preflightRan && ( + + simulated on REVM + + )} + + + )} +
+ )} + {flowState === "error" && errorMsg && ( m.customerChargeUsd != null); + if (priced.length === 0) return null; + const total = priced.reduce((s, m) => s + (m.customerChargeUsd ?? 0), 0); + const models = Array.from(new Set(priced.map((m) => m.model).filter(Boolean))); + const free = total < 0.0001; + return ( +
+ +
+
+ BTL Runtime · {priced.length} {priced.length === 1 ? "call" : "calls"} +
+
+ {models.join(" · ") || "—"} +
+
+
+ {free ? "≈ $0" : `$${total.toFixed(4)}`} +
+
+ ); +} diff --git a/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx b/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx index 68aff04e..78dc5187 100644 --- a/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx +++ b/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx @@ -10,6 +10,8 @@ import { DestinationPicker } from "./DestinationPicker"; import { ExecutionQueue } from "./ExecutionQueue"; import { FlowDiagram, type RoutingMode } from "./FlowDiagram"; import { LlmErrorAlert } from "./LlmErrorAlert"; +import { BtlRuntimePanel } from "./BtlRuntimePanel"; +import BtlBadge from "@/components/BtlBadge"; import { EXTENDED_NETWORKS, type ExtendedChain, @@ -27,6 +29,7 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import type { IdleAsset, SelectedSource } from "./types"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; import type { EarnVault } from "../types"; interface IdleSweepPanelProps { @@ -113,6 +116,7 @@ export function IdleSweepPanel({ targetAddress }: IdleSweepPanelProps) { }); const recommendations = recsData?.recommendations ?? []; const llmError = recsData?.llmError ?? null; + const btlMetas = recommendations.map((r) => r.meta).filter(Boolean) as BtlRuntimeMeta[]; const onToggle = useCallback((asset: IdleAsset, on: boolean) => { setSelections((prev) => { @@ -538,6 +542,10 @@ export function IdleSweepPanel({ targetAddress }: IdleSweepPanelProps) { onRetry={refetchRecs} isRetrying={recsFetching} /> + + {btlMetas.some((m) => m.customerChargeUsd != null) && ( + + )} , retryable: true, }; @@ -70,7 +70,7 @@ function classify(rawError: string): Classification { category: "auth", title: "Recommender not authorized", description: - "The AI proxy rejected our request. This usually means the GEMINI_API_KEY isn't set on the server or the Origin allow-list is misconfigured. Rules-based picks are still safe to use.", + "The AI proxy rejected our request. This usually means the BTL_API_KEY isn't set on the server or the Origin allow-list is misconfigured. Rules-based picks are still safe to use.", icon: , retryable: false, }; @@ -80,14 +80,14 @@ function classify(rawError: string): Classification { msg.includes("500") || msg.includes("502") || msg.includes("503") || - msg.includes("gemini_api_key") || + msg.includes("btl_api_key") || msg.includes("upstream") ) { return { category: "upstream", title: "Recommender is down", description: - "Gemini (or our proxy in front of it) returned an error. This usually clears up on its own — you can retry, or keep using the rules-based picks below.", + "The BTL Runtime (or our proxy in front of it) returned an error. This usually clears up on its own — you can retry, or keep using the rules-based picks below.", icon: , retryable: true, }; @@ -103,7 +103,7 @@ function classify(rawError: string): Classification { category: "schema", title: "Recommender returned something we couldn't parse", description: - "Gemini's response didn't match the shape we expect. We already retried once and fell back to rules-based picks — they're safe to use. Try again to see if a second call returns clean JSON.", + "The model's response didn't match the shape we expect. We already retried once and fell back to rules-based picks — they're safe to use. Try again to see if a second call returns clean JSON.", icon: , retryable: true, }; diff --git a/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx b/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx index 2a5f106d..b74b39cb 100644 --- a/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx +++ b/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx @@ -13,6 +13,7 @@ import { TooltipContent, } from "../../../../components/ui/tooltip"; import ChainIcon from "../../../icons/ChainIcon"; +import { AiCostChip } from "@/components/btl/AiCostChip"; import { VaultForecastButton } from "../simulator/VaultPositionSimulator"; import type { VaultRecommendation, @@ -143,6 +144,7 @@ export function VaultRecommendations({ > {rec.source === "ai" ? "AI" : "Rules"} + {rec.source === "ai" && }
diff --git a/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts b/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts index 9fc5cfb9..80c08f4f 100644 --- a/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts +++ b/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts @@ -2,6 +2,8 @@ import { useMemo } from "react"; import { useQueries } from "@tanstack/react-query"; import { formatUnits } from "viem"; import { postLlmRecommend } from "../../earnApi"; +import { buildBtlChatRequest, extractOpenAiText, safeParseJson } from "@/lib/btl/client"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; import type { EarnVault } from "../../types"; import type { IdleAsset, @@ -204,11 +206,12 @@ async function fetchRecommendationForAsset( const request = buildGeminiRequest([source], candidateMap); let parsed: LlmRecommendationResponse | null = null; + let meta: BtlRuntimeMeta | undefined; let lastError: string | null = null; for (let attempt = 0; attempt < 2; attempt++) { try { - const raw = await postLlmRecommend(request); - const text = extractGeminiText(raw); + const { data: raw, meta: btlMeta } = await postLlmRecommend(request); + const text = extractOpenAiText(raw); if (!text) throw new Error("empty LLM response"); const json = safeParseJson(text); if (!json) throw new Error("LLM did not return JSON"); @@ -219,6 +222,7 @@ async function fetchRecommendationForAsset( ); } parsed = result.data; + meta = btlMeta; lastError = null; break; } catch (err) { @@ -237,8 +241,9 @@ async function fetchRecommendationForAsset( } const merged = mergeLlmWithCandidates([asset], candidateMap, parsed, vaultPool); + const recBase = merged[0] ?? pickByRules(asset, candidates); const rec = enforceDistinctPicks( - merged[0] ?? pickByRules(asset, candidates), + recBase.source === "ai" && meta ? { ...recBase, meta } : recBase, candidates ); return { rec, llmError: null }; @@ -332,77 +337,12 @@ DECISION PRIORITY: }, }; - return { - contents: [ - { - role: "user", - parts: [ - { text: system }, - { - text: - "INPUT:\n```json\n" + - JSON.stringify(userPayload, null, 2) + - "\n```\n\nReturn ONLY the JSON object matching required_output_shape. No prose, no code fences.", - }, - ], - }, - ], - generationConfig: { - responseMimeType: "application/json", - temperature: 0.2, - }, - }; -} + const userText = + "INPUT:\n```json\n" + + JSON.stringify(userPayload, null, 2) + + "\n```\n\nReturn ONLY the JSON object matching required_output_shape. No prose, no code fences."; -function extractGeminiText(raw: unknown): string | null { - // Gemini 3 Pro can return multi-part content with `thought: true` parts - // before the answer — concatenate every non-thought text part. - try { - const r = raw as { - candidates?: Array<{ - content?: { - parts?: Array<{ text?: string; thought?: boolean }>; - }; - }>; - }; - const parts = r.candidates?.[0]?.content?.parts ?? []; - const joined = parts - .filter((p) => !p.thought && typeof p.text === "string") - .map((p) => p.text ?? "") - .join("") - .trim(); - return joined.length > 0 ? joined : null; - } catch { - return null; - } -} - -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - // Strip common noise: code fences, leading commentary - const stripped = text - .replace(/^```(?:json)?/i, "") - .replace(/```$/i, "") - .trim(); - try { - return JSON.parse(stripped); - } catch { - // Thinking models sometimes prepend prose before the JSON object. - // Find the first `{` and last `}` and try parsing that substring. - const first = stripped.indexOf("{"); - const last = stripped.lastIndexOf("}"); - if (first >= 0 && last > first) { - try { - return JSON.parse(stripped.slice(first, last + 1)); - } catch { - /* fall through */ - } - } - return null; - } - } + return buildBtlChatRequest(system, userText, { temperature: 0.2 }); } function mergeLlmWithCandidates( diff --git a/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx b/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx index c7320c9b..c97a96c6 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx +++ b/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx @@ -11,6 +11,11 @@ import { Warning, } from "@phosphor-icons/react"; import { Textarea } from "../../../../../components/ui/textarea"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "../../../../../components/ui/tooltip"; import ChainIcon from "../../../../icons/ChainIcon"; import { VaultRecommendations } from "../VaultRecommendations"; import { LlmErrorAlert } from "../LlmErrorAlert"; @@ -24,6 +29,10 @@ import { useIdleBalances } from "../hooks/useIdleBalances"; import { useIntentParser } from "./hooks/useIntentParser"; import { useVaultsByIntent } from "./hooks/useVaultsByIntent"; import { useIntentRecommendation, buildRecommendation } from "./hooks/useIntentRecommendation"; +import { BTL_AB_MODELS, BTL_DEFAULT_MODEL } from "@/lib/btl/models"; +import BtlBadge from "@/components/BtlBadge"; +import { AiCostChip } from "@/components/btl/AiCostChip"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; import type { ParsedIntent } from "./schema"; import type { EarnVault } from "../../types"; import type { IdleAsset, SelectedSource, VaultRecommendation } from "../types"; @@ -223,6 +232,23 @@ interface MultiRecResult { refetch: () => void; } +/** + * Fingerprints the parts of a ParsedIntent that shape the LLM prompt/ranking, + * so query cache keys invalidate when objective/filters change. + */ +function intentFingerprint(intent: ParsedIntent): string { + return [ + intent.objective, + intent.target_symbol ?? "", + intent.target_chain_id ?? "", + intent.min_apy_pct ?? "", + intent.max_apy_pct ?? "", + intent.min_tvl_usd ?? "", + intent.include_protocols.join("+"), + intent.exclude_protocols.join("+"), + ].join(":"); +} + /** * Batches multiple recommendation args into a single React Query call. Each * entry in `argsList` produces one independent recommendation (separate LLM @@ -235,7 +261,7 @@ function useMultiAssetRecommendations( () => argsList.map((a) => a - ? `${a.synthChainId}:${a.synthTokenAddress}:${a.sourceTokenSymbol ?? ""}:${a.rankedVaults.slice(0, 8).map((v) => v.slug).join(",")}` + ? `${a.synthChainId}:${a.synthTokenAddress}:${a.sourceTokenSymbol ?? ""}:${a.model ?? ""}:${intentFingerprint(a.intent)}:${a.rankedVaults.map((v) => v.slug).join(",")}` : "null", ).join("|"), [argsList], @@ -276,6 +302,10 @@ function useMultiAssetRecommendations( export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: IntentPanelProps) { const [text, setText] = useState(""); const [intent, setIntent] = useState(null); + // Cost/routing for the NL intent-parse BTL call (shown on the filters row). + const [parseMeta, setParseMeta] = useState(null); + // A/B model toggle — re-runs the recommendation on a different BTL provider. + const [selectedModel, setSelectedModel] = useState(BTL_DEFAULT_MODEL); const { address: walletAddress, isConnected } = useAccount(); const { data: chains = [] } = useEarnChains(); const { data: protocols = [] } = useEarnProtocols(); @@ -485,9 +515,10 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I intent, rankedVaults: effectiveRanked, walletAssets: idleAssets, + model: selectedModel, } : null, - [intent, synthAsset, effectiveRanked, idleAssets], + [intent, synthAsset, effectiveRanked, idleAssets, selectedModel], ); const { @@ -512,9 +543,10 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I walletAssets: idleAssets, sourceTokenSymbol: asset.token.symbol.toUpperCase(), sourceChainId: asset.chainId, + model: selectedModel, }; }); - }, [isMyAssetsMode, intent, dedupedAssets, perAssetVaults, perAssetIntents, idleAssets]); + }, [isMyAssetsMode, intent, dedupedAssets, perAssetVaults, perAssetIntents, idleAssets, selectedModel]); const perAssetRecs = useMultiAssetRecommendations(perAssetRecArgs); @@ -625,6 +657,7 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I try { const result = await parser.mutateAsync({ text, chains, protocols }); setIntent(result.intent); + setParseMeta(result.meta); } catch { // error surfaces via parser.error — nothing else to do here } @@ -637,6 +670,7 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I const handleReset = useCallback(() => { setIntent(null); + setParseMeta(null); parser.reset(); legDispatch({ type: "RESET" }); setSelectedConsolidateSlug(null); @@ -754,6 +788,10 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I (no specific criteria — ranking entire vault universe) )} + + {parseMeta && } + +
@@ -942,6 +980,34 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I ) : ( <> +
+ + +
+ + Model + + {BTL_AB_MODELS.map((m) => ( + + ))} +
+
+ + Re-run this recommendation on a different provider via one BTL endpoint + +
+
; - }; - }>; - }; - const parts = r.candidates?.[0]?.content?.parts ?? []; - const joined = parts - .filter((p) => !p.thought && typeof p.text === "string") - .map((p) => p.text ?? "") - .join("") - .trim(); - return joined.length > 0 ? joined : null; - } catch { - return null; - } -} + const userText = + "INPUT:\n```json\n" + + JSON.stringify(payload, null, 2) + + "\n```\n\nReturn ONLY the JSON object."; -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - const stripped = text - .replace(/^```(?:json)?/i, "") - .replace(/```$/i, "") - .trim(); - try { - return JSON.parse(stripped); - } catch { - const first = stripped.indexOf("{"); - const last = stripped.lastIndexOf("}"); - if (first >= 0 && last > first) { - try { - return JSON.parse(stripped.slice(first, last + 1)); - } catch { - /* fall through */ - } - } - return null; - } - } + return buildBtlChatRequest(system, userText, { temperature: 0.1 }); } diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts index ab701eb6..d21f5749 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { postLlmRecommend } from "../../../earnApi"; +import { buildBtlChatRequest, extractOpenAiText, safeParseJson } from "@/lib/btl/client"; import { llmRecommendationSchema } from "../../schema"; import { DEFAULT_CONFIG } from "../../types"; import type { @@ -67,6 +68,8 @@ interface IntentRecommendationArgs { sourceTokenSymbol?: string; /** Source chain ID for the asset being recommended for. */ sourceChainId?: number; + /** BTL model override (see BTL_AB_MODELS). Defaults to BTL_DEFAULT_MODEL. */ + model?: string; } interface IntentRecommendationResult { @@ -91,6 +94,7 @@ export function useIntentRecommendation( queryKey: [ "intent-recommendation", LLM_MODE, + args?.model ?? "", args?.synthChainId ?? 0, args?.synthTokenAddress ?? "", intentCacheKey(args?.intent), @@ -143,14 +147,14 @@ export async function buildRecommendation( }; } - const request = buildGeminiIntentRequest(intent, candidates, args.walletAssets, args.sourceTokenSymbol, args.sourceChainId); + const request = buildGeminiIntentRequest(intent, candidates, args.walletAssets, args.sourceTokenSymbol, args.sourceChainId, args.model); let lastError: string | null = null; for (let attempt = 0; attempt < 3; attempt++) { if (attempt > 0) await new Promise((r) => setTimeout(r, 800 * attempt)); try { - const raw = await postLlmRecommend(request); - const text = extractGeminiText(raw); + const { data: raw, meta } = await postLlmRecommend(request); + const text = extractOpenAiText(raw); if (!text) throw new Error("empty LLM response"); const json = safeParseJson(text); if (!json) throw new Error("LLM did not return JSON"); @@ -231,6 +235,7 @@ export async function buildRecommendation( alternatives: dedupedAlts, source: "ai", topRationale: rec.best_pick?.rationale ?? "", + meta, }, llmError: null, }; @@ -308,7 +313,7 @@ function rulesFallback( }; } -function buildGeminiIntentRequest(intent: ParsedIntent, candidates: EarnVault[], walletAssets: IdleAsset[], sourceTokenSymbol?: string, sourceChainId?: number) { +function buildGeminiIntentRequest(intent: ParsedIntent, candidates: EarnVault[], walletAssets: IdleAsset[], sourceTokenSymbol?: string, sourceChainId?: number, model?: string) { const system = `You are a DeFi yield strategist with deep knowledge of vault mechanics, protocol risk, and yield sustainability. You evaluate vaults the way a seasoned DeFi portfolio manager would — not just by raw numbers, but by understanding what drives those numbers and whether they'll last. You will be given: @@ -456,26 +461,12 @@ ENTRY COST FRAMEWORK: }, }; - return { - contents: [ - { - role: "user", - parts: [ - { text: fullSystem }, - { - text: - "INPUT:\n" + - JSON.stringify(userPayload) + - "\n\nReturn ONLY the JSON object matching required_output_shape. No prose, no code fences.", - }, - ], - }, - ], - generationConfig: { - responseMimeType: "application/json", - temperature: 0.2, - }, - }; + const userText = + "INPUT:\n" + + JSON.stringify(userPayload) + + "\n\nReturn ONLY the JSON object matching required_output_shape. No prose, no code fences."; + + return buildBtlChatRequest(fullSystem, userText, { temperature: 0.2, model }); } function intentCacheKey(intent: ParsedIntent | undefined): string { @@ -494,53 +485,6 @@ function intentCacheKey(intent: ParsedIntent | undefined): string { ].join("|"); } -function extractGeminiText(raw: unknown): string | null { - try { - const r = raw as { - candidates?: Array<{ - content?: { - parts?: Array<{ text?: string; thought?: boolean }>; - }; - }>; - }; - const parts = r.candidates?.[0]?.content?.parts ?? []; - const joined = parts - .filter((p) => !p.thought && typeof p.text === "string") - .map((p) => p.text ?? "") - .join("") - .trim(); - return joined.length > 0 ? joined : null; - } catch { - return null; - } -} - -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - const stripped = text - .replace(/^```(?:json)?/i, "") - .replace(/```$/i, "") - .trim(); - try { - return JSON.parse(stripped); - } catch { - // Thinking models sometimes prepend prose before the JSON object. - const first = stripped.indexOf("{"); - const last = stripped.lastIndexOf("}"); - if (first >= 0 && last > first) { - try { - return JSON.parse(stripped.slice(first, last + 1)); - } catch { - /* fall through */ - } - } - return null; - } - } -} - function formatApy(apy: number | null): string { if (apy == null) return "—"; return `${apy.toFixed(2)}%`; diff --git a/src/components/integrations/lifi-earn/concierge/types.ts b/src/components/integrations/lifi-earn/concierge/types.ts index 7a1b7e92..45e07de0 100644 --- a/src/components/integrations/lifi-earn/concierge/types.ts +++ b/src/components/integrations/lifi-earn/concierge/types.ts @@ -1,4 +1,5 @@ import type { EarnVault, EarnToken } from "../types"; +import type { BtlRuntimeMeta } from "@/lib/btl/client"; export interface IdleAsset { chainId: number; @@ -23,6 +24,7 @@ export interface VaultRecommendation { alternatives: RecommendationPick[]; source: "ai" | "rules"; topRationale: string; + meta?: BtlRuntimeMeta; // BTL cost/routing for the call that produced this rec } export interface RecommendationPick { diff --git a/src/components/integrations/lifi-earn/earnApi.ts b/src/components/integrations/lifi-earn/earnApi.ts index 5eb3196a..85b319b8 100644 --- a/src/components/integrations/lifi-earn/earnApi.ts +++ b/src/components/integrations/lifi-earn/earnApi.ts @@ -8,6 +8,7 @@ import type { EarnProtocolInfo, LifiStatusResponse, } from "./types"; +import { parseBtlMeta, type BtlRuntimeMeta } from "@/lib/btl/client"; const EARN_PROXY = "/api/lifi-earn"; const COMPOSER_PROXY = "/api/lifi-composer"; @@ -180,7 +181,7 @@ export function extractUniqueUnderlyings( const LLM_PROXY = "/api/llm-recommend"; -export async function postLlmRecommend(body: unknown): Promise { +export async function postLlmRecommend(body: unknown): Promise<{ data: unknown; meta: BtlRuntimeMeta }> { const res = await fetch(LLM_PROXY, { method: "POST", headers: { "Content-Type": "application/json", ...proxyHeaders() }, @@ -193,7 +194,7 @@ export async function postLlmRecommend(body: unknown): Promise { const text = await res.text().catch(() => ""); throw new Error(`LLM proxy error: ${res.status} ${text}`); } - return res.json(); + return { data: await res.json(), meta: parseBtlMeta(res.headers) }; } export async function fetchCrossChainStatus(params: { diff --git a/src/components/simulation-results/SummaryTab.tsx b/src/components/simulation-results/SummaryTab.tsx index 5d0acc43..f39054f6 100644 --- a/src/components/simulation-results/SummaryTab.tsx +++ b/src/components/simulation-results/SummaryTab.tsx @@ -1,7 +1,14 @@ import React, { Suspense } from "react"; +import { CircleNotch } from "@phosphor-icons/react"; import type { SimulationResult } from "../../types/transaction"; import type { TraceRow, TraceFilters } from "../ExecutionStackTrace"; import LoadingSpinner from "../shared/LoadingSpinner"; +import { useBtlExplain } from "@/lib/btl/useBtlExplain"; +import BtlExplanation from "@/components/btl/BtlExplanation"; + +const LLM_MODE = + (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? + "live"; const ExecutionStackTrace = React.lazy(() => import("../ExecutionStackTrace")); @@ -53,6 +60,16 @@ export const SummaryTab: React.FC = ({ highlightedValue, setHighlightedValue, }) => { + const { explain: explainRevert, text: revertExplanation, meta: revertMeta, loading: revertLoading, error: revertError } = useBtlExplain(); + + const handleExplainRevert = () => { + const userText = JSON.stringify({ errorMessage, revertInfo }).slice(0, 4000); + void explainRevert( + "You are an EVM debugging assistant. Given a reverted transaction, explain the root cause in plain English and suggest a concrete fix. Be specific; never invent state you were not given.", + userText, + ); + }; + return ( <> {/* Warnings from EDB */} @@ -133,6 +150,40 @@ export const SummaryTab: React.FC = ({ ))}
)} + {(errorMessage || revertInfo) && LLM_MODE !== "off" && ( +
+ + {(revertExplanation || revertLoading || revertError) && ( +
+ +
+ )} +
+ )} )} }> diff --git a/src/components/smart-decoder/SmartDecoder.tsx b/src/components/smart-decoder/SmartDecoder.tsx index 50f547d0..c2090b1c 100644 --- a/src/components/smart-decoder/SmartDecoder.tsx +++ b/src/components/smart-decoder/SmartDecoder.tsx @@ -9,6 +9,8 @@ import { import { Button } from '../ui/button'; import { Textarea } from '../ui/textarea'; import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'; +import { useBtlExplain } from '@/lib/btl/useBtlExplain'; +import BtlExplanation from '@/components/btl/BtlExplanation'; import { useToolkit } from '../../contexts/ToolkitContext'; import { parseFunctionSignatureParameters } from '../../utils/solidityTypes'; import { EXTENDED_NETWORKS, type ExtendedChain } from '../shared/NetworkSelector'; @@ -39,6 +41,28 @@ import { import { useDecodeHandlers } from './useDecodeHandlers'; import ArgsOnlyInput, { type ArgsOnlyParam } from './ArgsOnlyInput'; +const LLM_MODE = + (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? + "live"; + +// Recursively converts a decoded value into something JSON-safe (bigint → +// string) and truncates strings/arrays/objects so a single huge param can't +// blow up the LLM payload. +function toPromptValue(value: unknown, depth = 0): unknown { + if (depth > 4) return '…'; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'string') return value.length > 300 ? `${value.slice(0, 300)}…` : value; + if (Array.isArray(value)) return value.slice(0, 20).map((v) => toPromptValue(v, depth + 1)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record).slice(0, 20)) { + out[k] = toPromptValue(v, depth + 1); + } + return out; + } + return value; +} + type DecodeMode = 'calldata' | 'args-only'; interface AbiInputLike { @@ -267,6 +291,32 @@ const SmartDecoder: React.FC = () => { return { parameterData, hasGenericNames, hasRealNames }; }; + const { explain: explainCall, text: callExplanation, meta: callMeta, loading: callLoading, error: callError } = useBtlExplain(); + + const handleExplainCall = () => { + if (!decodedResult) return; + const { parameterData } = getParameterDisplayData(); + const payload = { + name: decodedResult.name ?? null, + signature: decodedResult.signature ?? null, + args: toPromptValue(decodedResult.args ?? []), + parameters: parameterData.slice(0, 30).map((p) => ({ + name: p.name, + type: p.type, + value: toPromptValue(p.value), + })), + contractName: contractMetadata?.name ?? null, + contractAddress: contractAddress || null, + }; + // Belt-and-suspenders cap on top of toPromptValue's per-field truncation — + // keeps the request body well under 64KB even for pathological ABIs. + const userText = JSON.stringify(payload).slice(0, 60_000); + void explainCall( + "You are a smart-contract analyst. Explain in plain English what this function call does and what its parameters mean. Be concise; flag anything risky (token approvals, transfers, ownership/admin changes).", + userText, + ); + }; + const addArgsOnlyParam = () => setArgsOnlyParams(prev => [...prev, { type: 'uint256', name: '' }]); const removeArgsOnlyParam = (i: number) => setArgsOnlyParams(prev => prev.filter((_, idx) => idx !== i)); const updateArgsOnlyParam = (i: number, field: 'type' | 'name', val: string) => { @@ -484,6 +534,41 @@ const SmartDecoder: React.FC = () => { getParameterDisplayData={getParameterDisplayData} /> + {decodedResult && LLM_MODE !== 'off' && ( +
+ + + {(callExplanation || callLoading || callError) && ( + + )} +
+ )} + setContractConfirmation(null)} diff --git a/src/lib/btl/agent.ts b/src/lib/btl/agent.ts new file mode 100644 index 00000000..554b77e8 --- /dev/null +++ b/src/lib/btl/agent.ts @@ -0,0 +1,37 @@ +import { BTL_DEFAULT_MODEL } from "./models"; +import type { BtlRuntimeMeta } from "./client"; + +export interface BtlTool { name: string; description: string; parameters: object; run: (args: any) => Promise } + +export async function runBtlAgent(opts: { + system: string; userText: string; tools: BtlTool[]; model?: string; maxIters?: number; + callBtl: (body: unknown) => Promise<{ data: unknown; meta: BtlRuntimeMeta }>; +}) { + const { system, userText, tools, model = BTL_DEFAULT_MODEL, maxIters = 4, callBtl } = opts; + const toolSpecs = tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } })); + const messages: any[] = [ { role: "system", content: system }, { role: "user", content: userText } ]; + const metas: BtlRuntimeMeta[] = []; + const toolRuns: Array<{ name: string; args: unknown; result: unknown }> = []; + + for (let i = 0; i < maxIters; i++) { + const { data, meta } = await callBtl({ model, messages, tools: toolSpecs, tool_choice: "auto", temperature: 0.2 }); + metas.push(meta); + const choice = (data as any)?.choices?.[0]; + const msg = choice?.message; + if (!msg) break; + messages.push(msg); + const calls = msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }> | undefined; + if (!calls || calls.length === 0) { + return { finalText: typeof msg.content === "string" ? msg.content : null, metas, toolRuns }; + } + for (const c of calls) { + const tool = tools.find((t) => t.name === c.function.name); + let result: unknown; + try { result = tool ? await tool.run(JSON.parse(c.function.arguments || "{}")) : { error: "unknown tool" }; } + catch (e: any) { result = { error: e?.message ?? "tool failed" }; } + toolRuns.push({ name: c.function.name, args: c.function.arguments, result }); + messages.push({ role: "tool", tool_call_id: c.id, content: JSON.stringify(result) }); + } + } + return { finalText: null, metas, toolRuns }; +} diff --git a/src/lib/btl/client.ts b/src/lib/btl/client.ts new file mode 100644 index 00000000..5053cb33 --- /dev/null +++ b/src/lib/btl/client.ts @@ -0,0 +1,80 @@ +import { BTL_DEFAULT_MODEL } from "./models"; + +export interface BtlRuntimeMeta { + model: string | null; + customerChargeUsd: number | null; + benchmarkCostUsd: number | null; + feePct: number | null; + requestId: string | null; +} + +export interface BtlChatResult { + data: unknown; + meta: BtlRuntimeMeta; +} + +export function buildBtlChatRequest( + system: string, + userText: string, + opts: { model?: string; temperature?: number; jsonMode?: boolean; tools?: unknown[]; toolChoice?: unknown; maxTokens?: number } = {}, +): Record { + const body: Record = { + model: opts.model ?? BTL_DEFAULT_MODEL, + messages: [ + { role: "system", content: system }, + { role: "user", content: userText }, + ], + temperature: opts.temperature ?? 0.2, + // Some models (e.g. gpt-4o-mini) truncate structured JSON at a low default + // max_tokens (finish_reason:"length" → invalid JSON → rules fallback). Set + // a generous default so recommendation/agent JSON completes. + max_tokens: opts.maxTokens ?? 2048, + }; + if (opts.jsonMode !== false && !opts.tools) body.response_format = { type: "json_object" }; + if (opts.tools) body.tools = opts.tools; + if (opts.toolChoice) body.tool_choice = opts.toolChoice; + return body; +} + +export function extractOpenAiText(raw: unknown): string | null { + const r = raw as { choices?: Array<{ message?: { content?: string | null } }> }; + const content = r?.choices?.[0]?.message?.content; + return typeof content === "string" && content.trim().length > 0 ? content.trim() : null; +} + +export function parseBtlMeta(headers: Headers): BtlRuntimeMeta { + const num = (k: string): number | null => { + const v = headers.get(k); + if (v == null || v.trim() === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + return { + model: headers.get("x-btl-model"), + customerChargeUsd: num("x-btl-customer-charge"), + benchmarkCostUsd: num("x-btl-benchmark-cost"), + feePct: num("x-gateway-fee-pct"), + requestId: headers.get("x-request-id"), + }; +} + +// Lifted verbatim from the concierge hooks (was triplicated) — keep the +// fence/slice fallbacks; with response_format:json_object the content is +// usually already clean JSON, but models vary. +export function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + const stripped = text.replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim(); + try { + return JSON.parse(stripped); + } catch { + const first = stripped.indexOf("{"); + const last = stripped.lastIndexOf("}"); + if (first >= 0 && last > first) { + try { return JSON.parse(stripped.slice(first, last + 1)); } catch { /* fall through */ } + } + return null; + } + } +} diff --git a/src/lib/btl/models.ts b/src/lib/btl/models.ts new file mode 100644 index 00000000..52c53743 --- /dev/null +++ b/src/lib/btl/models.ts @@ -0,0 +1,25 @@ +// BTL Runtime model registry. +// +// Verified callable on our tier (OpenAI + DeepSeek providers only). Everything +// else in the 314-model catalog either 400s or returns HTTP 200 with empty +// content on our key: +// - btl-2 → non-deterministically routes to gpt-4o-mini OR an empty +// free route; too flaky to pin for a demo. +// - minimax-m3, +// gemini-*, +// deepseek-r1, +// deepseek-chat-v3.1 → route to openrouter/free, return content:"". +// - claude-* → require /v1/messages + paid Anthropic credits (blocked). +export const BTL_BASE_URL_DEFAULT = "https://api.badtheorylabs.com"; + +/** Default model: fast, 1M ctx, tool-calling + json_object verified live. */ +export const BTL_DEFAULT_MODEL = "deepseek-v4-flash"; + +/** Proven fallback / A-B alternate: OpenAI, tool-calling verified. */ +export const BTL_FALLBACK_MODEL = "gpt-4o-mini"; + +/** The cross-provider A/B toggle: DeepSeek vs OpenAI, both tool-capable. */ +export const BTL_AB_MODELS = [ + { id: "deepseek-v4-flash", label: "DeepSeek v4 Flash", provider: "DeepSeek" }, + { id: "gpt-4o-mini", label: "GPT-4o mini", provider: "OpenAI" }, +] as const; diff --git a/src/lib/btl/useBtlExplain.ts b/src/lib/btl/useBtlExplain.ts new file mode 100644 index 00000000..993d88a1 --- /dev/null +++ b/src/lib/btl/useBtlExplain.ts @@ -0,0 +1,47 @@ +import { useCallback, useState } from "react"; +import { buildBtlChatRequest, extractOpenAiText, type BtlRuntimeMeta } from "./client"; +import { postLlmRecommend } from "@/components/integrations/lifi-earn/earnApi"; + +// Tiny, generic weave engine: one-shot BTL call → text (or raw JSON) + cost +// meta. Shared by any surface that wants an "explain this" call. Pass +// { jsonMode: true } for surfaces that want structured JSON back (e.g. the +// storage slot chips), which then parse `text` themselves. +export function useBtlExplain(opts?: { jsonMode?: boolean; maxTokens?: number }) { + const jsonMode = opts?.jsonMode ?? false; + const maxTokens = opts?.maxTokens ?? 700; + const [text, setText] = useState(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const explain = useCallback(async (system: string, userText: string) => { + setText(null); + setMeta(null); + setError(null); + setLoading(true); + try { + // Hard cap the user text so the JSON-serialized chat body stays under + // the proxy's 64KB limit even after escaping (30k chars leaves ~2x + // headroom). Per-weave caps can stay; this is the safety net all + // weaves pass through. + const cappedUserText = userText.slice(0, 30000); + const { data, meta } = await postLlmRecommend( + buildBtlChatRequest(system, cappedUserText, { jsonMode, maxTokens }), + ); + setText(extractOpenAiText(data)); + setMeta(meta); + } catch (err) { + setError(err instanceof Error ? err.message : "Couldn't get an explanation."); + } finally { + setLoading(false); + } + }, [jsonMode, maxTokens]); + + const reset = useCallback(() => { + setText(null); + setMeta(null); + setError(null); + }, []); + + return { explain, text, meta, loading, error, reset }; +} diff --git a/vite.config.ts b/vite.config.ts index 676bd8e7..f163d02d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -96,7 +96,7 @@ function devExplorerProxy(): Plugin { }; } -// ── Gemini AI Studio LLM proxy (dev server) ──────────────────────────────── +// ── BTL Runtime LLM proxy (dev server) ──────────────────────────────── function llmProxyPlugin(envObj: Record): Plugin { return { @@ -110,19 +110,26 @@ function llmProxyPlugin(envObj: Record): Plugin { for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks).toString("utf-8"); - const model = envObj.GEMINI_MODEL || "gemini-2.5-flash-lite"; - const apiKey = envObj.GEMINI_API_KEY || ""; - if (!apiKey) { res.statusCode = 500; res.end('{"error":"No GEMINI_API_KEY"}'); return; } + const model = envObj.BTL_MODEL || "deepseek-v4-flash"; + const apiKey = envObj.BTL_API_KEY || ""; + const baseUrl = envObj.BTL_BASE_URL || "https://api.badtheorylabs.com"; + if (!apiKey) { res.statusCode = 500; res.end('{"error":"No BTL_API_KEY"}'); return; } - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; - const gemHeaders: Record = { "Content-Type": "application/json", "x-goog-api-key": apiKey }; + const url = `${baseUrl}/v1/chat/completions`; + const btlHeaders: Record = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }; try { - const upstream = await fetch(url, { method: "POST", headers: gemHeaders, body, signal: AbortSignal.timeout(55_000) }); + const upstream = await fetch(url, { method: "POST", headers: btlHeaders, body, signal: AbortSignal.timeout(55_000) }); const text = await upstream.text(); res.statusCode = upstream.status; res.setHeader("Content-Type", "application/json"); - res.setHeader("X-Gemini-Model", model); + for (const h of ["x-btl-benchmark-cost","x-btl-customer-charge","x-btl-saved","x-gateway-fee-pct","x-gateway-cost","x-request-id"]) { + const v = upstream.headers.get(h); + if (v) res.setHeader(h, v); + } + let requestedModel = model; + try { requestedModel = JSON.parse(body)?.model || model; } catch {} + res.setHeader("X-BTL-Model", requestedModel); res.end(text); } catch (err: any) { console.error(`[llm-proxy] ${model} failed:`, err?.message); @@ -190,7 +197,7 @@ export default defineConfig(({ mode }) => { watch: { usePolling: false, interval: 100, - ignored: ["**/node_modules/**", "**/.git/**", "**/dist/**"], + ignored: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/edb/**", "**/starknet-sim/**", "**/target/**"], }, proxy: { // Proxy for EDB bridge (strips /api/edb prefix, forwards to bridge) @@ -323,7 +330,7 @@ export default defineConfig(({ mode }) => { "x-lifi-api-key": LIFI_API_KEY, }, }, - // Gemini is handled by llmProxyPlugin() below — not a static proxy + // BTL Runtime is handled by llmProxyPlugin() below — not a static proxy // Proxy for Sourcify repo "/api/repo": { target: "https://repo.sourcify.dev", From 1f0a88fb5201e498903d08d88c47ec4295f72805 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 12:13:38 +0100 Subject: [PATCH 2/7] chore: trigger preview redeploy From f23b5aee93dabe4a270a67b98a9b4b8bd92fbc37 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 12:18:16 +0100 Subject: [PATCH 3/7] chore: temp env-visibility probe on llm-recommend --- api/llm-recommend.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/llm-recommend.ts b/api/llm-recommend.ts index 3a26cc10..53f853d4 100644 --- a/api/llm-recommend.ts +++ b/api/llm-recommend.ts @@ -39,6 +39,21 @@ function hasValidSecret(req: VercelRequest): boolean { const MAX_BODY_BYTES = 64 * 1024; export default async function handler(req: VercelRequest, res: VercelResponse) { + // TEMP debug probe — reports env visibility without leaking the key. Remove after diagnosis. + if (req.query?.debug === "1") { + const k = process.env.BTL_API_KEY || ""; + return res.status(200).json({ + vercelEnv: process.env.VERCEL_ENV || null, + gitBranch: process.env.VERCEL_GIT_COMMIT_REF || null, + commit: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) || null, + hasKey: k.length > 0, + keyLen: k.length, + keyPrefix: k ? k.slice(0, 3) : null, + baseUrl: process.env.BTL_BASE_URL || "(default)", + btlEnvNames: Object.keys(process.env).filter((n) => n.toUpperCase().includes("BTL")), + }); + } + const allowedOrigin = getAllowedOrigin(req); if (req.method === "OPTIONS") { From 3245aa76d8a18e3e544befa83b1bd14093427797 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 12:34:29 +0100 Subject: [PATCH 4/7] chore: redeploy with corrected BTL_API_KEY From 12ce91f3423499bf8789dcfdc9a3e47180e86907 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 12:44:20 +0100 Subject: [PATCH 5/7] fix(btl): default to deepseek-v3.2 (v4-flash route unfunded); drop debug probe --- api/llm-recommend.ts | 17 +---------------- src/lib/btl/models.ts | 6 +++--- vite.config.ts | 2 +- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/api/llm-recommend.ts b/api/llm-recommend.ts index 53f853d4..db1e37f7 100644 --- a/api/llm-recommend.ts +++ b/api/llm-recommend.ts @@ -6,7 +6,7 @@ export const config = { maxDuration: 60, }; -const BTL_MODEL = process.env.BTL_MODEL || "deepseek-v4-flash"; +const BTL_MODEL = process.env.BTL_MODEL || "deepseek-v3.2"; const BTL_API_KEY = process.env.BTL_API_KEY || ""; const BTL_BASE_URL = process.env.BTL_BASE_URL || "https://api.badtheorylabs.com"; @@ -39,21 +39,6 @@ function hasValidSecret(req: VercelRequest): boolean { const MAX_BODY_BYTES = 64 * 1024; export default async function handler(req: VercelRequest, res: VercelResponse) { - // TEMP debug probe — reports env visibility without leaking the key. Remove after diagnosis. - if (req.query?.debug === "1") { - const k = process.env.BTL_API_KEY || ""; - return res.status(200).json({ - vercelEnv: process.env.VERCEL_ENV || null, - gitBranch: process.env.VERCEL_GIT_COMMIT_REF || null, - commit: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) || null, - hasKey: k.length > 0, - keyLen: k.length, - keyPrefix: k ? k.slice(0, 3) : null, - baseUrl: process.env.BTL_BASE_URL || "(default)", - btlEnvNames: Object.keys(process.env).filter((n) => n.toUpperCase().includes("BTL")), - }); - } - const allowedOrigin = getAllowedOrigin(req); if (req.method === "OPTIONS") { diff --git a/src/lib/btl/models.ts b/src/lib/btl/models.ts index 52c53743..d3ef998c 100644 --- a/src/lib/btl/models.ts +++ b/src/lib/btl/models.ts @@ -12,14 +12,14 @@ // - claude-* → require /v1/messages + paid Anthropic credits (blocked). export const BTL_BASE_URL_DEFAULT = "https://api.badtheorylabs.com"; -/** Default model: fast, 1M ctx, tool-calling + json_object verified live. */ -export const BTL_DEFAULT_MODEL = "deepseek-v4-flash"; +/** Default model: DeepSeek, tool-calling + json_object verified live. */ +export const BTL_DEFAULT_MODEL = "deepseek-v3.2"; /** Proven fallback / A-B alternate: OpenAI, tool-calling verified. */ export const BTL_FALLBACK_MODEL = "gpt-4o-mini"; /** The cross-provider A/B toggle: DeepSeek vs OpenAI, both tool-capable. */ export const BTL_AB_MODELS = [ - { id: "deepseek-v4-flash", label: "DeepSeek v4 Flash", provider: "DeepSeek" }, + { id: "deepseek-v3.2", label: "DeepSeek v3.2", provider: "DeepSeek" }, { id: "gpt-4o-mini", label: "GPT-4o mini", provider: "OpenAI" }, ] as const; diff --git a/vite.config.ts b/vite.config.ts index f163d02d..0f32786c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -110,7 +110,7 @@ function llmProxyPlugin(envObj: Record): Plugin { for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks).toString("utf-8"); - const model = envObj.BTL_MODEL || "deepseek-v4-flash"; + const model = envObj.BTL_MODEL || "deepseek-v3.2"; const apiKey = envObj.BTL_API_KEY || ""; const baseUrl = envObj.BTL_BASE_URL || "https://api.badtheorylabs.com"; if (!apiKey) { res.statusCode = 500; res.end('{"error":"No BTL_API_KEY"}'); return; } From f5e8afa6524bd86b7699deca6da35d65b0daeef5 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 14:53:36 +0100 Subject: [PATCH 6/7] =?UTF-8?q?fix(btl):=20disable=20deepseek-v3.2=20think?= =?UTF-8?q?ing=20(reasoning=5Feffort:none)=20=E2=80=94=20kills=20502/empty?= =?UTF-8?q?-response=20flakiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/btl/agent.ts | 4 +++- src/lib/btl/client.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/btl/agent.ts b/src/lib/btl/agent.ts index 554b77e8..30339462 100644 --- a/src/lib/btl/agent.ts +++ b/src/lib/btl/agent.ts @@ -14,7 +14,9 @@ export async function runBtlAgent(opts: { const toolRuns: Array<{ name: string; args: unknown; result: unknown }> = []; for (let i = 0; i < maxIters; i++) { - const { data, meta } = await callBtl({ model, messages, tools: toolSpecs, tool_choice: "auto", temperature: 0.2 }); + // reasoning_effort:"none" — deepseek-v3.2 thinking mode adds ~50s latency + // that trips the proxy timeout; disable it (verified tool-calling still fires). + const { data, meta } = await callBtl({ model, messages, tools: toolSpecs, tool_choice: "auto", temperature: 0.2, reasoning_effort: "none" }); metas.push(meta); const choice = (data as any)?.choices?.[0]; const msg = choice?.message; diff --git a/src/lib/btl/client.ts b/src/lib/btl/client.ts index 5053cb33..1a4c99fc 100644 --- a/src/lib/btl/client.ts +++ b/src/lib/btl/client.ts @@ -29,6 +29,11 @@ export function buildBtlChatRequest( // max_tokens (finish_reason:"length" → invalid JSON → rules fallback). Set // a generous default so recommendation/agent JSON completes. max_tokens: opts.maxTokens ?? 2048, + // deepseek-v3.2 is a thinking model: left on, it emits ~10k chars of hidden + // reasoning per call → 40-55s latency → the proxy's 55s timeout returns 502, + // and in json mode it intermittently returns empty content ("empty LLM + // response"). "none" disables reasoning; OpenAI models ignore it harmlessly. + reasoning_effort: "none", }; if (opts.jsonMode !== false && !opts.tools) body.response_format = { type: "json_object" }; if (opts.tools) body.tools = opts.tools; From a0069ce282d1be494d390e3181e129a9089c48c6 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 6 Jul 2026 15:12:24 +0100 Subject: [PATCH 7/7] fix(btl): default to gpt-4o-mini (~5s vs v3.2's ~20s); DeepSeek stays A/B alternate --- api/llm-recommend.ts | 2 +- src/lib/btl/models.ts | 14 ++++++++------ vite.config.ts | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/api/llm-recommend.ts b/api/llm-recommend.ts index db1e37f7..7e8e1215 100644 --- a/api/llm-recommend.ts +++ b/api/llm-recommend.ts @@ -6,7 +6,7 @@ export const config = { maxDuration: 60, }; -const BTL_MODEL = process.env.BTL_MODEL || "deepseek-v3.2"; +const BTL_MODEL = process.env.BTL_MODEL || "gpt-4o-mini"; const BTL_API_KEY = process.env.BTL_API_KEY || ""; const BTL_BASE_URL = process.env.BTL_BASE_URL || "https://api.badtheorylabs.com"; diff --git a/src/lib/btl/models.ts b/src/lib/btl/models.ts index d3ef998c..598f35ea 100644 --- a/src/lib/btl/models.ts +++ b/src/lib/btl/models.ts @@ -12,14 +12,16 @@ // - claude-* → require /v1/messages + paid Anthropic credits (blocked). export const BTL_BASE_URL_DEFAULT = "https://api.badtheorylabs.com"; -/** Default model: DeepSeek, tool-calling + json_object verified live. */ -export const BTL_DEFAULT_MODEL = "deepseek-v3.2"; +/** Default model: OpenAI, ~5s/call, tool-calling + json_object verified live. + * (deepseek-v3.2 is reliable but ~20s/call — its thinking route is slow — so + * it's the A/B alternate rather than the default.) */ +export const BTL_DEFAULT_MODEL = "gpt-4o-mini"; -/** Proven fallback / A-B alternate: OpenAI, tool-calling verified. */ -export const BTL_FALLBACK_MODEL = "gpt-4o-mini"; +/** Proven A-B alternate: DeepSeek, tool-calling verified (reasoning_effort:none). */ +export const BTL_FALLBACK_MODEL = "deepseek-v3.2"; -/** The cross-provider A/B toggle: DeepSeek vs OpenAI, both tool-capable. */ +/** The cross-provider A/B toggle: OpenAI vs DeepSeek, both tool-capable. */ export const BTL_AB_MODELS = [ - { id: "deepseek-v3.2", label: "DeepSeek v3.2", provider: "DeepSeek" }, { id: "gpt-4o-mini", label: "GPT-4o mini", provider: "OpenAI" }, + { id: "deepseek-v3.2", label: "DeepSeek v3.2", provider: "DeepSeek" }, ] as const; diff --git a/vite.config.ts b/vite.config.ts index 0f32786c..230d2976 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -110,7 +110,7 @@ function llmProxyPlugin(envObj: Record): Plugin { for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks).toString("utf-8"); - const model = envObj.BTL_MODEL || "deepseek-v3.2"; + const model = envObj.BTL_MODEL || "gpt-4o-mini"; const apiKey = envObj.BTL_API_KEY || ""; const baseUrl = envObj.BTL_BASE_URL || "https://api.badtheorylabs.com"; if (!apiKey) { res.statusCode = 500; res.end('{"error":"No BTL_API_KEY"}'); return; }