diff --git a/proxy/events.go b/proxy/events.go index 11403fc8..ba65ccd6 100644 --- a/proxy/events.go +++ b/proxy/events.go @@ -8,6 +8,7 @@ const ConfigFileChangedEventID = 0x03 const LogDataEventID = 0x04 const TokenMetricsEventID = 0x05 const ModelPreloadedEventID = 0x06 +const RequestEventID = 0x07 type ProcessStateChangeEvent struct { ProcessName string diff --git a/proxy/metrics_monitor.go b/proxy/metrics_monitor.go index a3b07de2..09dd9715 100644 --- a/proxy/metrics_monitor.go +++ b/proxy/metrics_monitor.go @@ -97,7 +97,10 @@ func (mp *metricsMonitor) wrapHandler( request *http.Request, next func(modelID string, w http.ResponseWriter, r *http.Request) error, ) error { - recorder := newBodyCopier(writer) + recorder, ok := writer.(*responseBodyCopier) + if !ok { + recorder = newBodyCopier(writer) + } // Filter Accept-Encoding to only include encodings we can decompress for metrics if ae := request.Header.Get("Accept-Encoding"); ae != "" { @@ -301,9 +304,10 @@ func decompressBody(body []byte, encoding string) ([]byte, error) { // while also capturing it in a buffer for later processing type responseBodyCopier struct { gin.ResponseWriter - body *bytes.Buffer - tee io.Writer - start time.Time + body *bytes.Buffer + tee io.Writer + start time.Time + onWrite func([]byte) } func newBodyCopier(w gin.ResponseWriter) *responseBodyCopier { @@ -320,6 +324,10 @@ func (w *responseBodyCopier) Write(b []byte) (int, error) { w.start = time.Now() } + if w.onWrite != nil { + w.onWrite(b) + } + // Single write operation that writes to both the response and buffer return w.tee.Write(b) } diff --git a/proxy/proxymanager.go b/proxy/proxymanager.go index e0201424..efcfa067 100644 --- a/proxy/proxymanager.go +++ b/proxy/proxymanager.go @@ -40,6 +40,7 @@ type ProxyManager struct { muxLogger *LogMonitor metricsMonitor *metricsMonitor + requestMonitor *requestMonitor processGroups map[string]*ProcessGroup @@ -152,6 +153,7 @@ func New(proxyConfig config.Config) *ProxyManager { upstreamLogger: upstreamLogger, metricsMonitor: newMetricsMonitor(proxyLogger, maxMetrics), + requestMonitor: newRequestMonitor(maxMetrics), processGroups: make(map[string]*ProcessGroup), @@ -598,15 +600,29 @@ func (pm *ProxyManager) proxyToUpstream(c *gin.Context) { originalPath := c.Request.URL.Path c.Request.URL.Path = remainingPath + var requestBody string + if c.Request.ContentLength > 0 && c.Request.ContentLength < 1024*1024 { // Only capture small bodies + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + pm.proxyLogger.Errorf("Error reading request body for recording: %v", err) + } else { + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + requestBody = string(bodyBytes) + } + } + + recorder, done := pm.recordRequest(c, modelID, requestBody) + defer done() + // attempt to record metrics if it is a POST request if pm.metricsMonitor != nil && c.Request.Method == "POST" { - if err := pm.metricsMonitor.wrapHandler(modelID, c.Writer, c.Request, processGroup.ProxyRequest); err != nil { + if err := pm.metricsMonitor.wrapHandler(modelID, recorder, c.Request, processGroup.ProxyRequest); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying metrics wrapped request: %s", err.Error())) - pm.proxyLogger.Errorf("Error proxying wrapped upstream request for model %s, path=%s", modelID, originalPath) + pm.proxyLogger.Errorf("Error proxying metrics wrapped request for model %s, path=%s", modelID, originalPath) return } } else { - if err := processGroup.ProxyRequest(modelID, c.Writer, c.Request); err != nil { + if err := processGroup.ProxyRequest(modelID, recorder, c.Request); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) pm.proxyLogger.Errorf("Error proxying upstream request for model %s, path=%s", modelID, originalPath) return @@ -720,6 +736,9 @@ func (pm *ProxyManager) proxyInferenceHandler(c *gin.Context) { c.Request.Header.Set("content-length", strconv.Itoa(len(bodyBytes))) c.Request.ContentLength = int64(len(bodyBytes)) + recorder, done := pm.recordRequest(c, modelID, string(bodyBytes)) + defer done() + // issue #366 extract values that downstream handlers may need isStreaming := gjson.GetBytes(bodyBytes, "stream").Bool() ctx := context.WithValue(c.Request.Context(), proxyCtxKey("streaming"), isStreaming) @@ -727,13 +746,13 @@ func (pm *ProxyManager) proxyInferenceHandler(c *gin.Context) { c.Request = c.Request.WithContext(ctx) if pm.metricsMonitor != nil && c.Request.Method == "POST" { - if err := pm.metricsMonitor.wrapHandler(modelID, c.Writer, c.Request, nextHandler); err != nil { + if err := pm.metricsMonitor.wrapHandler(modelID, recorder, c.Request, nextHandler); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying metrics wrapped request: %s", err.Error())) pm.proxyLogger.Errorf("Error Proxying Metrics Wrapped Request model %s", modelID) return } } else { - if err := nextHandler(modelID, c.Writer, c.Request); err != nil { + if err := nextHandler(modelID, recorder, c.Request); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) pm.proxyLogger.Errorf("Error Proxying Request for model %s", modelID) return @@ -862,7 +881,10 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) { modifiedReq.ContentLength = int64(requestBuffer.Len()) // Use the modified request for proxying - if err := nextHandler(modelID, c.Writer, modifiedReq); err != nil { + recorder, done := pm.recordRequest(c, modelID, "") + defer done() + + if err := nextHandler(modelID, recorder, modifiedReq); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) pm.proxyLogger.Errorf("Error Proxying Request for model %s", modelID) return @@ -899,7 +921,10 @@ func (pm *ProxyManager) proxyGETModelHandler(c *gin.Context) { return } - if err := nextHandler(modelID, c.Writer, c.Request); err != nil { + recorder, done := pm.recordRequest(c, modelID, "") + defer done() + + if err := nextHandler(modelID, recorder, c.Request); err != nil { pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error())) pm.proxyLogger.Errorf("Error Proxying GET Request for model %s", modelID) return @@ -1026,3 +1051,29 @@ func (pm *ProxyManager) SetVersion(buildDate string, commit string, version stri pm.commit = commit pm.version = version } + +func (pm *ProxyManager) recordRequest(c *gin.Context, modelID string, requestBody string) (*responseBodyCopier, func()) { + startTime := time.Now() + requestID := pm.requestMonitor.Add(&RequestEntry{ + Timestamp: startTime, + Method: c.Request.Method, + Path: c.Request.URL.Path, + Model: modelID, + RequestBody: requestBody, + }) + + recorder := newBodyCopier(c.Writer) + recorder.onWrite = func(b []byte) { + pm.requestMonitor.AppendResponse(requestID, string(b)) + } + + return recorder, func() { + duration := time.Since(startTime) + respBody := "" + isStreaming := strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") + if !isStreaming { + respBody = recorder.body.String() + } + pm.requestMonitor.Update(requestID, recorder.Status(), duration, respBody) + } +} diff --git a/proxy/proxymanager_api.go b/proxy/proxymanager_api.go index fe4326d0..4a8d9f38 100644 --- a/proxy/proxymanager_api.go +++ b/proxy/proxymanager_api.go @@ -30,6 +30,8 @@ func addApiHandlers(pm *ProxyManager) { apiGroup.POST("/models/unload/*model", pm.apiUnloadSingleModelHandler) apiGroup.GET("/events", pm.apiSendEvents) apiGroup.GET("/metrics", pm.apiGetMetrics) + apiGroup.GET("/requests", pm.apiGetRequests) + apiGroup.GET("/requests/:id", pm.apiGetRequest) apiGroup.GET("/version", pm.apiGetVersion) } } @@ -105,6 +107,7 @@ const ( msgTypeModelStatus messageType = "modelStatus" msgTypeLogData messageType = "logData" msgTypeMetrics messageType = "metrics" + msgTypeRequest messageType = "request" ) type messageEnvelope struct { @@ -121,7 +124,7 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) { // prevent nginx from buffering SSE c.Header("X-Accel-Buffering", "no") - sendBuffer := make(chan messageEnvelope, 25) + sendBuffer := make(chan messageEnvelope, 100) ctx, cancel := context.WithCancel(c.Request.Context()) sendModels := func() { data, err := json.Marshal(pm.getModelStatus()) @@ -164,6 +167,18 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) { } } + sendRequest := func(req RequestEntry) { + jsonData, err := json.Marshal(req) + if err == nil { + select { + case sendBuffer <- messageEnvelope{Type: msgTypeRequest, Data: string(jsonData)}: + case <-ctx.Done(): + return + default: + } + } + } + /** * Send updated models list */ @@ -191,12 +206,30 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) { sendMetrics([]TokenMetrics{e.Metrics}) })() + /** + * Send Request data + */ + defer event.On(func(e RequestEvent) { + sendRequest(e.Entry) + })() + // send initial batch of data sendLogData("proxy", pm.proxyLogger.GetHistory()) sendLogData("upstream", pm.upstreamLogger.GetHistory()) sendModels() sendMetrics(pm.metricsMonitor.getMetrics()) + // Send recent requests (without bodies to keep initial sync light) + requests := pm.requestMonitor.GetEntries() + if len(requests) > 20 { + requests = requests[len(requests)-20:] + } + for _, r := range requests { + r.RequestBody = "" + r.ResponseBody = "" + sendRequest(r) + } + for { select { case <-c.Request.Context().Done(): @@ -221,6 +254,33 @@ func (pm *ProxyManager) apiGetMetrics(c *gin.Context) { c.Data(http.StatusOK, "application/json", jsonData) } +func (pm *ProxyManager) apiGetRequests(c *gin.Context) { + entries := pm.requestMonitor.GetEntries() + // Strip bodies for list view + for i := range entries { + entries[i].RequestBody = "" + entries[i].ResponseBody = "" + } + c.JSON(http.StatusOK, entries) +} + +func (pm *ProxyManager) apiGetRequest(c *gin.Context) { + idStr := c.Param("id") + var id int + if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request id"}) + return + } + + entry, found := pm.requestMonitor.GetEntry(id) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "request not found"}) + return + } + + c.JSON(http.StatusOK, entry) +} + func (pm *ProxyManager) apiUnloadSingleModelHandler(c *gin.Context) { requestedModel := strings.TrimPrefix(c.Param("model"), "/") realModelName, found := pm.config.RealModelName(requestedModel) diff --git a/proxy/request_monitor.go b/proxy/request_monitor.go new file mode 100644 index 00000000..39973e71 --- /dev/null +++ b/proxy/request_monitor.go @@ -0,0 +1,131 @@ +package proxy + +import ( + "bytes" + "sync" + "time" + + "github.com/mostlygeek/llama-swap/event" +) + +type RequestEntry struct { + ID int `json:"id"` + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + Path string `json:"path"` + Model string `json:"model"` + Status int `json:"status"` + Duration time.Duration `json:"duration"` + RequestBody string `json:"request_body,omitempty"` + ResponseBody string `json:"response_body,omitempty"` + Pending bool `json:"pending"` + lastEmit time.Time + respBuf bytes.Buffer +} + +type RequestEvent struct { + Entry RequestEntry +} + +func (e RequestEvent) Type() uint32 { + return RequestEventID +} + +type requestMonitor struct { + mu sync.RWMutex + entries []*RequestEntry + maxEntries int + nextID int +} + +func newRequestMonitor(maxEntries int) *requestMonitor { + return &requestMonitor{ + maxEntries: maxEntries, + entries: make([]*RequestEntry, 0), + } +} + +func (rm *requestMonitor) Add(entry *RequestEntry) int { + rm.mu.Lock() + defer rm.mu.Unlock() + + entry.ID = rm.nextID + rm.nextID++ + entry.Pending = true + entry.lastEmit = time.Now() + + rm.entries = append(rm.entries, entry) + if len(rm.entries) > rm.maxEntries { + rm.entries = rm.entries[len(rm.entries)-rm.maxEntries:] + } + + event.Emit(RequestEvent{Entry: *entry}) + return entry.ID +} + +func (rm *requestMonitor) Update(id int, status int, duration time.Duration, responseBody string) { + rm.mu.Lock() + defer rm.mu.Unlock() + + for _, e := range rm.entries { + if e.ID == id { + e.Status = status + e.Duration = duration + if responseBody != "" { + e.ResponseBody = responseBody + } else if e.respBuf.Len() > 0 { + e.ResponseBody = e.respBuf.String() + } + e.Pending = false + // Create a copy to emit to avoid race conditions if the pointer is modified later + entryCopy := *e + event.Emit(RequestEvent{Entry: entryCopy}) + return + } + } +} + +func (rm *requestMonitor) AppendResponse(id int, data string) { + rm.mu.Lock() + defer rm.mu.Unlock() + + for _, e := range rm.entries { + if e.ID == id { + // Limit streaming buffer to 1MB total per request + if e.respBuf.Len() < 1024*1024 { + e.respBuf.WriteString(data) + + // Throttle emissions during streaming to 10 per second + if time.Since(e.lastEmit) > 100*time.Millisecond { + e.lastEmit = time.Now() + e.ResponseBody = e.respBuf.String() + event.Emit(RequestEvent{Entry: *e}) + } + } + return + } + } +} + +func (rm *requestMonitor) GetEntries() []RequestEntry { + rm.mu.RLock() + defer rm.mu.RUnlock() + + result := make([]RequestEntry, len(rm.entries)) + for i, e := range rm.entries { + result[i] = *e + } + return result +} + +func (rm *requestMonitor) GetEntry(id int) (RequestEntry, bool) { + rm.mu.RLock() + defer rm.mu.RUnlock() + + for _, e := range rm.entries { + if e.ID == id { + return *e, true + } + } + return RequestEntry{}, false +} diff --git a/ui-svelte/package-lock.json b/ui-svelte/package-lock.json index 1958d64d..8c86b603 100644 --- a/ui-svelte/package-lock.json +++ b/ui-svelte/package-lock.json @@ -925,7 +925,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1308,7 +1307,6 @@ "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1441,7 +1439,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3452,7 +3449,6 @@ "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -3565,7 +3561,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.5.tgz", "integrity": "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3721,7 +3716,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3900,7 +3894,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/ui-svelte/src/App.svelte b/ui-svelte/src/App.svelte index 41ec576e..ebd5304f 100644 --- a/ui-svelte/src/App.svelte +++ b/ui-svelte/src/App.svelte @@ -5,6 +5,7 @@ import LogViewer from "./routes/LogViewer.svelte"; import Models from "./routes/Models.svelte"; import Activity from "./routes/Activity.svelte"; + import Requests from "./routes/Requests.svelte"; import Playground from "./routes/Playground.svelte"; import { enableAPIEvents } from "./stores/api"; import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme"; @@ -14,6 +15,7 @@ "/models": Models, "/logs": LogViewer, "/activity": Activity, + "/requests": Requests, "*": Playground, }; diff --git a/ui-svelte/src/components/Header.svelte b/ui-svelte/src/components/Header.svelte index 310cb95b..5b07a01b 100644 --- a/ui-svelte/src/components/Header.svelte +++ b/ui-svelte/src/components/Header.svelte @@ -68,6 +68,14 @@ > Activity + + Requests + + let { content = "" } = $props(); + + let formattedContent = $derived.by(() => { + try { + const obj = JSON.parse(content); + return JSON.stringify(obj, null, 2); + } catch (e) { + return content; + } + }); + + +
+
{formattedContent}
+
diff --git a/ui-svelte/src/components/playground/ChatMessage.svelte b/ui-svelte/src/components/playground/ChatMessage.svelte index 1f573826..7180db5a 100644 --- a/ui-svelte/src/components/playground/ChatMessage.svelte +++ b/ui-svelte/src/components/playground/ChatMessage.svelte @@ -1,14 +1,9 @@ + +
+

Requests

+ +
+ + {#snippet leftPanel()} +
+ + + + + + + + + + + + + + {#each sortedRequests as req (req.id)} + viewDetail(req)} + > + + + + + + + + + {/each} + +
IDTimeMethodPathModelStatusDuration
{req.id + 1}{formatRelativeTime(req.timestamp)}{req.method}{req.path}{req.model} + {#if req.pending} + pending + {:else} + = 200 && req.status < 300 ? 'text-green-500' : 'text-red-500'}> + {req.status} + + {/if} + {req.pending ? "-" : formatDuration(req.duration)}
+
+ {/snippet} + + {#snippet rightPanel()} + {#if selectedRequest} +
+
+
+

Request Detail #{selectedRequest.id + 1}

+ +
+ +
+ + {#if isLoadingDetail} +
Loading details...
+ {:else} +
+ {#if showFullJson} + {#if selectedRequest.request_body} +
+

Request Body (JSON)

+ +
+ {/if} + + {#if selectedRequest.response_body} +
+

Response Body (Raw/SSE)

+ +
+ {/if} + {:else} + {@const reqParts = parseRequestParts(selectedRequest)} + {#if reqParts.length > 0} +
+ {#each reqParts as part, i} + {@const cardId = `req-${selectedRequest.id}-${i}`} + {@const isCollapsed = collapsedCards[cardId]} +
+ + + {#if !isCollapsed} +
+ {#if part.tools} +
+ {#each part.tools as tool} +
+
+ {tool.name} +
+ {#if tool.description} +

{tool.description}

+ {/if} + {#if tool.parameters && tool.parameters.properties} +
+

Parameters

+
+ + + + + + + + + + {#each Object.entries(tool.parameters.properties) as [propName, prop]} + + + + + + {/each} + +
NameTypeDescription
+ {propName} + {#if tool.parameters.required?.includes(propName)} + * + {/if} + + + {(prop as any).type || 'any'} + + + {(prop as any).description || '-'} +
+
+
+ {:else if tool.parameters} +
+

Parameters

+ +
+ {/if} +
+ {/each} +
+ {:else if part.type === 'tool call'} + {@const parsedArgs = (() => { + try { + return JSON.parse(part.args || "{}"); + } catch (e) { + return null; + } + })()} + {#if parsedArgs && typeof parsedArgs === 'object'} +
+ + + + + + + + + {#each Object.entries(parsedArgs) as [name, value]} + + + + + {/each} + +
ArgumentValue
{name} + {#if typeof value === 'object' && value !== null} + + {:else} + {value} + {/if} +
+
+ {:else} + + {/if} + {:else} +
+ {part.text} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {:else if selectedRequest.request_body} +
+

Request Body

+ +
+ {/if} + + {@const respParts = parseResponseParts(selectedRequest)} + {#if respParts.length > 0} +
+ {#each respParts as part, i} + {@const cardId = `resp-${selectedRequest.id}-${i}`} + {@const isCollapsed = collapsedCards[cardId]} +
+ + + {#if !isCollapsed} +
+ {#if part.type === 'tool call'} + {@const parsedArgs = (() => { + try { + return JSON.parse(part.args || "{}"); + } catch (e) { + return null; + } + })()} + {#if parsedArgs && typeof parsedArgs === 'object'} +
+ + + + + + + + + {#each Object.entries(parsedArgs) as [name, value]} + + + + + {/each} + +
ArgumentValue
{name} + {#if typeof value === 'object' && value !== null} + + {:else} + {value} + {/if} +
+
+ {:else} + + {/if} + {:else} +
+ {part.text} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {:else if selectedRequest.response_body} +
+

Response Body

+ +
+ {/if} + {/if} +
+ {/if} +
+ {:else} +
+ Select a request to view details +
+ {/if} + {/snippet} +
+
+
diff --git a/ui-svelte/src/stores/api.ts b/ui-svelte/src/stores/api.ts index cbeef16f..ab677f11 100644 --- a/ui-svelte/src/stores/api.ts +++ b/ui-svelte/src/stores/api.ts @@ -1,5 +1,5 @@ import { writable } from "svelte/store"; -import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope } from "../lib/types"; +import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope, RequestLog } from "../lib/types"; import { connectionState } from "./theme"; const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */ @@ -9,6 +9,7 @@ export const models = writable([]); export const proxyLogs = writable(""); export const upstreamLogs = writable(""); export const metrics = writable([]); +export const requests = writable([]); export const versionInfo = writable({ build_date: "unknown", commit: "unknown", @@ -46,6 +47,7 @@ export function enableAPIEvents(enabled: boolean): void { proxyLogs.set(""); upstreamLogs.set(""); metrics.set([]); + requests.set([]); models.set([]); retryCount = 0; connectionState.set("connected"); @@ -83,6 +85,21 @@ export function enableAPIEvents(enabled: boolean): void { metrics.update((prevMetrics) => [...newMetrics, ...prevMetrics]); break; } + + case "request": { + const req = JSON.parse(message.data) as RequestLog; + requests.update((prev) => { + const index = prev.findIndex((r) => r.id === req.id); + if (index === -1) { + return [req, ...prev]; + } else { + const updated = [...prev]; + updated[index] = req; + return updated; + } + }); + break; + } } } catch (err) { console.error(e.data, err); @@ -172,3 +189,16 @@ export async function loadModel(model: string): Promise { throw error; } } + +export async function getRequestDetail(id: number): Promise { + try { + const response = await fetch(`/api/requests/${id}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error("Failed to fetch request detail:", error); + throw error; + } +}