import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement, renderMarkdown } from '../lib/base.js'; // ── Helpers ─────────────────────────────────────────────────────────────────── function formatDate(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString(undefined, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }); } function fmtTokens(n) { if (n == null) return '—'; if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; return String(n); } function cacheHitPct(item) { if (item.cache_read_tokens == null || !item.input_tokens) return '—'; return (item.cache_read_tokens / item.input_tokens * 100).toFixed(0) + '%'; } function cacheTooltip(item) { const parts = []; if (item.cache_read_tokens != null) parts.push(`read: ${item.cache_read_tokens.toLocaleString()} tk`); if (item.cache_creation_tokens != null) parts.push(`write: ${item.cache_creation_tokens.toLocaleString()} tk`); return parts.length ? parts.join(' | ') : ''; } function parseJson(str) { if (!str) return null; try { return JSON.parse(str); } catch { return null; } } function extractSystem(req) { if (!req) return null; // Anthropic: top-level system field if (req.system != null) { if (typeof req.system === 'string') return req.system; if (Array.isArray(req.system)) { return req.system.map(b => (typeof b === 'string' ? b : b.text ?? '')).join('\n\n'); } } // OpenAI: only the very first message if it is role=system if (Array.isArray(req.messages) && req.messages[0]?.role === 'system') { return typeof req.messages[0].content === 'string' ? req.messages[0].content : ''; } return null; } function extractMessages(req) { if (!req || !Array.isArray(req.messages)) return []; let msgs = req.messages; // For OpenAI format: skip the first message if it was already shown as the System Prompt if (!req.system && msgs[0]?.role === 'system') msgs = msgs.slice(1); // All remaining messages are kept, including mid-conversation role=system inserts return msgs; } function extractParams(req) { if (!req) return []; const skip = new Set(['model', 'messages', 'system', 'tools', 'tool_choice', 'stream']); return Object.entries(req) .filter(([k]) => !skip.has(k)) .map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]); } function extractTools(req) { return req?.tools ?? []; } function extractRespBlocks(resp) { if (!resp) return []; if (Array.isArray(resp.content)) return resp.content; // Anthropic const msg = resp?.choices?.[0]?.message; if (msg) return contentBlocks(msg); // OpenAI — reuse helper (handles reasoning_content, tool_calls) return []; } function extractRespMeta(resp) { if (!resp) return []; const pairs = []; const skip = new Set(['content', 'choices', 'type', 'role', 'object', 'usage']); for (const [k, v] of Object.entries(resp)) { if (skip.has(k)) continue; pairs.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)]); } // OpenAI: finish_reason lives inside choices const choice = resp?.choices?.[0]; if (choice?.finish_reason && !resp.stop_reason) { pairs.push(['finish_reason', choice.finish_reason]); } // usage — flatten sub-keys if (resp.usage && typeof resp.usage === 'object') { for (const [k, v] of Object.entries(resp.usage)) { if (v != null) pairs.push([`usage.${k}`, typeof v === 'object' ? JSON.stringify(v) : String(v)]); } } return pairs; } function paramsPreview(input) { if (!input || Object.keys(input).length === 0) return ''; const str = JSON.stringify(input); return str.length > 80 ? str.slice(0, 77) + '…' : str; } function normalizeToolResultContent(block) { if (Array.isArray(block.content)) return block.content.map(b => b.text ?? JSON.stringify(b)).join('\n'); if (typeof block.content === 'string') return block.content; return JSON.stringify(block.content ?? ''); } function buildToolResultMap(msgs) { const map = new Map(); // tool_use_id → { content, is_error } for (const msg of msgs) { // Anthropic format: tool_result blocks inside user message content for (const block of contentBlocks(msg)) { if (block.type === 'tool_result') { map.set(block.tool_use_id, { content: normalizeToolResultContent(block), is_error: !!block.is_error, }); } } // OpenAI format: role='tool' messages carry the result directly if (msg.role === 'tool' && msg.tool_call_id) { const content = typeof msg.content === 'string' ? msg.content : (Array.isArray(msg.content) ? msg.content.map(b => b.text ?? JSON.stringify(b)).join('\n') : JSON.stringify(msg.content ?? '')); map.set(msg.tool_call_id, { content, is_error: false }); } } return map; } function contentBlocks(msg) { const blocks = []; if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) { blocks.push({ type: 'reasoning', text: msg.reasoning_content }); } if (msg.content) { if (typeof msg.content === 'string') blocks.push({ type: 'text', text: msg.content }); else if (Array.isArray(msg.content)) blocks.push(...msg.content); } // OpenAI format: tool calls live in tool_calls[], not in content if (Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { let input = {}; try { input = JSON.parse(tc.function?.arguments ?? '{}'); } catch { /* ignore */ } blocks.push({ type: 'tool_use', id: tc.id, name: tc.function?.name ?? '?', input }); } } return blocks; } // ── Component ───────────────────────────────────────────────────────────────── export class LlmRequestDetail extends LightElement { static properties = { detailId: { type: Number }, _detail: { state: true }, _loading: { state: true }, _error: { state: true }, _openSections: { state: true }, _expandedTools: { state: true }, }; constructor() { super(); this.detailId = null; this._detail = null; this._loading = false; this._error = null; this._openSections = new Set(['response']); this._expandedTools = new Set(); } updated(changed) { if (changed.has('detailId') && this.detailId != null) { this._detail = null; this._error = null; this._openSections = new Set(['response']); this._expandedTools = new Set(); this._fetch(this.detailId); } } async _fetch(id) { this._loading = true; this._error = null; try { const res = await fetch(`/api/dev/llm-requests/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._detail = await res.json(); } catch (e) { this._error = e.message; } finally { this._loading = false; } } _back() { this.dispatchEvent(new CustomEvent('detail-back', { bubbles: true })); } _toggleSection(name) { const next = new Set(this._openSections); next.has(name) ? next.delete(name) : next.add(name); this._openSections = next; } _toggleToolExpand(key) { const next = new Set(this._expandedTools); next.has(key) ? next.delete(key) : next.add(key); this._expandedTools = next; } // ── Section wrapper ───────────────────────────────────────────────────────── _renderSection(id, title, content, badge = null) { const open = this._openSections.has(id); return html`
this._toggleSection(id)}> ${title} ${badge != null ? html`${badge}` : nothing}
${content}
`; } // ── Key-value table ────────────────────────────────────────────────────────── _renderKvTable(pairs) { if (!pairs || pairs.length === 0) return html`

`; return html` ${pairs.map(([k, v]) => html` `)}
${k} ${v}
`; } // ── Stat bar ───────────────────────────────────────────────────────────────── _renderStatBar(d) { return html`
${d.agent_id ?? 'no agent'} ${d.source ?? '—'} ${d.model_name} ${d.stack_id != null ? html`stack #${d.stack_id}` : nothing} ${fmtTokens(d.input_tokens)} ${fmtTokens(d.output_tokens)} ${d.cache_read_tokens > 0 ? html` cache ${cacheHitPct(d)} ` : nothing} ${d.duration_ms} ms ${formatDate(d.created_at)} ${d.error_text ? html` error ` : nothing}
${d.error_text ? html`
${d.error_text}
` : nothing} `; } // ── Content blocks ─────────────────────────────────────────────────────────── // toolResultMap: Map — null when not available _renderContentBlock(block, keyPrefix, toolResultMap = null) { if (!block) return nothing; const type = block.type; if (type === 'reasoning') { const key = `${keyPrefix}-reasoning`; const open = this._expandedTools.has(key); const text = block.text ?? ''; return html`
this._toggleToolExpand(key)}> reasoning
${open ? html`
${text}
` : nothing}
`; } if (type === 'text') { const text = block.text ?? ''; if (!text) return nothing; return html`
${text}
`; } if (type === 'tool_use') { const key = `${keyPrefix}-use-${block.id ?? block.name}`; const open = this._expandedTools.has(key); const args = block.input != null ? JSON.stringify(block.input, null, 2) : '{}'; const preview = paramsPreview(block.input); const result = toolResultMap?.get(block.id); return html`
this._toggleToolExpand(key)}> ${block.name} ${preview ? html`${preview}` : nothing}
${open ? html`
${args}
${result != null ? html`
${result.content}
` : nothing}
` : nothing}
`; } // tool_result: only rendered when there is no toolResultMap (i.e. not in conversation context) if (type === 'tool_result') { if (toolResultMap != null) return nothing; // shown inline inside the tool_use block const key = `${keyPrefix}-result-${block.tool_use_id}`; const open = this._expandedTools.has(key); const content = normalizeToolResultContent(block); return html`
this._toggleToolExpand(key)}> result ${block.tool_use_id ?? ''} ${block.is_error ? html`error` : nothing}
${open ? html`
${content}
` : nothing}
`; } return html`
${JSON.stringify(block, null, 2)}
`; } _renderMessage(msg, idx, toolResultMap) { const role = msg.role ?? 'unknown'; const blocks = contentBlocks(msg); // skip messages that are entirely tool results (shown inline inside the tool_use block) // Anthropic: role=user messages whose content is all tool_result blocks // OpenAI: role=tool messages if (role === 'tool') return nothing; if (role === 'user' && blocks.length > 0 && blocks.every(b => b.type === 'tool_result')) { return nothing; } // mid-conversation system prompt: render with markdown and a distinct style if (role === 'system') { const text = typeof msg.content === 'string' ? msg.content : ''; if (!text) return nothing; return html`
system
${unsafeHTML(renderMarkdown(text))}
`; } return html`
${role}
${blocks.map((b, bi) => this._renderContentBlock(b, `msg-${idx}-${bi}`, toolResultMap))}
`; } _renderResponseBlock(block, idx) { if (!block) return nothing; // Delegate to _renderContentBlock for shared block types (reasoning, text, tool_use, tool_result) return this._renderContentBlock(block, `resp-${idx}`); } // ── Main render ────────────────────────────────────────────────────────────── render() { if (this._loading) return html`
Loading…
`; if (this._error) return html`
${this._error}
`; if (!this._detail) return nothing; const d = this._detail; const req = parseJson(d.request_json); const resp = parseJson(d.response_json); const hdrs = parseJson(d.request_headers); const respHdrs = parseJson(d.response_headers); const system = extractSystem(req); const msgs = extractMessages(req); const params = extractParams(req); const tools = extractTools(req); const payloadMissing = !req && !resp; const respBlocks = extractRespBlocks(resp); const respMeta = extractRespMeta(resp); const toolResultMap = buildToolResultMap(msgs); return html`
Request #${d.id}
${this._renderStatBar(d)} ${payloadMissing ? html`
Payload not available — this request has been purged by the retention policy.
` : nothing} ${hdrs ? this._renderSection('req-headers', 'Request Headers', this._renderKvTable(Object.entries(hdrs)) ) : nothing} ${respHdrs ? this._renderSection('resp-headers', 'Response Headers', this._renderKvTable(Object.entries(respHdrs)) ) : nothing} ${params.length ? this._renderSection('params', 'Parameters', this._renderKvTable(params) ) : nothing} ${system ? this._renderSection('system', 'System Prompt', html`
${unsafeHTML(renderMarkdown(system))}
` ) : nothing} ${msgs.length ? this._renderSection('conversation', 'Conversation', html`
${msgs.map((m, i) => this._renderMessage(m, i, toolResultMap))}
`, msgs.length ) : nothing} ${tools.length ? this._renderSection('tools', 'Tools Defined', html`
${tools.map((t, i) => { // Anthropic: { name, description, input_schema } // OpenAI: { type: 'function', function: { name, description, parameters } } const name = t.name ?? t.function?.name ?? '(unknown)'; const desc = t.description ?? t.function?.description ?? ''; const schema = t.input_schema ?? t.function?.parameters ?? null; const key = `tooldef-${i}-${name}`; const open = this._expandedTools.has(key); return html`
this._toggleToolExpand(key)}> ${name} ${desc} ${schema ? html` ` : nothing}
${open && schema ? html`
${JSON.stringify(schema, null, 2)}
` : nothing}
`; })}
`, tools.length ) : nothing} ${resp ? this._renderSection('response', 'Response', html` ${respMeta.length ? this._renderKvTable(respMeta) : nothing}
${respBlocks.map((b, i) => this._renderResponseBlock(b, i))}
` ) : nothing}
`; } }