debug_handlers.go

  1package server
  2
  3import (
  4	"encoding/json"
  5	"net/http"
  6	"strconv"
  7)
  8
  9// handleDebugLLMRequests serves the debug page for LLM requests
 10func (s *Server) handleDebugLLMRequests(w http.ResponseWriter, r *http.Request) {
 11	w.Header().Set("Content-Type", "text/html")
 12	w.Write([]byte(debugLLMRequestsHTML))
 13}
 14
 15// handleDebugLLMRequestsAPI returns recent LLM requests as JSON
 16func (s *Server) handleDebugLLMRequestsAPI(w http.ResponseWriter, r *http.Request) {
 17	ctx := r.Context()
 18
 19	limit := int64(100)
 20	if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
 21		if l, err := strconv.ParseInt(limitStr, 10, 64); err == nil && l > 0 {
 22			limit = l
 23		}
 24	}
 25
 26	requests, err := s.db.ListRecentLLMRequests(ctx, limit)
 27	if err != nil {
 28		s.logger.Error("Failed to list LLM requests", "error", err)
 29		http.Error(w, "Internal server error", http.StatusInternalServerError)
 30		return
 31	}
 32
 33	w.Header().Set("Content-Type", "application/json")
 34	json.NewEncoder(w).Encode(requests)
 35}
 36
 37// handleDebugLLMRequestBody returns the request body for a specific LLM request
 38func (s *Server) handleDebugLLMRequestBody(w http.ResponseWriter, r *http.Request) {
 39	ctx := r.Context()
 40
 41	idStr := r.PathValue("id")
 42	id, err := strconv.ParseInt(idStr, 10, 64)
 43	if err != nil {
 44		http.Error(w, "Invalid ID", http.StatusBadRequest)
 45		return
 46	}
 47
 48	body, err := s.db.GetLLMRequestBody(ctx, id)
 49	if err != nil {
 50		s.logger.Error("Failed to get LLM request body", "error", err, "id", id)
 51		http.Error(w, "Not found", http.StatusNotFound)
 52		return
 53	}
 54
 55	if body == nil {
 56		w.Header().Set("Content-Type", "application/json")
 57		w.Write([]byte("null"))
 58		return
 59	}
 60
 61	w.Header().Set("Content-Type", "application/json")
 62	w.Write([]byte(*body))
 63}
 64
 65// handleDebugLLMResponseBody returns the response body for a specific LLM request
 66func (s *Server) handleDebugLLMResponseBody(w http.ResponseWriter, r *http.Request) {
 67	ctx := r.Context()
 68
 69	idStr := r.PathValue("id")
 70	id, err := strconv.ParseInt(idStr, 10, 64)
 71	if err != nil {
 72		http.Error(w, "Invalid ID", http.StatusBadRequest)
 73		return
 74	}
 75
 76	body, err := s.db.GetLLMResponseBody(ctx, id)
 77	if err != nil {
 78		s.logger.Error("Failed to get LLM response body", "error", err, "id", id)
 79		http.Error(w, "Not found", http.StatusNotFound)
 80		return
 81	}
 82
 83	if body == nil {
 84		w.Header().Set("Content-Type", "application/json")
 85		w.Write([]byte("null"))
 86		return
 87	}
 88
 89	w.Header().Set("Content-Type", "application/json")
 90	w.Write([]byte(*body))
 91}
 92
 93// handleDebugLLMRequestBodyFull returns the full reconstructed request body,
 94// including prefix data from the prefix chain.
 95func (s *Server) handleDebugLLMRequestBodyFull(w http.ResponseWriter, r *http.Request) {
 96	ctx := r.Context()
 97
 98	idStr := r.PathValue("id")
 99	id, err := strconv.ParseInt(idStr, 10, 64)
100	if err != nil {
101		http.Error(w, "Invalid ID", http.StatusBadRequest)
102		return
103	}
104
105	// Use the existing DB method to reconstruct the full body
106	fullBody, err := s.db.GetFullLLMRequestBody(ctx, id)
107	if err != nil {
108		s.logger.Error("Failed to get full LLM request body", "error", err, "id", id)
109		http.Error(w, "Not found", http.StatusNotFound)
110		return
111	}
112
113	if fullBody == "" {
114		w.Header().Set("Content-Type", "application/json")
115		w.Write([]byte("null"))
116		return
117	}
118
119	w.Header().Set("Content-Type", "application/json")
120	w.Write([]byte(fullBody))
121}
122
123const debugLLMRequestsHTML = `<!DOCTYPE html>
124<html lang="en">
125<head>
126<meta charset="UTF-8">
127<meta name="viewport" content="width=device-width, initial-scale=1.0">
128<title>Debug: LLM Requests</title>
129<style>
130* { box-sizing: border-box; }
131body {
132	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
133	margin: 0;
134	padding: 20px;
135	background: #fff;
136	color: #1a1a1a;
137}
138h1 { margin: 0 0 20px 0; font-size: 24px; color: #000; }
139table {
140	width: 100%;
141	border-collapse: collapse;
142	font-size: 13px;
143}
144th, td {
145	padding: 8px 12px;
146	text-align: left;
147	border-bottom: 1px solid #e0e0e0;
148}
149th {
150	background: #f5f5f5;
151	font-weight: 600;
152	position: sticky;
153	top: 0;
154}
155tr:hover { background: #f8f8f8; }
156.mono { font-family: 'SF Mono', Monaco, monospace; font-size: 12px; }
157.error { color: #d32f2f; }
158.success { color: #2e7d32; }
159.btn {
160	background: #f5f5f5;
161	border: 1px solid #ccc;
162	color: #333;
163	padding: 4px 8px;
164	border-radius: 4px;
165	cursor: pointer;
166	font-size: 12px;
167}
168.btn:hover { background: #e8e8e8; }
169.btn:disabled { opacity: 0.5; cursor: not-allowed; }
170.btn.active { background: #1976d2; color: #fff; border-color: #1565c0; }
171.json-viewer {
172	background: #fafafa;
173	border: 1px solid #e0e0e0;
174	border-radius: 4px;
175	padding: 12px;
176	overflow-x: auto;
177	max-height: 600px;
178	overflow-y: auto;
179	flex: 1;
180	min-width: 0;
181}
182.json-viewer pre {
183	margin: 0;
184	font-family: 'SF Mono', Monaco, monospace;
185	font-size: 12px;
186	white-space: pre-wrap;
187	word-wrap: break-word;
188}
189.collapsed { display: none; }
190.size { color: #666; font-size: 11px; }
191.prefix { color: #f57c00; }
192.dedup-info { color: #1976d2; font-size: 11px; }
193.loading { color: #666; font-style: italic; }
194.expand-row { background: #fafafa; }
195.expand-row td { padding: 0; }
196.expand-content { padding: 12px; }
197.panels {
198	display: flex;
199	gap: 16px;
200}
201.panel {
202	flex: 1;
203	min-width: 0;
204	display: flex;
205	flex-direction: column;
206}
207.panel-header {
208	font-weight: 600;
209	margin-bottom: 8px;
210	color: #333;
211	display: flex;
212	align-items: center;
213	gap: 8px;
214}
215.panel-header .btn {
216	font-size: 11px;
217	padding: 2px 6px;
218}
219.model-display { color: #1976d2; }
220.model-id { color: #666; font-size: 11px; }
221.string { color: #2e7d32; }
222.number { color: #e65100; }
223.boolean { color: #0097a7; }
224.null { color: #7b1fa2; }
225.key { color: #c62828; }
226</style>
227</head>
228<body>
229<h1>LLM Requests</h1>
230<table id="requests-table">
231<thead>
232<tr>
233	<th>ID</th>
234	<th>Time</th>
235	<th>Model</th>
236	<th>Provider</th>
237	<th>Status</th>
238	<th>Duration</th>
239	<th>Request Size</th>
240	<th>Response Size</th>
241	<th>Prefix Info</th>
242	<th>Actions</th>
243</tr>
244</thead>
245<tbody id="requests-body">
246<tr><td colspan="10" class="loading">Loading...</td></tr>
247</tbody>
248</table>
249
250<script>
251const expandedRows = new Set();
252const loadedData = {};
253
254function formatSize(bytes) {
255	if (bytes === null || bytes === undefined) return '-';
256	if (bytes < 1024) return bytes + ' B';
257	if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
258	return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
259}
260
261function formatDate(dateStr) {
262	const d = new Date(dateStr);
263	return d.toLocaleString();
264}
265
266function formatDuration(ms) {
267	if (ms === null || ms === undefined) return '-';
268	if (ms < 1000) return ms + 'ms';
269	return (ms / 1000).toFixed(2) + 's';
270}
271
272function formatModel(model, displayName) {
273	if (displayName) {
274		return '<span class="model-display">' + displayName + '</span> <span class="model-id">(' + model + ')</span>';
275	}
276	return model;
277}
278
279function syntaxHighlight(json) {
280	if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
281	json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
282	return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
283		let cls = 'number';
284		if (/^"/.test(match)) {
285			if (/:$/.test(match)) {
286				cls = 'key';
287			} else {
288				cls = 'string';
289			}
290		} else if (/true|false/.test(match)) {
291			cls = 'boolean';
292		} else if (/null/.test(match)) {
293			cls = 'null';
294		}
295		return '<span class="' + cls + '">' + match + '</span>';
296	});
297}
298
299async function loadRequests() {
300	try {
301		const resp = await fetch('/debug/llm_requests/api?limit=100');
302		const data = await resp.json();
303		renderTable(data);
304	} catch (e) {
305		document.getElementById('requests-body').innerHTML =
306			'<tr><td colspan="10" class="error">Error loading requests: ' + e.message + '</td></tr>';
307	}
308}
309
310function renderTable(requests) {
311	const tbody = document.getElementById('requests-body');
312	if (!requests || requests.length === 0) {
313		tbody.innerHTML = '<tr><td colspan="10">No requests found</td></tr>';
314		return;
315	}
316	tbody.innerHTML = '';
317	for (const req of requests) {
318		const tr = document.createElement('tr');
319		tr.id = 'row-' + req.id;
320
321		const statusClass = req.status_code && req.status_code >= 200 && req.status_code < 300 ? 'success' :
322			(req.status_code ? 'error' : '');
323
324		let prefixInfo = '-';
325		if (req.prefix_request_id) {
326			prefixInfo = '<span class="dedup-info">prefix from #' + req.prefix_request_id +
327				' (' + formatSize(req.prefix_length) + ')</span>';
328		}
329
330		tr.innerHTML = ` + "`" + `
331			<td class="mono">${req.id}</td>
332			<td>${formatDate(req.created_at)}</td>
333			<td>${formatModel(req.model, req.model_display_name)}</td>
334			<td>${req.provider}</td>
335			<td class="${statusClass}">${req.status_code || '-'}${req.error ? ' ⚠' : ''}</td>
336			<td>${formatDuration(req.duration_ms)}</td>
337			<td class="size">${formatSize(req.request_body_length)}</td>
338			<td class="size">${formatSize(req.response_body_length)}</td>
339			<td>${prefixInfo}</td>
340			<td><button class="btn" onclick="toggleExpand(${req.id}, ${req.prefix_request_id !== null})">Expand</button></td>
341		` + "`" + `;
342		tbody.appendChild(tr);
343	}
344}
345
346async function toggleExpand(id, hasPrefix) {
347	const existingExpand = document.getElementById('expand-' + id);
348	if (existingExpand) {
349		existingExpand.remove();
350		expandedRows.delete(id);
351		return;
352	}
353
354	expandedRows.add(id);
355	const row = document.getElementById('row-' + id);
356	const expandRow = document.createElement('tr');
357	expandRow.id = 'expand-' + id;
358	expandRow.className = 'expand-row';
359	expandRow.innerHTML = ` + "`" + `
360		<td colspan="10">
361			<div class="expand-content">
362				<div class="panels">
363					<div class="panel">
364						<div class="panel-header">
365							Request
366							${hasPrefix ? '<button class="btn" id="toggle-full-' + id + '" onclick="toggleFullRequest(' + id + ')">Show Full</button>' : ''}
367						</div>
368						<div class="json-viewer" id="request-panel-${id}"><pre class="loading">Loading request...</pre></div>
369					</div>
370					<div class="panel">
371						<div class="panel-header">Response</div>
372						<div class="json-viewer" id="response-panel-${id}"><pre class="loading">Loading response...</pre></div>
373					</div>
374				</div>
375			</div>
376		</td>
377	` + "`" + `;
378	row.after(expandRow);
379
380	// Load both request and response
381	loadBody(id, 'request');
382	loadBody(id, 'response');
383}
384
385async function loadBody(id, type) {
386	const key = id + '-' + type;
387	if (loadedData[key]) {
388		renderBody(id, type, loadedData[key]);
389		return;
390	}
391
392	try {
393		const url = type === 'request'
394			? '/debug/llm_requests/' + id + '/request'
395			: '/debug/llm_requests/' + id + '/response';
396		const resp = await fetch(url);
397		const text = await resp.text();
398		let data;
399		try {
400			data = JSON.parse(text);
401		} catch {
402			data = text;
403		}
404		loadedData[key] = data;
405		renderBody(id, type, data);
406	} catch (e) {
407		const panelId = type === 'request' ? 'request-panel-' + id : 'response-panel-' + id;
408		const container = document.querySelector('#' + panelId + ' pre');
409		if (container) {
410			container.className = 'error';
411			container.textContent = 'Error loading: ' + e.message;
412		}
413	}
414}
415
416async function loadFullBody(id) {
417	const key = id + '-request-full';
418	if (loadedData[key]) {
419		return loadedData[key];
420	}
421
422	try {
423		const resp = await fetch('/debug/llm_requests/' + id + '/request_full');
424		const text = await resp.text();
425		let data;
426		try {
427			data = JSON.parse(text);
428		} catch {
429			data = text;
430		}
431		loadedData[key] = data;
432		return data;
433	} catch (e) {
434		throw e;
435	}
436}
437
438async function toggleFullRequest(id) {
439	const btn = document.getElementById('toggle-full-' + id);
440	if (!btn) return;
441
442	const isShowingFull = btn.classList.contains('active');
443
444	if (isShowingFull) {
445		// Switch back to suffix-only
446		btn.classList.remove('active');
447		btn.textContent = 'Show Full';
448		renderBody(id, 'request', loadedData[id + '-request']);
449	} else {
450		// Load and show full request
451		btn.textContent = 'Loading...';
452		try {
453			const fullData = await loadFullBody(id);
454			btn.classList.add('active');
455			btn.textContent = 'Show Suffix Only';
456			renderBody(id, 'request', fullData);
457		} catch (e) {
458			btn.textContent = 'Error';
459			setTimeout(() => { btn.textContent = 'Show Full'; }, 2000);
460		}
461	}
462}
463
464function renderBody(id, type, data) {
465	const panelId = type === 'request' ? 'request-panel-' + id : 'response-panel-' + id;
466	const container = document.querySelector('#' + panelId + ' pre');
467	if (!container) return;
468
469	if (data === null) {
470		container.className = '';
471		container.textContent = '(empty)';
472		return;
473	}
474
475	container.className = '';
476	if (typeof data === 'object') {
477		container.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
478	} else {
479		container.textContent = data;
480	}
481}
482
483loadRequests();
484</script>
485</body>
486</html>
487`