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]