This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 0fdbdf5  feat(proxy): keep a record of every relayed LLM request
0fdbdf5 is described below

commit 0fdbdf5b5a581fcc867f2aee601fcaf46509c1d2
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 01:45:39 2026 +0800

    feat(proxy): keep a record of every relayed LLM request
---
 auditutil/sanitize.go               |  12 ++
 conf/app.conf                       |   8 +
 conf/conf.go                        |  49 +++++
 controllers/llm_record.go           | 127 ++++++++++++
 controllers/proxy.go                |  44 +++-
 controllers/proxy_record.go         | 171 ++++++++++++++++
 main.go                             |   2 +
 object/llm_record.go                | 393 ++++++++++++++++++++++++++++++++++++
 object/ormer.go                     |   5 +
 routers/router.go                   |   6 +
 web/src/App.tsx                     |   5 +
 web/src/backend/LlmRecordBackend.ts |  46 +++++
 web/src/locales/en/data.json        |  28 +++
 web/src/locales/zh/data.json        |  28 +++
 web/src/nav.ts                      |   2 +
 web/src/pages/LlmRecordsPage.tsx    | 387 +++++++++++++++++++++++++++++++++++
 web/src/types.ts                    |  33 +++
 17 files changed, 1344 insertions(+), 2 deletions(-)

diff --git a/auditutil/sanitize.go b/auditutil/sanitize.go
index b4c9c78..787b2b6 100644
--- a/auditutil/sanitize.go
+++ b/auditutil/sanitize.go
@@ -58,11 +58,23 @@ func SanitizeValue(key string, value any) any {
        }
 }
 
+// tokenCountKeys carry the "token" marker below but are ordinary model
+// parameters and usage counters, not credentials.
+var tokenCountKeys = map[string]bool{
+       "maxtokens": true, "maxcompletiontokens": true, "maxtokenstosample": 
true, "maxoutputtokens": true,
+       "prompttokens": true, "completiontokens": true, "totaltokens": true, 
"reasoningtokens": true,
+       "inputtokens": true, "outputtokens": true, "tokencount": true, 
"numtokens": true, "tokens": true,
+       "cachecreationinputtokens": true, "cachereadinputtokens": true, 
"cachedtokens": true,
+}
+
 // SensitiveKey recognizes common credential-bearing field names after
 // punctuation and case normalization.
 func SensitiveKey(key string) bool {
        normalized := strings.ToLower(key)
        normalized = strings.NewReplacer("_", "", "-", "", ".", 
"").Replace(normalized)
+       if tokenCountKeys[normalized] {
+               return false
+       }
        if normalized == "token" || normalized == "accesstoken" || normalized 
== "refreshtoken" || normalized == "idtoken" {
                return true
        }
diff --git a/conf/app.conf b/conf/app.conf
index 4e7c292..a26b290 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -89,3 +89,11 @@ agentPatchStateDir = "./data/agent-patches"
 agentRecordCapacity = 1000
 ; How often to rescan the append-only logs of file-tailed agents, in seconds.
 agentMonitorPollSeconds = 5
+
+; LLM request records. "off" keeps nothing, "metadata" records who called which
+; model with which outcome, and "full" also stores the sanitized request body.
+; Anything beyond "off" is opt-in because prompts can carry sensitive data.
+llmRecordMode = "off"
+llmRecordQueueCapacity = 1000
+llmRecordRetentionDays = 30
+llmRecordMaxRecords = 10000
diff --git a/conf/conf.go b/conf/conf.go
index 7e74769..fb30e26 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -225,6 +225,55 @@ func GetAgentMonitorPollSeconds() int {
        return res
 }
 
+// How much of an LLM request Gateway retains.
+const (
+       LlmRecordOff      = "off"
+       LlmRecordMetadata = "metadata"
+       LlmRecordFull     = "full"
+)
+
+// GetLlmRecordMode reports whether LLM requests are recorded, and whether the
+// request body is kept along with the metadata. Prompts are sensitive, so the
+// default is to keep nothing.
+func GetLlmRecordMode() string {
+       switch strings.ToLower(strings.Trim(GetConfigString("llmRecordMode"), 
`"' `)) {
+       case LlmRecordMetadata:
+               return LlmRecordMetadata
+       case LlmRecordFull:
+               return LlmRecordFull
+       default:
+               return LlmRecordOff
+       }
+}
+
+// GetLlmRecordQueueCapacity bounds the records waiting to be written. A full
+// queue drops records rather than slowing the proxy down.
+func GetLlmRecordQueueCapacity() int {
+       res := GetConfigIntDefault("llmRecordQueueCapacity", 1000)
+       if res <= 0 {
+               res = 1000
+       }
+       return res
+}
+
+// GetLlmRecordRetentionDays is how long a recorded request is kept.
+func GetLlmRecordRetentionDays() int {
+       res := GetConfigIntDefault("llmRecordRetentionDays", 30)
+       if res <= 0 {
+               res = 30
+       }
+       return res
+}
+
+// GetLlmRecordMaxRecords caps the table regardless of the retention window.
+func GetLlmRecordMaxRecords() int {
+       res := GetConfigIntDefault("llmRecordMaxRecords", 10000)
+       if res <= 0 {
+               res = 10000
+       }
+       return res
+}
+
 func GetConfigRealDataSourceName(driverName string) string {
        var dataSourceName string
        if driverName != "mysql" {
diff --git a/controllers/llm_record.go b/controllers/llm_record.go
new file mode 100644
index 0000000..423545f
--- /dev/null
+++ b/controllers/llm_record.go
@@ -0,0 +1,127 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "strconv"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+// A record holds what a user asked a model, so unlike the proxy endpoints
+// themselves these are limited to Gateway administrators.
+
+// GetLlmRecords lists one page of relayed requests, without their bodies.
+func (c *ApiController) GetLlmRecords() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       page, _ := strconv.Atoi(c.Input().Get("p"))
+       if page < 1 {
+               page = 1
+       }
+       limit, _ := strconv.Atoi(c.Input().Get("pageSize"))
+       if limit < 1 {
+               limit = 25
+       }
+       if limit > 100 {
+               limit = 100
+       }
+
+       filter := object.LlmRecordFilter{
+               Model:    c.Input().Get("model"),
+               Channel:  c.Input().Get("channel"),
+               Agent:    c.Input().Get("agent"),
+               ClientIp: c.Input().Get("clientIp"),
+               Outcome:  c.Input().Get("outcome"),
+       }
+       records, count, err := object.GetLlmRecords(filter, (page-1)*limit, 
limit)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(records, count)
+}
+
+// GetLlmRecord returns one record with its stored request body.
+func (c *ApiController) GetLlmRecord() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       id, err := strconv.ParseInt(c.Input().Get("id"), 10, 64)
+       if err != nil {
+               c.ResponseError("invalid record id")
+               return
+       }
+       record, err := object.GetLlmRecord(id)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(record)
+}
+
+// GetLlmRecordStatus reports the recorder's own settings and how many records
+// it had to drop, so a gap in the list can be explained.
+func (c *ApiController) GetLlmRecordStatus() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       status, err := object.GetLlmRecordStatus()
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(status)
+}
+
+func (c *ApiController) DeleteLlmRecord() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       id, err := strconv.ParseInt(c.Input().Get("id"), 10, 64)
+       if err != nil {
+               c.ResponseError("invalid record id")
+               return
+       }
+       if err := object.DeleteLlmRecord(id); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(true)
+}
+
+// ClearLlmRecords drops every record, for the operator who has to answer a
+// request to erase retained prompts.
+func (c *ApiController) ClearLlmRecords() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       if err := object.ClearLlmRecords(); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(true)
+}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index d11d258..b377bed 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -28,6 +28,7 @@ import (
 
        "github.com/apache/casbin-gateway/object"
        "github.com/apache/casbin-gateway/proxy"
+       "github.com/apache/casbin-gateway/util"
        "github.com/beego/beego"
 )
 
@@ -78,6 +79,10 @@ type proxyRoute struct {
        // source describes how the channels were chosen, for the error a client
        // sees when none of them can be used.
        source string
+       start  time.Time
+       // record accumulates what is written to the LLM record of this 
request. It
+       // is nil while recording is off.
+       record *object.LlmRecord
 }
 
 // routingFields are the only fields read out of the request body. Both the
@@ -173,14 +178,19 @@ func (c *ApiController) proxyByModel(target proxyTarget) {
                return
        }
        route.source = "model: " + route.model
+       // Every way out of a proxy entry point ends the client request, which 
is
+       // what a record describes, so this is the only place one is written.
+       defer c.finishLlmRecord(route)
 
        // Match the channels globally, without an owner filter.
        channels, err := object.GetChannelsByModel(route.model)
        if err != nil {
                if errors.Is(err, object.ErrNoChannelAvailable) {
+                       route.recordOutcome(http.StatusBadRequest, err.Error())
                        c.writeProxyError(target.protocol, 
http.StatusBadRequest, "invalid_request_error", err.Error())
                } else {
                        beego.Error("channel lookup failed:", err)
+                       route.recordOutcome(http.StatusBadGateway, "channel 
lookup failed")
                        c.writeProxyError(target.protocol, 
http.StatusBadGateway, "server_error", "channel lookup failed")
                }
                return
@@ -197,13 +207,19 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
        }
        agentId := c.Ctx.Input.Param(":agentId")
        route.source = "agent: " + agentId
+       if route.record != nil {
+               route.record.Agent = agentId
+       }
+       defer c.finishLlmRecord(route)
 
        channel, err := object.GetChannelByAgent(agentId)
        if err != nil {
                if errors.Is(err, object.ErrAgentNoChannel) {
+                       route.recordOutcome(http.StatusBadRequest, err.Error())
                        c.writeProxyError(target.protocol, 
http.StatusBadRequest, "invalid_request_error", err.Error())
                } else {
                        beego.Error("agent channel lookup failed:", err)
+                       route.recordOutcome(http.StatusBadGateway, err.Error())
                        c.writeProxyError(target.protocol, 
http.StatusBadGateway, "server_error", err.Error())
                }
                return
@@ -225,7 +241,17 @@ func (c *ApiController) readProxyRoute(target proxyTarget) 
(*proxyRoute, bool) {
                return nil, false
        }
 
-       return &proxyRoute{target: target, body: rawBody, model: fields.Model, 
stream: fields.Stream}, true
+       route := &proxyRoute{target: target, body: rawBody, model: 
fields.Model, stream: fields.Stream, start: time.Now()}
+       if object.IsLlmRecording() {
+               route.record = &object.LlmRecord{
+                       Protocol: target.protocol,
+                       Endpoint: target.endpoint,
+                       Model:    fields.Model,
+                       ClientIp: util.GetClientIp(c.Ctx.Request),
+                       Stream:   fields.Stream,
+               }
+       }
+       return route, true
 }
 
 // forwardToChannels relays the request to the first channel that answers.
@@ -247,6 +273,7 @@ func (c *ApiController) forwardToChannels(channels 
[]*object.Channel, route *pro
                if skipReason != "" {
                        message = skipReason
                }
+               route.recordOutcome(http.StatusBadGateway, message)
                c.writeProxyError(route.target.protocol, http.StatusBadGateway, 
"server_error", message)
                return
        }
@@ -258,6 +285,7 @@ func (c *ApiController) forwardToChannels(channels 
[]*object.Channel, route *pro
        for i, channel := range usableChannels {
                if c.Ctx.Request.Context().Err() != nil {
                        // The client hung up, there is nobody left to fail 
over for.
+                       route.recordOutcome(0, "client disconnected")
                        return
                }
 
@@ -268,6 +296,7 @@ func (c *ApiController) forwardToChannels(channels 
[]*object.Channel, route *pro
                lastStatus, lastMessage = status, message
        }
 
+       route.recordOutcome(lastStatus, lastMessage)
        c.writeProxyError(route.target.protocol, lastStatus, "server_error", 
lastMessage)
 }
 
@@ -277,6 +306,8 @@ func (c *ApiController) forwardToChannels(channels 
[]*object.Channel, route *pro
 // the next channel. The response of the last channel is always relayed, even
 // when its status would otherwise be retried.
 func (c *ApiController) forwardToChannel(channel *object.Channel, route 
*proxyRoute, isLast bool) (int, string, bool) {
+       route.recordAttempt(channel.GetId())
+
        upstreamUrl, err := object.BuildChannelUrl(channel.BaseUrl, 
route.target.protocol, route.target.endpoint)
        if err != nil {
                return http.StatusBadGateway, err.Error(), false
@@ -301,6 +332,7 @@ func (c *ApiController) forwardToChannel(channel 
*object.Channel, route *proxyRo
        if err != nil {
                if c.Ctx.Request.Context().Err() != nil {
                        // The client hung up mid-request, there is nothing 
left to answer.
+                       route.recordOutcome(0, "client disconnected")
                        return 0, "", true
                }
 
@@ -325,7 +357,15 @@ func (c *ApiController) forwardToChannel(channel 
*object.Channel, route *proxyRo
        body := newIdleTimeoutReader(upstreamResp.Body, proxyIdleTimeout, 
cancel)
        defer body.Stop()
 
-       c.relayResponse(upstreamResp, body, route.stream && 
isEventStream(upstreamResp))
+       route.recordOutcome(upstreamResp.StatusCode, "")
+       if route.record == nil {
+               c.relayResponse(upstreamResp, body, route.stream && 
isEventStream(upstreamResp))
+               return 0, "", true
+       }
+
+       tap := &usageTap{reader: body}
+       c.relayResponse(upstreamResp, tap, route.stream && 
isEventStream(upstreamResp))
+       route.recordUsage(tap.tail)
        return 0, "", true
 }
 
diff --git a/controllers/proxy_record.go b/controllers/proxy_record.go
new file mode 100644
index 0000000..13dd5d1
--- /dev/null
+++ b/controllers/proxy_record.go
@@ -0,0 +1,171 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "bytes"
+       "encoding/json"
+       "io"
+       "time"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+// usageTailBytes is how much of the end of a response is kept to read the 
token
+// usage out of. Both APIs report it last, so the tail is enough.
+const usageTailBytes = 16 * 1024
+
+// finishLlmRecord queues the record of one relayed request. Every path out of
+// forwardToChannels ends the client request, so this is the only place it is
+// called from.
+func (c *ApiController) finishLlmRecord(route *proxyRoute) {
+       if route.record == nil {
+               return
+       }
+
+       record := route.record
+       route.record = nil
+       record.DurationMs = time.Since(route.start).Milliseconds()
+       object.AddLlmRecord(record, route.body)
+}
+
+func (route *proxyRoute) recordAttempt(channelId string) {
+       if route.record == nil {
+               return
+       }
+       route.record.Attempts++
+       route.record.Channel = channelId
+}
+
+func (route *proxyRoute) recordOutcome(status int, message string) {
+       if route.record == nil {
+               return
+       }
+       route.record.Status = status
+       route.record.Error = message
+}
+
+func (route *proxyRoute) recordUsage(tail []byte) {
+       if route.record == nil {
+               return
+       }
+
+       usage := readUsage(tail)
+       route.record.PromptTokens = higher(usage.PromptTokens, 
usage.InputTokens)
+       route.record.CompletionTokens = higher(usage.CompletionTokens, 
usage.OutputTokens)
+       route.record.TotalTokens = usage.TotalTokens
+       if route.record.TotalTokens == 0 {
+               route.record.TotalTokens = route.record.PromptTokens + 
route.record.CompletionTokens
+       }
+}
+
+// usageTap keeps the tail of a relayed response. It never changes a byte of
+// what the client receives, and never holds more than usageTailBytes.
+type usageTap struct {
+       reader io.Reader
+       tail   []byte
+}
+
+func (tap *usageTap) Read(p []byte) (int, error) {
+       n, err := tap.reader.Read(p)
+       if n > 0 {
+               tap.keep(p[:n])
+       }
+       return n, err
+}
+
+func (tap *usageTap) keep(chunk []byte) {
+       if len(chunk) >= usageTailBytes {
+               tap.tail = append(tap.tail[:0], 
chunk[len(chunk)-usageTailBytes:]...)
+               return
+       }
+       if len(tap.tail)+len(chunk) > usageTailBytes {
+               kept := usageTailBytes - len(chunk)
+               copy(tap.tail, tap.tail[len(tap.tail)-kept:])
+               tap.tail = tap.tail[:kept]
+       }
+       tap.tail = append(tap.tail, chunk...)
+}
+
+// llmUsage covers both spellings of the same counters: OpenAI reports
+// prompt/completion tokens, Anthropic input/output ones.
+type llmUsage struct {
+       PromptTokens     int `json:"prompt_tokens"`
+       CompletionTokens int `json:"completion_tokens"`
+       TotalTokens      int `json:"total_tokens"`
+       InputTokens      int `json:"input_tokens"`
+       OutputTokens     int `json:"output_tokens"`
+}
+
+func (usage *llmUsage) merge(other llmUsage) {
+       usage.PromptTokens = higher(usage.PromptTokens, other.PromptTokens)
+       usage.CompletionTokens = higher(usage.CompletionTokens, 
other.CompletionTokens)
+       usage.TotalTokens = higher(usage.TotalTokens, other.TotalTokens)
+       usage.InputTokens = higher(usage.InputTokens, other.InputTokens)
+       usage.OutputTokens = higher(usage.OutputTokens, other.OutputTokens)
+}
+
+// readUsage merges every usage object in the tail: a stream splits the 
counters
+// across several events, and a plain response carries them once.
+func readUsage(tail []byte) llmUsage {
+       usage := llmUsage{}
+       marker := []byte(`"usage"`)
+       for rest := tail; ; {
+               index := bytes.Index(rest, marker)
+               if index < 0 {
+                       return usage
+               }
+               rest = rest[index+len(marker):]
+
+               var parsed llmUsage
+               if value := balancedObject(rest); value != nil && 
json.Unmarshal(value, &parsed) == nil {
+                       usage.merge(parsed)
+               }
+       }
+}
+
+// balancedObject returns the JSON object that directly follows a key, or nil
+// when the value is not one. Usage objects hold only numbers, so counting
+// braces cannot be thrown off by one inside a string.
+func balancedObject(data []byte) []byte {
+       start := 0
+       for start < len(data) && (data[start] == ':' || data[start] == ' ' || 
data[start] == '\t' || data[start] == '\r' || data[start] == '\n') {
+               start++
+       }
+       if start >= len(data) || data[start] != '{' {
+               return nil
+       }
+
+       depth := 0
+       for i := start; i < len(data); i++ {
+               switch data[i] {
+               case '{':
+                       depth++
+               case '}':
+                       depth--
+                       if depth == 0 {
+                               return data[start : i+1]
+                       }
+               }
+       }
+       return nil
+}
+
+func higher(a int, b int) int {
+       if a > b {
+               return a
+       }
+       return b
+}
diff --git a/main.go b/main.go
index 496486c..e445e90 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,8 @@ func main() {
        object.InitFlag()
        object.InitAdapter()
        object.CreateTables()
+       object.StartLlmRecordWriter()
+       defer object.StopLlmRecordWriter()
        casdoor.InitCasdoorConfig()
        object.InitUsers()
        proxy.InitHttpClient()
diff --git a/object/llm_record.go b/object/llm_record.go
new file mode 100644
index 0000000..7ef662e
--- /dev/null
+++ b/object/llm_record.go
@@ -0,0 +1,393 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "encoding/json"
+       "strings"
+       "sync"
+       "sync/atomic"
+       "time"
+
+       "github.com/apache/casbin-gateway/auditutil"
+       "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/util"
+       "github.com/beego/beego"
+       "github.com/xorm-io/xorm"
+)
+
+const llmRecordSummaryRunes = 160
+
+// LlmRecord is one client request relayed by the LLM proxy, together with the
+// outcome it ended in. Payload is filled only in "full" mode, and only after
+// auditutil has removed the credentials the body may carry.
+type LlmRecord struct {
+       Id          int64  `xorm:"int notnull pk autoincr" json:"id"`
+       CreatedTime string `xorm:"varchar(100) notnull index" 
json:"createdTime"`
+
+       Protocol string `xorm:"varchar(20)" json:"protocol"`
+       Endpoint string `xorm:"varchar(100)" json:"endpoint"`
+       Model    string `xorm:"varchar(255) index" json:"model"`
+       Channel  string `xorm:"varchar(201) index" json:"channel"`
+       Agent    string `xorm:"varchar(201) index" json:"agent"`
+       ClientIp string `xorm:"varchar(100) index" json:"clientIp"`
+       Stream   bool   `xorm:"bool" json:"stream"`
+
+       Status     int    `xorm:"int index" json:"status"`
+       DurationMs int64  `xorm:"bigint" json:"durationMs"`
+       Attempts   int    `xorm:"int" json:"attempts"`
+       Error      string `xorm:"varchar(500)" json:"error"`
+
+       PromptTokens     int `xorm:"int" json:"promptTokens"`
+       CompletionTokens int `xorm:"int" json:"completionTokens"`
+       TotalTokens      int `xorm:"int" json:"totalTokens"`
+
+       Summary    string `xorm:"varchar(500)" json:"summary"`
+       Payload    string `xorm:"mediumtext" json:"payload"`
+       Redactions int    `xorm:"int" json:"redactions"`
+       Truncated  bool   `xorm:"bool" json:"truncated"`
+       Bytes      int    `xorm:"int" json:"bytes"`
+}
+
+// LlmRecordFilter narrows the record list. Empty fields match everything.
+type LlmRecordFilter struct {
+       Model    string
+       Channel  string
+       Agent    string
+       ClientIp string
+       Outcome  string
+}
+
+// LlmRecordStatus is what the management page shows about the recorder itself,
+// so that a gap in the records is visible instead of silent.
+type LlmRecordStatus struct {
+       Mode          string `json:"mode"`
+       RetentionDays int    `json:"retentionDays"`
+       MaxRecords    int    `json:"maxRecords"`
+       Dropped       int64  `json:"dropped"`
+       Count         int64  `json:"count"`
+}
+
+type llmRecordTask struct {
+       record  *LlmRecord
+       rawBody []byte
+}
+
+type llmRecordWriter struct {
+       mutex sync.RWMutex
+       queue chan llmRecordTask
+       done  chan struct{}
+
+       dropped     atomic.Int64
+       dropLogTime atomic.Int64
+}
+
+var llmWriter llmRecordWriter
+
+// StartLlmRecordWriter starts the writer that owns every parse, sanitization
+// and database write, so the proxy goroutine never does any of them.
+func StartLlmRecordWriter() {
+       if conf.GetLlmRecordMode() == conf.LlmRecordOff {
+               return
+       }
+
+       llmWriter.mutex.Lock()
+       defer llmWriter.mutex.Unlock()
+       if llmWriter.queue != nil {
+               return
+       }
+       llmWriter.queue = make(chan llmRecordTask, 
conf.GetLlmRecordQueueCapacity())
+       llmWriter.done = make(chan struct{})
+       go llmWriter.run(llmWriter.queue, llmWriter.done)
+}
+
+// StopLlmRecordWriter drains the records already accepted.
+func StopLlmRecordWriter() {
+       llmWriter.mutex.Lock()
+       queue, done := llmWriter.queue, llmWriter.done
+       llmWriter.queue, llmWriter.done = nil, nil
+       llmWriter.mutex.Unlock()
+
+       if queue == nil {
+               return
+       }
+       close(queue)
+       <-done
+}
+
+// IsLlmRecording reports whether a record would be kept at all, so the proxy
+// can skip building one.
+func IsLlmRecording() bool {
+       return conf.GetLlmRecordMode() != conf.LlmRecordOff
+}
+
+// AddLlmRecord queues one finished request. Recording is best effort: a full
+// queue drops the record rather than holding the request up.
+func AddLlmRecord(record *LlmRecord, rawBody []byte) {
+       if record == nil {
+               return
+       }
+       record.CreatedTime = util.GetCurrentTime()
+       record.Bytes = len(rawBody)
+       if conf.GetLlmRecordMode() != conf.LlmRecordFull {
+               rawBody = nil
+       } else if len(rawBody) > auditutil.MaxPayloadBytes {
+               record.Truncated = true
+               rawBody = nil
+       }
+
+       llmWriter.mutex.RLock()
+       defer llmWriter.mutex.RUnlock()
+       if llmWriter.queue == nil {
+               return
+       }
+       select {
+       case llmWriter.queue <- llmRecordTask{record: record, rawBody: rawBody}:
+       default:
+               llmWriter.noteDrop()
+       }
+}
+
+func (writer *llmRecordWriter) noteDrop() {
+       dropped := writer.dropped.Add(1)
+       last := writer.dropLogTime.Load()
+       now := time.Now().Unix()
+       if now-last < 60 || !writer.dropLogTime.CompareAndSwap(last, now) {
+               return
+       }
+       beego.Error("LLM record queue is full, dropped records so far:", 
dropped)
+}
+
+func (writer *llmRecordWriter) run(queue chan llmRecordTask, done chan 
struct{}) {
+       defer close(done)
+
+       writer.prune()
+       ticker := time.NewTicker(time.Minute)
+       defer ticker.Stop()
+       for {
+               select {
+               case task, ok := <-queue:
+                       if !ok {
+                               return
+                       }
+                       writer.write(task)
+               case <-ticker.C:
+                       writer.prune()
+               }
+       }
+}
+
+func (writer *llmRecordWriter) write(task llmRecordTask) {
+       if ormer == nil || ormer.Engine == nil {
+               return
+       }
+       if len(task.rawBody) > 0 {
+               fillLlmRecordBody(task.record, task.rawBody)
+       }
+       if _, err := ormer.Engine.Insert(task.record); err != nil {
+               beego.Error("LLM record write failed:", err)
+       }
+}
+
+func (writer *llmRecordWriter) prune() {
+       if ormer == nil || ormer.Engine == nil {
+               return
+       }
+
+       cutoff := util.FormatTime(time.Now().AddDate(0, 0, 
-conf.GetLlmRecordRetentionDays()))
+       if _, err := ormer.Engine.Where("created_time < ?", 
cutoff).Delete(&LlmRecord{}); err != nil {
+               beego.Error("LLM record retention cleanup failed:", err)
+               return
+       }
+
+       count, err := ormer.Engine.Count(&LlmRecord{})
+       if err != nil {
+               beego.Error("LLM record retention count failed:", err)
+               return
+       }
+       maximum := int64(conf.GetLlmRecordMaxRecords())
+       if count <= maximum {
+               return
+       }
+
+       oldest := []LlmRecord{}
+       if err := 
ormer.Engine.Cols("id").Asc("id").Limit(int(count-maximum)).Find(&oldest); err 
!= nil {
+               beego.Error("LLM record retention lookup failed:", err)
+               return
+       }
+       if len(oldest) == 0 {
+               return
+       }
+       ids := make([]int64, 0, len(oldest))
+       for _, record := range oldest {
+               ids = append(ids, record.Id)
+       }
+       if _, err := ormer.Engine.In("id", ids).Delete(&LlmRecord{}); err != 
nil {
+               beego.Error("LLM record retention deletion failed:", err)
+       }
+}
+
+// fillLlmRecordBody is the only place a request body is retained, and it runs
+// on the writer goroutine. Request headers, which is where the inbound
+// credentials are, never reach a record at all.
+func fillLlmRecordBody(record *LlmRecord, rawBody []byte) {
+       var decoded any
+       if json.Unmarshal(rawBody, &decoded) != nil {
+               return
+       }
+
+       sanitized := auditutil.SanitizeValue("", decoded)
+       encoded, err := json.Marshal(sanitized)
+       if err != nil {
+               return
+       }
+       if len(encoded) > auditutil.MaxPayloadBytes {
+               record.Truncated = true
+               record.Payload = auditutil.EncodeBoundedJSON(sanitized, 
auditutil.MaxPayloadBytes)
+       } else {
+               record.Payload = string(encoded)
+       }
+       record.Redactions = strings.Count(record.Payload, "[REDACTED")
+       record.Summary = llmRecordSummary(sanitized)
+}
+
+// llmRecordSummary is the last thing the user asked for, which is what makes a
+// row recognizable without opening it. Both APIs name the field the same way.
+func llmRecordSummary(value any) string {
+       body, ok := value.(map[string]any)
+       if !ok {
+               return ""
+       }
+       messages, ok := body["messages"].([]any)
+       if !ok {
+               return ""
+       }
+
+       for i := len(messages) - 1; i >= 0; i-- {
+               message, ok := messages[i].(map[string]any)
+               if !ok {
+                       continue
+               }
+               if role, _ := message["role"].(string); role != "user" {
+                       continue
+               }
+               if text := llmMessageText(message["content"]); text != "" {
+                       return boundRunes(strings.Join(strings.Fields(text), " 
"), llmRecordSummaryRunes)
+               }
+       }
+       return ""
+}
+
+func llmMessageText(content any) string {
+       switch typed := content.(type) {
+       case string:
+               return typed
+       case []any:
+               parts := []string{}
+               for _, block := range typed {
+                       blockMap, ok := block.(map[string]any)
+                       if !ok {
+                               continue
+                       }
+                       if text, ok := blockMap["text"].(string); ok {
+                               parts = append(parts, text)
+                       }
+               }
+               return strings.Join(parts, " ")
+       }
+       return ""
+}
+
+func boundRunes(value string, maximum int) string {
+       runes := []rune(value)
+       if len(runes) <= maximum {
+               return value
+       }
+       return string(runes[:maximum]) + "..."
+}
+
+func llmRecordSession(filter LlmRecordFilter) *xorm.Session {
+       session := ormer.Engine.NewSession()
+       if filter.Model != "" {
+               session = session.And("model like ?", "%"+filter.Model+"%")
+       }
+       if filter.Channel != "" {
+               session = session.And("channel like ?", "%"+filter.Channel+"%")
+       }
+       if filter.Agent != "" {
+               session = session.And("agent = ?", filter.Agent)
+       }
+       if filter.ClientIp != "" {
+               session = session.And("client_ip = ?", filter.ClientIp)
+       }
+       switch filter.Outcome {
+       case "ok":
+               session = session.And("status >= 200").And("status < 300")
+       case "error":
+               session = session.And("status = 0 or status < 200 or status >= 
300")
+       }
+       return session
+}
+
+// GetLlmRecords lists one page. The payload is left out on purpose: a list of
+// 25 prompts would be megabytes, and the page only needs it once a row is
+// opened.
+func GetLlmRecords(filter LlmRecordFilter, offset int, limit int) 
([]*LlmRecord, int64, error) {
+       countSession := llmRecordSession(filter)
+       defer countSession.Close()
+       count, err := countSession.Count(&LlmRecord{})
+       if err != nil {
+               return nil, 0, err
+       }
+
+       session := llmRecordSession(filter)
+       defer session.Close()
+       records := []*LlmRecord{}
+       err = session.Omit("payload").Desc("id").Limit(limit, 
offset).Find(&records)
+       return records, count, err
+}
+
+func GetLlmRecord(id int64) (*LlmRecord, error) {
+       record := LlmRecord{Id: id}
+       existed, err := ormer.Engine.Get(&record)
+       if err != nil || !existed {
+               return nil, err
+       }
+       return &record, nil
+}
+
+func DeleteLlmRecord(id int64) error {
+       _, err := ormer.Engine.ID(id).Delete(&LlmRecord{})
+       return err
+}
+
+func ClearLlmRecords() error {
+       _, err := ormer.Engine.Where("id > 0").Delete(&LlmRecord{})
+       return err
+}
+
+func GetLlmRecordStatus() (*LlmRecordStatus, error) {
+       count, err := ormer.Engine.Count(&LlmRecord{})
+       if err != nil {
+               return nil, err
+       }
+       return &LlmRecordStatus{
+               Mode:          conf.GetLlmRecordMode(),
+               RetentionDays: conf.GetLlmRecordRetentionDays(),
+               MaxRecords:    conf.GetLlmRecordMaxRecords(),
+               Dropped:       llmWriter.dropped.Load(),
+               Count:         count,
+       }, nil
+}
diff --git a/object/ormer.go b/object/ormer.go
index 2d7a183..1801e1b 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -287,4 +287,9 @@ func (a *Ormer) createTable() {
        if err != nil {
                panic(err)
        }
+
+       err = a.Engine.Sync2(new(LlmRecord))
+       if err != nil {
+               panic(err)
+       }
 }
diff --git a/routers/router.go b/routers/router.go
index e85d6ab..589968c 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -94,6 +94,12 @@ func initAPI() {
        beego.Router("/api/delete-channel", &controllers.ApiController{}, 
"POST:DeleteChannel")
        beego.Router("/api/test-channel", &controllers.ApiController{}, 
"POST:TestChannel")
 
+       beego.Router("/api/get-llm-records", &controllers.ApiController{}, 
"GET:GetLlmRecords")
+       beego.Router("/api/get-llm-record", &controllers.ApiController{}, 
"GET:GetLlmRecord")
+       beego.Router("/api/get-llm-record-status", 
&controllers.ApiController{}, "GET:GetLlmRecordStatus")
+       beego.Router("/api/delete-llm-record", &controllers.ApiController{}, 
"POST:DeleteLlmRecord")
+       beego.Router("/api/clear-llm-records", &controllers.ApiController{}, 
"POST:ClearLlmRecords")
+
        // The LLM gateway, in both wire formats. The agent routes carry the two
        // endpoint shapes an OpenAI and an Anthropic client append to one base 
URL.
        beego.Router("/v1/chat/completions", &controllers.ApiController{}, 
"POST:ChatCompletions")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 202186b..8ecade2 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -37,6 +37,7 @@ import CertEditPage from "@/pages/CertEditPage";
 import CertListPage from "@/pages/CertListPage";
 import ChannelEditPage from "@/pages/ChannelEditPage";
 import ChannelListPage from "@/pages/ChannelListPage";
+import LlmRecordsPage from "@/pages/LlmRecordsPage";
 import NodeEditPage from "@/pages/NodeEditPage";
 import NodeListPage from "@/pages/NodeListPage";
 import RecordEditPage from "@/pages/RecordEditPage";
@@ -280,6 +281,10 @@ export default function App() {
                   path="/channels/:owner/:channelName"
                   element={requireSignin(() => <ChannelEditPage />)}
                 />
+                <Route
+                  path="/llm-records"
+                  element={requireSignin(user => <LlmRecordsPage 
account={user} />)}
+                />
                 <Route path="/dashboard" element={requireSignin(() => 
<DashboardPage />)} />
               </Routes>
             </React.Suspense>
diff --git a/web/src/backend/LlmRecordBackend.ts 
b/web/src/backend/LlmRecordBackend.ts
new file mode 100644
index 0000000..d0d5b38
--- /dev/null
+++ b/web/src/backend/LlmRecordBackend.ts
@@ -0,0 +1,46 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {query, request} from "@/backend/request";
+import type {LlmRecord, LlmRecordStatus} from "@/types";
+
+export interface LlmRecordFilter {
+  model?: string;
+  channel?: string;
+  agent?: string;
+  clientIp?: string;
+  outcome?: string;
+}
+
+export function getLlmRecords(page = 1, pageSize = 25, filter: LlmRecordFilter 
= {}) {
+  return request<LlmRecord[], number>(
+    `/api/get-llm-records${query({p: page, pageSize: pageSize, ...filter})}`,
+  );
+}
+
+export function getLlmRecord(id: number) {
+  return request<LlmRecord>(`/api/get-llm-record${query({id: id})}`);
+}
+
+export function getLlmRecordStatus() {
+  return request<LlmRecordStatus>("/api/get-llm-record-status");
+}
+
+export function deleteLlmRecord(id: number) {
+  return request<boolean>(`/api/delete-llm-record${query({id: id})}`, "POST");
+}
+
+export function clearLlmRecords() {
+  return request<boolean>("/api/clear-llm-records", "POST");
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 8f40c2e..2346871 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -231,6 +231,34 @@
     "User-Agent": "User-Agent",
     "{total} in total": "{total} in total"
   },
+  "llm": {
+    "Any outcome": "Any outcome",
+    "Attempts": "Attempts",
+    "Body not stored": "The request body was not stored: llmRecordMode is 
\"metadata\".",
+    "Body too large to store": "The request body was larger than the capture 
limit and was not stored.",
+    "Channel": "Channel",
+    "Clear": "Clear",
+    "Delete every stored record?": "Delete every stored record?",
+    "Endpoint": "Endpoint",
+    "Error": "Error",
+    "Failed": "Failed",
+    "LLM Records": "LLM Records",
+    "Mode full": "full - metadata and sanitized request body",
+    "Mode metadata": "metadata only",
+    "Mode off": "off",
+    "Mode {mode}, kept for {days} days, up to {max} records": "Mode {mode}, 
kept for {days} days, up to {max} records",
+    "No records yet": "No records yet - send a request through the LLM 
gateway.",
+    "No response": "no response",
+    "Recording is off": "Recording is off",
+    "Recording is off detail": "Set llmRecordMode to \"metadata\" or \"full\" 
in conf/app.conf and restart Gateway. Prompts can carry sensitive data, so 
nothing is retained until you ask for it.",
+    "Records were dropped": "{count} records were dropped because the write 
queue was full. Raise llmRecordQueueCapacity if this keeps happening.",
+    "Redacted values": "Redacted values",
+    "Request": "Request",
+    "Request size": "Request size",
+    "Status": "Status",
+    "Succeeded": "Succeeded",
+    "Tokens": "Tokens"
+  },
   "node": {
     "Edit Node": "Edit Node",
     "New Node": "New Node"
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index a4f00d1..57ea6a1 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -231,6 +231,34 @@
     "User-Agent": "User-Agent",
     "{total} in total": "共 {total} 条"
   },
+  "llm": {
+    "Any outcome": "全部结果",
+    "Attempts": "尝试次数",
+    "Body not stored": "未保存请求正文:llmRecordMode 为 \"metadata\"。",
+    "Body too large to store": "请求正文超过采集上限,未予保存。",
+    "Channel": "渠道",
+    "Clear": "清空",
+    "Delete every stored record?": "确定删除全部已保存的记录?",
+    "Endpoint": "接口",
+    "Error": "错误",
+    "Failed": "失败",
+    "LLM Records": "LLM 记录",
+    "Mode full": "完整(元数据 + 脱敏后的请求正文)",
+    "Mode metadata": "仅元数据",
+    "Mode off": "关闭",
+    "Mode {mode}, kept for {days} days, up to {max} records": "模式:{mode},保留 
{days} 天,最多 {max} 条",
+    "No records yet": "暂无记录——请通过 LLM 网关发送一次请求。",
+    "No response": "无响应",
+    "Recording is off": "记录功能已关闭",
+    "Recording is off detail": "请在 conf/app.conf 中把 llmRecordMode 设为 
\"metadata\" 或 \"full\" 并重启 Gateway。提示词可能包含敏感数据,因此默认不保留任何内容。",
+    "Records were dropped": "有 {count} 条记录因写入队列已满而被丢弃。如持续出现,请调大 
llmRecordQueueCapacity。",
+    "Redacted values": "已脱敏字段数",
+    "Request": "请求内容",
+    "Request size": "请求大小",
+    "Status": "状态",
+    "Succeeded": "成功",
+    "Tokens": "Token 用量"
+  },
   "node": {
     "Edit Node": "编辑节点",
     "New Node": "新建节点"
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 2dae3db..77c23e5 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -19,6 +19,7 @@ import {
   Globe,
   LayoutDashboard,
   ListFilter,
+  Logs,
   MessageSquare,
   Plug,
   ScrollText,
@@ -67,6 +68,7 @@ export const navGroups: NavGroup[] = [
   },
   {key: "/agent-records", label: "agent:Agent Records", icon: FileSearch, 
path: "/agent-records", adminOnly: true},
   {key: "/channels", label: "channel:Channels", icon: Plug, path: "/channels"},
+  {key: "/llm-records", label: "llm:LLM Records", icon: Logs, path: 
"/llm-records", adminOnly: true},
   {
     key: "/advanced",
     label: "general:Advanced",
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
new file mode 100644
index 0000000..784b135
--- /dev/null
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -0,0 +1,387 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Logs, RefreshCw, Trash2} from "lucide-react";
+import i18next from "i18next";
+
+import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
+import * as Setting from "@/Setting";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {CodeBlock, CodeText, DescriptionList, UnauthorizedResult} from 
"@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Label} from "@/components/ui/label";
+import {Switch} from "@/components/ui/switch";
+import type {Account, LlmRecord, LlmRecordStatus} from "@/types";
+
+function statusVariant(status: number): BadgeVariant {
+  if (status >= 200 && status < 300) {
+    return "success";
+  }
+  if (status >= 400 && status < 500) {
+    return "warning";
+  }
+  return "danger";
+}
+
+function formatPayload(payload: string) {
+  try {
+    return JSON.stringify(JSON.parse(payload), null, 2);
+  } catch {
+    return payload;
+  }
+}
+
+/** Mounted only once a row is expanded, which is when the body is fetched. */
+function RecordDetail({record, onDelete}: {record: LlmRecord; onDelete: () => 
void}) {
+  const [detail, setDetail] = React.useState<LlmRecord | null>(null);
+  const [error, setError] = React.useState("");
+
+  React.useEffect(() => {
+    LlmRecordBackend.getLlmRecord(record.id)
+      .then(res => (res.status === "ok" ? setDetail(res.data ?? null) : 
setError(res.msg)))
+      .catch(err => setError(err.message || String(err)));
+  }, [record.id]);
+
+  const payload = detail?.payload ?? "";
+
+  return (
+    <div className="flex flex-col gap-3 px-4 py-3">
+      <DescriptionList
+        columns={3}
+        items={[
+          {label: i18next.t("general:ID"), value: record.id},
+          {label: i18next.t("general:Created time"), value: 
Setting.getFormattedDate(record.createdTime)},
+          {label: i18next.t("llm:Endpoint"), value: 
<CodeText>{`${record.protocol} ${record.endpoint}`}</CodeText>},
+          record.channel && {label: i18next.t("llm:Channel"), value: 
<CodeText>{record.channel}</CodeText>},
+          record.agent && {label: i18next.t("agent:Agent"), value: 
<CodeText>{record.agent}</CodeText>},
+          {label: i18next.t("llm:Attempts"), value: record.attempts},
+          {label: i18next.t("llm:Tokens"), value: `${record.promptTokens} + 
${record.completionTokens} = ${record.totalTokens}`},
+          {label: i18next.t("llm:Request size"), value: 
`${record.bytes.toLocaleString()} B`},
+          {label: i18next.t("llm:Redacted values"), value: record.redactions},
+          record.error && {label: i18next.t("llm:Error"), value: record.error},
+        ]}
+      />
+
+      {error ? <MessageAlert title={error} /> : null}
+
+      {payload ? (
+        <div className="grid gap-1">
+          <span className="text-muted-foreground 
text-xs">{i18next.t("agent:Payload")}</span>
+          <CodeBlock copyable maxHeight="24rem">
+            {formatPayload(payload)}
+          </CodeBlock>
+        </div>
+      ) : (
+        <span className="text-muted-foreground text-xs">
+          {record.truncated ? i18next.t("llm:Body too large to store") : 
i18next.t("llm:Body not stored")}
+        </span>
+      )}
+
+      <div>
+        <ConfirmDialog
+          title={i18next.t("general:Sure to delete {name} 
?").replace("{name}", String(record.id))}
+          onConfirm={onDelete}
+        >
+          <Button size="sm" variant="destructive">
+            <Trash2 />
+            {i18next.t("general:Delete")}
+          </Button>
+        </ConfirmDialog>
+      </div>
+    </div>
+  );
+}
+
+export default function LlmRecordsPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const [records, setRecords] = React.useState<LlmRecord[]>([]);
+  const [status, setStatus] = React.useState<LlmRecordStatus | null>(null);
+  const [total, setTotal] = React.useState(0);
+  const [page, setPage] = React.useState(1);
+  const [pageSize, setPageSize] = React.useState(25);
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+  const [autoRefresh, setAutoRefresh] = React.useState(false);
+
+  const [outcome, setOutcome] = React.useState("");
+  const [modelDraft, setModelDraft] = React.useState("");
+  const [clientIpDraft, setClientIpDraft] = React.useState("");
+  const [filter, setFilter] = 
React.useState<LlmRecordBackend.LlmRecordFilter>({});
+
+  const load = React.useCallback(
+    (nextPage = page, nextPageSize = pageSize, foreground = true) => {
+      if (!isAdmin) {
+        return;
+      }
+      // A background poll must not raise the loading state, or it would cover
+      // the rows the operator is reading every few seconds.
+      if (foreground) {
+        setLoading(true);
+      }
+
+      LlmRecordBackend.getLlmRecords(nextPage, nextPageSize, {...filter, 
outcome: outcome})
+        .then(res => {
+          if (res.status === "ok") {
+            setRecords(res.data ?? []);
+            setTotal(res.data2 ?? 0);
+            setPage(nextPage);
+            setPageSize(nextPageSize);
+            setError("");
+          } else {
+            setError(res.msg || i18next.t("general:Failed to get data"));
+          }
+        })
+        .catch(err => setError(err.message || String(err)))
+        .then(() => {
+          if (foreground) {
+            setLoading(false);
+          }
+        });
+    },
+    [filter, isAdmin, outcome, page, pageSize],
+  );
+
+  const loadStatus = React.useCallback(() => {
+    if (!isAdmin) {
+      return;
+    }
+    LlmRecordBackend.getLlmRecordStatus().then(res => {
+      if (res.status === "ok") {
+        setStatus(res.data ?? null);
+      }
+    });
+  }, [isAdmin]);
+
+  React.useEffect(() => {
+    load(1, pageSize);
+    loadStatus();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [filter, outcome]);
+
+  React.useEffect(() => {
+    if (!autoRefresh) {
+      return undefined;
+    }
+    const interval = setInterval(() => load(page, pageSize, false), 5000);
+    return () => clearInterval(interval);
+  }, [autoRefresh, load, page, pageSize]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const deleteRecord = (id: number) => {
+    LlmRecordBackend.deleteLlmRecord(id)
+      .then(res => {
+        if (res.status === "error") {
+          Setting.showMessage("error", `${i18next.t("general:Failed to 
delete")}: ${res.msg}`);
+        } else {
+          Setting.showMessage("success", i18next.t("general:Deleted 
successfully"));
+          load(page, pageSize);
+        }
+      })
+      .catch(err => Setting.showMessage("error", `${i18next.t("general:Failed 
to delete")}: ${err}`));
+  };
+
+  const clearRecords = () => {
+    LlmRecordBackend.clearLlmRecords()
+      .then(res => {
+        if (res.status === "error") {
+          Setting.showMessage("error", `${i18next.t("general:Failed to 
delete")}: ${res.msg}`);
+        } else {
+          Setting.showMessage("success", i18next.t("general:Deleted 
successfully"));
+          load(1, pageSize);
+          loadStatus();
+        }
+      })
+      .catch(err => Setting.showMessage("error", `${i18next.t("general:Failed 
to delete")}: ${err}`));
+  };
+
+  const applyFilter = () => setFilter({model: modelDraft, clientIp: 
clientIpDraft});
+
+  const columns: Column<LlmRecord>[] = [
+    {
+      title: i18next.t("agent:Time"),
+      key: "createdTime",
+      dataIndex: "createdTime",
+      width: "170px",
+      render: (value: string) => Setting.getFormattedDate(value),
+    },
+    {
+      title: i18next.t("agent:Model"),
+      key: "model",
+      dataIndex: "model",
+      width: "190px",
+      render: (value: string, record) => (
+        <div className="flex min-w-0 flex-col gap-0.5">
+          <CodeText>{value}</CodeText>
+          <span className="text-muted-foreground truncate 
text-xs">{record.agent || record.channel}</span>
+        </div>
+      ),
+    },
+    {
+      title: i18next.t("general:Client ip"),
+      key: "clientIp",
+      dataIndex: "clientIp",
+      width: "130px",
+    },
+    {
+      title: i18next.t("llm:Status"),
+      key: "status",
+      width: "130px",
+      render: (_value, record) => (
+        <div className="flex flex-wrap items-center gap-1">
+          <Badge variant={statusVariant(record.status)}>{record.status || 
i18next.t("llm:No response")}</Badge>
+          {record.stream ? <Badge variant="muted">SSE</Badge> : null}
+        </div>
+      ),
+    },
+    {
+      title: i18next.t("agent:Duration"),
+      key: "durationMs",
+      dataIndex: "durationMs",
+      width: "110px",
+      render: (value: number) => `${value.toLocaleString()} ms`,
+    },
+    {
+      title: i18next.t("llm:Tokens"),
+      key: "totalTokens",
+      width: "120px",
+      render: (_value, record) =>
+        record.totalTokens > 0 ? (
+          <div className="flex min-w-0 flex-col gap-0.5">
+            <span>{record.totalTokens.toLocaleString()}</span>
+            <span className="text-muted-foreground truncate text-xs">
+              {record.promptTokens.toLocaleString()} + 
{record.completionTokens.toLocaleString()}
+            </span>
+          </div>
+        ) : null,
+    },
+    {
+      title: i18next.t("llm:Request"),
+      key: "summary",
+      render: (_value, record) => (
+        <span className="text-muted-foreground block truncate text-xs" 
title={record.summary || record.error}>
+          {record.summary || record.error}
+        </span>
+      ),
+    },
+  ];
+
+  const modeOff = status !== null && status.mode === "off";
+  const description = status
+    ? i18next
+      .t("llm:Mode {mode}, kept for {days} days, up to {max} records")
+      .replace("{mode}", i18next.t(`llm:Mode ${status.mode}`))
+      .replace("{days}", String(status.retentionDays))
+      .replace("{max}", status.maxRecords.toLocaleString())
+    : undefined;
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("llm:LLM Records")}
+        description={description}
+        actions={
+          <>
+            <Label className="text-sm font-normal">
+              <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
+              {i18next.t("agent:Auto refresh")}
+            </Label>
+            <Button variant="outline" onClick={() => load(page, pageSize)} 
loading={loading}>
+              <RefreshCw />
+              {i18next.t("general:Refresh")}
+            </Button>
+            <ConfirmDialog title={i18next.t("llm:Delete every stored 
record?")} onConfirm={clearRecords}>
+              <Button variant="outline" disabled={total === 0}>
+                <Trash2 />
+                {i18next.t("llm:Clear")}
+              </Button>
+            </ConfirmDialog>
+          </>
+        }
+      />
+
+      {modeOff ? <MessageAlert variant="info" title={i18next.t("llm:Recording 
is off")} description={i18next.t("llm:Recording is off detail")} /> : null}
+      {status && status.dropped > 0 ? (
+        <MessageAlert
+          variant="warning"
+          title={i18next.t("llm:Records were dropped").replace("{count}", 
status.dropped.toLocaleString())}
+        />
+      ) : null}
+      {error ? <MessageAlert title={error} /> : null}
+
+      <form
+        className="flex flex-wrap items-center gap-2"
+        onSubmit={event => {
+          event.preventDefault();
+          applyFilter();
+        }}
+      >
+        <Input
+          className="w-[220px]"
+          placeholder={i18next.t("agent:Model")}
+          value={modelDraft}
+          onChange={event => setModelDraft(event.target.value)}
+        />
+        <Input
+          className="w-[190px]"
+          placeholder={i18next.t("general:Client ip")}
+          value={clientIpDraft}
+          onChange={event => setClientIpDraft(event.target.value)}
+        />
+        <SimpleSelect
+          className="w-[170px]"
+          value={outcome}
+          onChange={setOutcome}
+          options={[
+            {label: i18next.t("llm:Any outcome"), value: ""},
+            {label: i18next.t("llm:Succeeded"), value: "ok"},
+            {label: i18next.t("llm:Failed"), value: "error"},
+          ]}
+        />
+        <Button type="submit" variant="outline">
+          {i18next.t("agent:Filter")}
+        </Button>
+      </form>
+
+      <DataTable
+        title={i18next.t("llm:LLM Records")}
+        description={`${total.toLocaleString()} 
${i18next.t("general:Records")}`}
+        columns={columns}
+        dataSource={records}
+        rowKey={record => String(record.id)}
+        loading={loading}
+        emptyIcon={Logs}
+        emptyText={modeOff ? i18next.t("llm:Recording is off detail") : 
i18next.t("llm:No records yet")}
+        expandable={{
+          expandedRowRender: record => <RecordDetail record={record} 
onDelete={() => deleteRecord(record.id)} />,
+        }}
+        serverPagination={{
+          page: page,
+          pageSize: pageSize,
+          total: total,
+          onChange: (nextPage, nextPageSize) => load(nextPage, nextPageSize),
+        }}
+      />
+    </PageContainer>
+  );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 7c552ff..3a67ffc 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -212,6 +212,39 @@ export interface AgentSession {
   lastTime: string;
 }
 
+export interface LlmRecord {
+  id: number;
+  createdTime: string;
+  protocol: string;
+  endpoint: string;
+  model: string;
+  channel: string;
+  agent: string;
+  clientIp: string;
+  stream: boolean;
+  status: number;
+  durationMs: number;
+  attempts: number;
+  error: string;
+  promptTokens: number;
+  completionTokens: number;
+  totalTokens: number;
+  summary: string;
+  /** Only returned by getLlmRecord, the list endpoint leaves it out. */
+  payload: string;
+  redactions: number;
+  truncated: boolean;
+  bytes: number;
+}
+
+export interface LlmRecordStatus {
+  mode: "off" | "metadata" | "full";
+  retentionDays: number;
+  maxRecords: number;
+  dropped: number;
+  count: number;
+}
+
 export interface MetricPoint {
   data: string;
   count: number;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to