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`
—
`; return html`| ${k} | ${v} |
${text}` : nothing}
${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`
${content}` : nothing}
${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`
${JSON.stringify(schema, null, 2)}
` : nothing}