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, '&').replace(/</g, '<').replace(/>/g, '>');
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`