diff --git a/VERSION b/VERSION
index e69de29bb..50935e9ab 100644
--- a/VERSION
+++ b/VERSION
@@ -0,0 +1 @@
+v1.0.0-rc.41
diff --git a/controller/log.go b/controller/log.go
index b5484992c..4f9fee56e 100644
--- a/controller/log.go
+++ b/controller/log.go
@@ -6,6 +6,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -32,6 +33,7 @@ func GetAllLogs(c *gin.Context) {
} else {
model.FormatRootLogs(logs)
}
+ normalizeUsagePromptAuditLogs(logs)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
@@ -54,6 +56,7 @@ func GetUserLogs(c *gin.Context) {
common.ApiError(c, err)
return
}
+ normalizeUsagePromptAuditLogs(logs)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
@@ -93,6 +96,7 @@ func GetLogByKey(c *gin.Context) {
})
return
}
+ normalizeUsagePromptAuditLogs(logs)
c.JSON(200, gin.H{
"success": true,
"message": "",
@@ -100,6 +104,12 @@ func GetLogByKey(c *gin.Context) {
})
}
+func normalizeUsagePromptAuditLogs(logs []*model.Log) {
+ for _, log := range logs {
+ log.Other = service.NormalizeUsagePromptAuditOther(log.Other)
+ }
+}
+
func GetLogsStat(c *gin.Context) {
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
diff --git a/controller/relay.go b/controller/relay.go
index 21c913819..851f8f0f5 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -123,6 +123,17 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
return
}
+ if bodyStorage, bodyErr := common.GetBodyStorage(c); bodyErr == nil {
+ body, truncated, captureErr := service.CaptureUsagePromptAuditBodyFromStorage(bodyStorage)
+ if captureErr != nil {
+ logger.LogWarn(c, "failed to capture usage prompt audit client request body: "+captureErr.Error())
+ } else {
+ relayInfo.UsageLogRawClientRequestBody = body
+ relayInfo.UsageLogRawClientRequestBodyTruncated = truncated
+ }
+ } else {
+ logger.LogWarn(c, "failed to get request body storage for usage prompt audit: "+bodyErr.Error())
+ }
defer func() {
recovered := recover()
diff --git a/controller/token.go b/controller/token.go
index 7c6e6a6ad..81d0f9215 100644
--- a/controller/token.go
+++ b/controller/token.go
@@ -129,13 +129,13 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
+ groups := c.QueryArray("group")
pageInfo := common.GetPageQuery(c)
- tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+ tokens, total, err := model.GetAllUserTokens(userId, groups, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
- total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
@@ -145,10 +145,11 @@ func SearchTokens(c *gin.Context) {
userId := c.GetInt("id")
keyword := c.Query("keyword")
token := c.Query("token")
+ groups := c.QueryArray("group")
pageInfo := common.GetPageQuery(c)
- tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+ tokens, total, err := model.SearchUserTokens(userId, keyword, token, groups, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
diff --git a/controller/token_test.go b/controller/token_test.go
index 28af3bb9c..3cf77ed4d 100644
--- a/controller/token_test.go
+++ b/controller/token_test.go
@@ -35,6 +35,7 @@ type tokenAPIResponse struct {
type tokenPageResponse struct {
Items []tokenResponseItem `json:"items"`
+ Total int `json:"total"`
}
type tokenResponseItem struct {
@@ -84,6 +85,10 @@ func openTokenControllerTestDB(t *testing.T) *gorm.DB {
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
common.RedisEnabled = false
+ // model 的 commonGroupCol/commonKeyCol 等方言列名只在 InitDB 里初始化,
+ // 隔离运行(go test -run)时为空会导致生成的 SQL 缺列名。
+ initModelListColumnNames(t)
+
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
@@ -490,6 +495,130 @@ func TestSearchTokensMasksKeyInResponse(t *testing.T) {
}
}
+func TestSearchTokensKeywordMatchesNameSubstring(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ seedToken(t, db, 1, "north-beijing-token", "abcd1234efgh5678")
+ seedToken(t, db, 1, "shanghai-token", "mnop1234qrst5678")
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=beijing&p=1&size=10", nil, 1)
+ SearchTokens(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var page tokenPageResponse
+ require.NoError(t, common.Unmarshal(response.Data, &page))
+ require.Len(t, page.Items, 1)
+ require.Equal(t, "north-beijing-token", page.Items[0].Name)
+}
+
+func TestSearchTokensTokenMatchesKeySubstring(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ seedToken(t, db, 1, "matching-key-token", "abcd1234efgh5678")
+ seedToken(t, db, 1, "other-key-token", "mnop1234qrst5678")
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?token=efgh&p=1&size=10", nil, 1)
+ SearchTokens(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var page tokenPageResponse
+ require.NoError(t, common.Unmarshal(response.Data, &page))
+ require.Len(t, page.Items, 1)
+ require.Equal(t, "matching-key-token", page.Items[0].Name)
+}
+
+func TestGetAllTokensFiltersByGroup(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ premium := seedToken(t, db, 1, "premium-token", "abcd1234efgh5678")
+ require.NoError(t, db.Model(premium).Update("group", "premium").Error)
+ seedToken(t, db, 1, "default-token", "mnop1234qrst5678")
+ otherUser := seedToken(t, db, 2, "other-user-premium", "uvwx1234yzab5678")
+ require.NoError(t, db.Model(otherUser).Update("group", "premium").Error)
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?group=premium&p=1&size=10", nil, 1)
+ GetAllTokens(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var page tokenPageResponse
+ require.NoError(t, common.Unmarshal(response.Data, &page))
+ require.Equal(t, 1, page.Total)
+ require.Len(t, page.Items, 1)
+ require.Equal(t, "premium-token", page.Items[0].Name)
+}
+
+func TestGetAllTokensFiltersByMultipleGroups(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ premium := seedToken(t, db, 1, "premium-token", "abcd1234efgh5678")
+ require.NoError(t, db.Model(premium).Update("group", "premium").Error)
+ standard := seedToken(t, db, 1, "standard-token", "mnop1234qrst5678")
+ require.NoError(t, db.Model(standard).Update("group", "standard").Error)
+ seedToken(t, db, 1, "default-token", "ijkl1234mnop5678")
+ otherUser := seedToken(t, db, 2, "other-user-premium", "uvwx1234yzab5678")
+ require.NoError(t, db.Model(otherUser).Update("group", "premium").Error)
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?group=premium&group=standard&p=1&size=10", nil, 1)
+ GetAllTokens(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var page tokenPageResponse
+ require.NoError(t, common.Unmarshal(response.Data, &page))
+ require.Equal(t, 2, page.Total)
+ require.Len(t, page.Items, 2)
+ assert.ElementsMatch(t, []string{"premium-token", "standard-token"}, []string{
+ page.Items[0].Name,
+ page.Items[1].Name,
+ })
+}
+
+func TestSearchTokensCombinesGroupAndNameSubstring(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ premium := seedToken(t, db, 1, "north-beijing-premium", "abcd1234efgh5678")
+ require.NoError(t, db.Model(premium).Update("group", "premium").Error)
+ seedToken(t, db, 1, "north-beijing-default", "mnop1234qrst5678")
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=beijing&group=premium&p=1&size=10", nil, 1)
+ SearchTokens(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var page tokenPageResponse
+ require.NoError(t, common.Unmarshal(response.Data, &page))
+ require.Equal(t, 1, page.Total)
+ require.Len(t, page.Items, 1)
+ require.Equal(t, "north-beijing-premium", page.Items[0].Name)
+}
+
+func TestSearchTokensCombinesMultipleGroupsAndNameSubstring(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ premium := seedToken(t, db, 1, "north-beijing-premium", "abcd1234efgh5678")
+ require.NoError(t, db.Model(premium).Update("group", "premium").Error)
+ standard := seedToken(t, db, 1, "north-beijing-standard", "mnop1234qrst5678")
+ require.NoError(t, db.Model(standard).Update("group", "standard").Error)
+ seedToken(t, db, 1, "north-beijing-default", "ijkl1234mnop5678")
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=beijing&group=premium&group=standard&p=1&size=10", nil, 1)
+ SearchTokens(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var page tokenPageResponse
+ require.NoError(t, common.Unmarshal(response.Data, &page))
+ require.Equal(t, 2, page.Total)
+ require.Len(t, page.Items, 2)
+ assert.ElementsMatch(t, []string{"north-beijing-premium", "north-beijing-standard"}, []string{
+ page.Items[0].Name,
+ page.Items[1].Name,
+ })
+}
+
func TestGetTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678")
diff --git a/model/ability.go b/model/ability.go
index ecfcb5c04..cd47b3676 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -16,7 +16,7 @@ import (
)
type Ability struct {
- Group string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
+ Group string `json:"group" gorm:"type:varchar(255);primaryKey;autoIncrement:false"`
Model string `json:"model" gorm:"type:varchar(255);primaryKey;autoIncrement:false"`
ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
Enabled bool `json:"enabled"`
diff --git a/model/channel.go b/model/channel.go
index bb192a306..f1dc7383a 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -37,7 +37,7 @@ type Channel struct {
Balance float64 `json:"balance"` // in USD
BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"`
Models string `json:"models"`
- Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
+ Group string `json:"group" gorm:"type:varchar(255);default:'default'"`
UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"`
ModelMapping *string `json:"model_mapping" gorm:"type:text"`
//MaxInputTokens *int `json:"max_input_tokens" gorm:"default:0"`
diff --git a/model/token.go b/model/token.go
index bd2d5bf19..b33886ed0 100644
--- a/model/token.go
+++ b/model/token.go
@@ -103,11 +103,16 @@ func (token *Token) GetIpLimits() []string {
return ipLimits
}
-func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
- var tokens []*Token
- var err error
- err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error
- return tokens, err
+func GetAllUserTokens(userId int, groups []string, startIdx int, num int) (tokens []*Token, total int64, err error) {
+ query := DB.Model(&Token{}).Where("user_id = ?", userId)
+ if len(groups) > 0 {
+ query = query.Where(commonGroupCol+" IN ?", groups)
+ }
+ if err = query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error
+ return tokens, total, err
}
// sanitizeLikePattern 校验并清洗用户输入的 LIKE 搜索模式。
@@ -154,9 +159,20 @@ func validateLikePattern(input string) error {
return nil
}
+func sanitizeContainsLikePattern(input string) (string, error) {
+ pattern, err := sanitizeLikePattern(input)
+ if err != nil {
+ return "", err
+ }
+ if strings.Contains(pattern, "%") {
+ return pattern, nil
+ }
+ return "%" + pattern + "%", nil
+}
+
const searchHardLimit = 100
-func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) {
+func SearchUserTokens(userId int, keyword string, token string, groups []string, offset int, limit int) (tokens []*Token, total int64, err error) {
// model 层强制截断
if limit <= 0 || limit > searchHardLimit {
limit = searchHardLimit
@@ -171,7 +187,7 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
// 超量用户(令牌数超过上限)只允许精确搜索,禁止模糊搜索
maxTokens := operation_setting.GetMaxUserTokens()
- hasFuzzy := strings.Contains(keyword, "%") || strings.Contains(token, "%")
+ hasFuzzy := keyword != "" || token != ""
if hasFuzzy {
count, err := CountUserTokens(userId)
if err != nil {
@@ -179,22 +195,25 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
return nil, 0, errors.New("获取令牌数量失败")
}
if int(count) > maxTokens {
- return nil, 0, errors.New("令牌数量超过上限,仅允许精确搜索,请勿使用 % 通配符")
+ return nil, 0, errors.New("令牌数量超过上限,仅允许精确搜索")
}
}
baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId)
+ if len(groups) > 0 {
+ baseQuery = baseQuery.Where(commonGroupCol+" IN ?", groups)
+ }
// 非空才加 LIKE 条件,空则跳过(不过滤该字段)
if keyword != "" {
- keywordPattern, err := sanitizeLikePattern(keyword)
+ keywordPattern, err := sanitizeContainsLikePattern(keyword)
if err != nil {
return nil, 0, err
}
baseQuery = baseQuery.Where("name LIKE ? ESCAPE '!'", keywordPattern)
}
if token != "" {
- tokenPattern, err := sanitizeLikePattern(token)
+ tokenPattern, err := sanitizeContainsLikePattern(token)
if err != nil {
return nil, 0, err
}
diff --git a/model/user.go b/model/user.go
index 9f1e8564c..fc969513a 100644
--- a/model/user.go
+++ b/model/user.go
@@ -97,7 +97,7 @@ type User struct {
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
- Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
+ Group string `json:"group" gorm:"type:varchar(255);default:'default'"`
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
diff --git a/relay/channel/claude/usage_prompt_audit.go b/relay/channel/claude/usage_prompt_audit.go
new file mode 100644
index 000000000..d3b2ac7e2
--- /dev/null
+++ b/relay/channel/claude/usage_prompt_audit.go
@@ -0,0 +1,13 @@
+package claude
+
+import (
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+)
+
+func recordUsagePromptAuditResponseBody(info *relaycommon.RelayInfo, body []byte) {
+ if info == nil || len(body) == 0 {
+ return
+ }
+ info.UsageLogRawResponseBody, info.UsageLogRawResponseBodyTruncated = service.CaptureUsagePromptAuditBytes(body)
+}
diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go
index 25429fc20..8bfa4605f 100644
--- a/relay/channel/openai/relay-openai.go
+++ b/relay/channel/openai/relay-openai.go
@@ -186,6 +186,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
applyUsagePostProcessing(info, usage, common.StringToByteSlice(usageFrame))
+ recordUsageLogSyntheticChatStreamResponse(info, responseTextBuilder.String(), lastStreamData)
for _, name := range streamFunctionCallNames {
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
@@ -272,6 +273,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
+ recordUsageLogRawResponseBody(info, responseBody)
err = common.Unmarshal(responseBody, &simpleResponse)
if err != nil {
diff --git a/relay/channel/openai/usage_prompt_audit.go b/relay/channel/openai/usage_prompt_audit.go
new file mode 100644
index 000000000..bb9ab8aee
--- /dev/null
+++ b/relay/channel/openai/usage_prompt_audit.go
@@ -0,0 +1,68 @@
+package openai
+
+import (
+ "github.com/QuantumNous/new-api/common"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/service"
+)
+
+func recordUsageLogRawResponseBody(info *relaycommon.RelayInfo, body []byte) {
+ if info == nil || len(body) == 0 {
+ return
+ }
+ info.UsageLogRawResponseBody, info.UsageLogRawResponseBodyTruncated = service.CaptureUsagePromptAuditBytes(body)
+}
+
+func recordUsageLogSyntheticChatStreamResponse(info *relaycommon.RelayInfo, text string, lastEvent string) {
+ if info == nil || (text == "" && lastEvent == "") {
+ return
+ }
+ payload := map[string]interface{}{
+ "object": "newapi.chat_stream_audit",
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{
+ "role": "assistant",
+ "content": text,
+ },
+ },
+ },
+ }
+ if lastEvent != "" {
+ payload["last_stream_event"] = lastEvent
+ }
+ body, err := common.Marshal(payload)
+ if err != nil {
+ return
+ }
+ recordUsageLogRawResponseBody(info, body)
+}
+
+func recordUsageLogSyntheticResponsesStreamResponse(info *relaycommon.RelayInfo, text string, lastEvent string) {
+ if info == nil || (text == "" && lastEvent == "") {
+ return
+ }
+ payload := map[string]interface{}{
+ "object": "newapi.responses_stream_audit",
+ "output": []map[string]interface{}{
+ {
+ "type": "message",
+ "role": "assistant",
+ "content": []map[string]interface{}{
+ {
+ "type": "output_text",
+ "text": text,
+ },
+ },
+ },
+ },
+ }
+ if lastEvent != "" {
+ payload["last_stream_event"] = lastEvent
+ }
+ body, err := common.Marshal(payload)
+ if err != nil {
+ return
+ }
+ recordUsageLogRawResponseBody(info, body)
+}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index b4ff8dd8a..e6e81bcdb 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -172,6 +172,14 @@ type RelayInfo struct {
// It is surfaced onto the consume/task log's admin_info for auditing.
QuotaClamp *common.QuotaClamp
+ // Usage-log prompt audit captures bounded raw request/response snapshots.
+ UsageLogRawClientRequestBody []byte
+ UsageLogRawClientRequestBodyTruncated bool
+ UsageLogRawUpstreamRequestBody []byte
+ UsageLogRawUpstreamRequestBodyTruncated bool
+ UsageLogRawResponseBody []byte
+ UsageLogRawResponseBodyTruncated bool
+
// TieredBillingSnapshot captures tiered billing rules at pre-consume time.
// Auto-group retries refresh its group-dependent fields before each attempt
// and again before settlement. Non-nil only when billing mode is "tiered_expr".
diff --git a/service/text_quota.go b/service/text_quota.go
index d2df20427..6e67e75c1 100644
--- a/service/text_quota.go
+++ b/service/text_quota.go
@@ -530,6 +530,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
+ InjectUsagePromptAudit(other, relayInfo)
attachQuotaSaturation(ctx, relayInfo, other)
diff --git a/service/usage_prompt_audit.go b/service/usage_prompt_audit.go
new file mode 100644
index 000000000..cc52753f5
--- /dev/null
+++ b/service/usage_prompt_audit.go
@@ -0,0 +1,847 @@
+package service
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/url"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+)
+
+const (
+ UsagePromptAuditSchemaV1 = "newapi_prompt_audit:v1"
+ UsagePromptAuditOtherKey = "prompt_audit"
+ maxUsagePromptAuditBodyBytes = 256 * 1024
+ maxUsagePromptAuditTextBytes = 64 * 1024
+ maxUsagePromptAuditItems = 200
+ usagePromptAuditClientSource = "client_request"
+ usagePromptAuditUpstreamSource = "upstream_request"
+ usagePromptAuditResponseSource = "upstream_response"
+)
+
+type UsagePromptAudit struct {
+ Schema string `json:"schema"`
+ UserInputs []UsagePromptAuditText `json:"user_inputs,omitempty"`
+ AssistantOutputs []UsagePromptAuditText `json:"assistant_outputs,omitempty"`
+ SystemPrompts []UsagePromptAuditText `json:"system_prompts,omitempty"`
+ Skills []UsagePromptAuditItem `json:"skills,omitempty"`
+ Tools []UsagePromptAuditItem `json:"tools,omitempty"`
+ ToolCalls []UsagePromptAuditItem `json:"tool_calls,omitempty"`
+ Plugins []UsagePromptAuditItem `json:"plugins,omitempty"`
+ Counts map[string]int `json:"counts"`
+}
+
+type UsagePromptAuditText struct {
+ Source string `json:"source"`
+ Text string `json:"text"`
+}
+
+type UsagePromptAuditItem struct {
+ Source string `json:"source"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Value any `json:"value,omitempty"`
+}
+
+type UsagePromptAuditCapturedBodies struct {
+ ClientRequestBody []byte
+ ClientRequestBodyTruncated bool
+ UpstreamRequestBody []byte
+ UpstreamRequestBodyTruncated bool
+ ResponseBody []byte
+ ResponseBodyTruncated bool
+}
+
+func BuildUsagePromptAudit(clientRequestBody []byte, upstreamRequestBody []byte, responseBody []byte) *UsagePromptAudit {
+ return BuildUsagePromptAuditFromCapturedBodies(UsagePromptAuditCapturedBodies{
+ ClientRequestBody: clientRequestBody,
+ UpstreamRequestBody: upstreamRequestBody,
+ ResponseBody: responseBody,
+ })
+}
+
+func BuildUsagePromptAuditFromCapturedBodies(bodies UsagePromptAuditCapturedBodies) *UsagePromptAudit {
+ audit := &UsagePromptAudit{
+ Schema: UsagePromptAuditSchemaV1,
+ Counts: map[string]int{},
+ }
+
+ extractUsagePromptAuditRequest(audit, usagePromptAuditClientSource, bodies.ClientRequestBody)
+ extractUsagePromptAuditRequest(audit, usagePromptAuditUpstreamSource, bodies.UpstreamRequestBody)
+ extractUsagePromptAuditResponse(audit, usagePromptAuditResponseSource, bodies.ResponseBody)
+
+ normalizeUsagePromptAuditAssistantOutputs(audit.AssistantOutputs)
+ normalizeUsagePromptAudit(audit)
+
+ if len(audit.UserInputs) == 0 &&
+ len(audit.AssistantOutputs) == 0 &&
+ len(audit.SystemPrompts) == 0 &&
+ len(audit.Skills) == 0 &&
+ len(audit.Tools) == 0 &&
+ len(audit.ToolCalls) == 0 &&
+ len(audit.Plugins) == 0 {
+ return nil
+ }
+
+ return audit
+}
+
+func InjectUsagePromptAudit(other *model.LogOther, relayInfo *relaycommon.RelayInfo) {
+ if other == nil || relayInfo == nil {
+ return
+ }
+ audit := BuildUsagePromptAuditFromCapturedBodies(UsagePromptAuditCapturedBodies{
+ ClientRequestBody: relayInfo.UsageLogRawClientRequestBody,
+ ClientRequestBodyTruncated: relayInfo.UsageLogRawClientRequestBodyTruncated,
+ UpstreamRequestBody: relayInfo.UsageLogRawUpstreamRequestBody,
+ UpstreamRequestBodyTruncated: relayInfo.UsageLogRawUpstreamRequestBodyTruncated,
+ ResponseBody: relayInfo.UsageLogRawResponseBody,
+ ResponseBodyTruncated: relayInfo.UsageLogRawResponseBodyTruncated,
+ })
+ if audit == nil {
+ return
+ }
+ other.SetAudit(UsagePromptAuditOtherKey, audit)
+}
+
+func NormalizeUsagePromptAuditOther(other string) string {
+ if other == "" {
+ return other
+ }
+ var otherMap map[string]any
+ if err := common.Unmarshal([]byte(other), &otherMap); err != nil {
+ return other
+ }
+ auditInfo, exists := otherMap["audit_info"].(map[string]any)
+ if !exists {
+ return other
+ }
+ rawAudit, exists := auditInfo[UsagePromptAuditOtherKey]
+ if !exists {
+ return other
+ }
+ auditData, err := common.Marshal(rawAudit)
+ if err != nil {
+ return other
+ }
+ var audit UsagePromptAudit
+ if err = common.Unmarshal(auditData, &audit); err != nil || audit.Schema != UsagePromptAuditSchemaV1 {
+ return other
+ }
+ normalizeUsagePromptAudit(&audit)
+ auditInfo[UsagePromptAuditOtherKey] = audit
+ normalized, err := common.Marshal(otherMap)
+ if err != nil {
+ return other
+ }
+ return string(normalized)
+}
+
+func normalizeUsagePromptAudit(audit *UsagePromptAudit) {
+ if audit == nil {
+ return
+ }
+ tools := make([]UsagePromptAuditItem, 0, len(audit.Tools))
+ for _, tool := range audit.Tools {
+ if item, ok := tool.Value.(map[string]any); ok &&
+ strings.HasPrefix(auditString(item["description"]), "ClawOps ") &&
+ strings.HasSuffix(auditString(item["description"]), " tool replay") {
+ audit.ToolCalls = append(audit.ToolCalls, tool)
+ continue
+ }
+ tools = append(tools, tool)
+ }
+ audit.Tools = tools
+ audit.SystemPrompts = deduplicateUsagePromptAuditTexts(audit.SystemPrompts)
+ audit.Skills = deduplicateUsagePromptAuditItems(audit.Skills)
+ audit.Tools = deduplicateUsagePromptAuditItems(audit.Tools)
+ audit.ToolCalls = deduplicateUsagePromptAuditIdentifiedToolCalls(audit.ToolCalls)
+ audit.Plugins = deduplicateUsagePromptAuditItems(audit.Plugins)
+ if audit.Counts == nil {
+ audit.Counts = map[string]int{}
+ }
+ audit.Counts["user_inputs"] = len(audit.UserInputs)
+ audit.Counts["assistant_outputs"] = len(audit.AssistantOutputs)
+ audit.Counts["system_prompts"] = len(audit.SystemPrompts)
+ audit.Counts["skills"] = len(audit.Skills)
+ audit.Counts["tools"] = len(audit.Tools)
+ audit.Counts["tool_calls"] = len(audit.ToolCalls)
+ audit.Counts["plugins"] = len(audit.Plugins)
+}
+
+func normalizeUsagePromptAuditAssistantOutputs(outputs []UsagePromptAuditText) {
+ for index := range outputs {
+ var result map[string]any
+ if err := common.Unmarshal([]byte(outputs[index].Text), &result); err != nil ||
+ auditString(result["determination"]) == "" {
+ continue
+ }
+ // ClawOps renders a triaged result's summary as the user-visible conclusion.
+ for _, key := range []string{"summary", "headline", "customer_line", "customerLine"} {
+ if text := auditString(result[key]); strings.TrimSpace(text) != "" {
+ outputs[index].Text = text
+ break
+ }
+ }
+ }
+
+}
+
+func CaptureUsagePromptAuditBytes(body []byte) ([]byte, bool) {
+ if len(body) == 0 {
+ return nil, false
+ }
+ if len(body) <= maxUsagePromptAuditBodyBytes {
+ captured := make([]byte, len(body))
+ copy(captured, body)
+ return captured, false
+ }
+ captured := make([]byte, maxUsagePromptAuditBodyBytes)
+ copy(captured, body[:maxUsagePromptAuditBodyBytes])
+ return captured, true
+}
+
+func CaptureUsagePromptAuditBodyFromStorage(storage common.BodyStorage) ([]byte, bool, error) {
+ if storage == nil || storage.Size() == 0 {
+ return nil, false, nil
+ }
+ if _, err := storage.Seek(0, io.SeekStart); err != nil {
+ return nil, false, err
+ }
+ data, err := io.ReadAll(io.LimitReader(storage, int64(maxUsagePromptAuditBodyBytes)+1))
+ if seekErr := func() error {
+ _, seekErr := storage.Seek(0, io.SeekStart)
+ return seekErr
+ }(); seekErr != nil && err == nil {
+ err = seekErr
+ }
+ if err != nil {
+ return nil, false, err
+ }
+ if len(data) > maxUsagePromptAuditBodyBytes {
+ captured := make([]byte, maxUsagePromptAuditBodyBytes)
+ copy(captured, data[:maxUsagePromptAuditBodyBytes])
+ return captured, true, nil
+ }
+ return data, false, nil
+}
+
+func extractUsagePromptAuditRequest(audit *UsagePromptAudit, source string, body []byte) {
+ root := usagePromptAuditObject(body)
+ if len(root) == 0 {
+ return
+ }
+
+ extractUsagePromptAuditMessages(audit, source, root)
+ extractUsagePromptAuditInput(audit, source, root)
+ extractUsagePromptAuditSystemPrompts(audit, source, root)
+ extractUsagePromptAuditTools(audit, source, root)
+ extractUsagePromptAuditMetadata(audit, source, root)
+ extractUsagePromptAuditClawOpsProfile(audit, source, root)
+ extractUsagePromptAuditClawOpsDispatch(audit, source, root)
+}
+
+func extractUsagePromptAuditResponse(audit *UsagePromptAudit, source string, body []byte) {
+ root := usagePromptAuditObject(body)
+ if len(root) == 0 {
+ return
+ }
+
+ choices, ok := root["choices"].([]any)
+ if ok {
+ for i, choiceValue := range choices {
+ choice, ok := choiceValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ message, ok := choice["message"].(map[string]any)
+ if !ok {
+ continue
+ }
+ extractUsagePromptAuditContentTexts(&audit.AssistantOutputs, fmt.Sprintf("%s.choices[%d].message.content", source, i), message["content"])
+ extractUsagePromptAuditToolCalls(audit, fmt.Sprintf("%s.choices[%d].message.tool_calls", source, i), message["tool_calls"])
+ }
+ }
+
+ outputs, ok := root["output"].([]any)
+ if ok {
+ for i, outputValue := range outputs {
+ output, ok := outputValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ outputType := auditString(output["type"])
+ switch outputType {
+ case "message":
+ if role := auditString(output["role"]); role == "" || role == "assistant" {
+ extractUsagePromptAuditContentTexts(&audit.AssistantOutputs, fmt.Sprintf("%s.output[%d].content", source, i), output["content"])
+ }
+ case "function_call", "tool_call", "web_search_call", "file_search_call":
+ appendUsagePromptAuditItem(&audit.ToolCalls, fmt.Sprintf("%s.output[%d]", source, i), output)
+ default:
+ if strings.Contains(outputType, "call") {
+ appendUsagePromptAuditItem(&audit.ToolCalls, fmt.Sprintf("%s.output[%d]", source, i), output)
+ }
+ }
+ }
+ }
+
+ if tools, ok := root["tools"].([]any); ok {
+ for i, tool := range tools {
+ appendUsagePromptAuditItem(&audit.ToolCalls, fmt.Sprintf("%s.tools[%d]", source, i), tool)
+ }
+ }
+ if role := auditString(root["role"]); role == "" || role == "assistant" {
+ extractUsagePromptAuditAnthropicResponseTexts(audit, source+".content", root["content"])
+ if completion := auditString(root["completion"]); completion != "" {
+ appendUsagePromptAuditText(&audit.AssistantOutputs, source+".completion", completion)
+ }
+ }
+ extractUsagePromptAuditAnthropicContentTools(audit, source+".content", root["content"])
+ extractUsagePromptAuditResponseMessages(audit, source, root)
+ extractUsagePromptAuditClawOpsInvestigation(audit, source, root)
+}
+
+func extractUsagePromptAuditMessages(audit *UsagePromptAudit, source string, root map[string]any) {
+ messages, ok := root["messages"].([]any)
+ if !ok {
+ return
+ }
+ for i, messageValue := range messages {
+ message, ok := messageValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ role := auditString(message["role"])
+ contentPath := fmt.Sprintf("%s.messages[%d].content", source, i)
+ switch role {
+ case "system", "developer":
+ extractUsagePromptAuditContentTexts(&audit.SystemPrompts, contentPath, message["content"])
+ case "user":
+ extractUsagePromptAuditContentTexts(&audit.UserInputs, contentPath, message["content"])
+ }
+ extractUsagePromptAuditToolCalls(audit, fmt.Sprintf("%s.messages[%d].tool_calls", source, i), message["tool_calls"])
+ extractUsagePromptAuditAnthropicContentTools(audit, contentPath, message["content"])
+ extractUsagePromptAuditComposerInputItems(audit, fmt.Sprintf("%s.messages[%d].composer", source, i), message["composer"])
+ }
+}
+
+func extractUsagePromptAuditResponseMessages(audit *UsagePromptAudit, source string, root map[string]any) {
+ messages, ok := root["messages"].([]any)
+ if !ok {
+ return
+ }
+ for i, messageValue := range messages {
+ message, ok := messageValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ if role := auditString(message["role"]); role == "" || role == "assistant" {
+ extractUsagePromptAuditContentTexts(&audit.AssistantOutputs, fmt.Sprintf("%s.messages[%d].content", source, i), message["content"])
+ }
+ extractUsagePromptAuditToolCalls(audit, fmt.Sprintf("%s.messages[%d].tool_calls", source, i), message["tool_calls"])
+ }
+}
+
+func extractUsagePromptAuditInput(audit *UsagePromptAudit, source string, root map[string]any) {
+ input, exists := root["input"]
+ if !exists {
+ return
+ }
+
+ switch value := input.(type) {
+ case string:
+ appendUsagePromptAuditText(&audit.UserInputs, source+".input", value)
+ case []any:
+ for i, itemValue := range value {
+ itemPath := fmt.Sprintf("%s.input[%d]", source, i)
+ item, ok := itemValue.(map[string]any)
+ if !ok {
+ appendUsagePromptAuditText(&audit.UserInputs, itemPath, auditTextValue(itemValue))
+ continue
+ }
+ itemType := auditString(item["type"])
+ switch itemType {
+ case "skill":
+ appendUsagePromptAuditItem(&audit.Skills, itemPath, item)
+ case "plugin":
+ appendUsagePromptAuditItem(&audit.Plugins, itemPath, item)
+ case "input_text":
+ appendUsagePromptAuditText(&audit.UserInputs, itemPath+".text", auditString(item["text"]))
+ case "message":
+ if role := auditString(item["role"]); role == "" || role == "user" {
+ extractUsagePromptAuditContentTexts(&audit.UserInputs, itemPath+".content", item["content"])
+ }
+ case "function_call", "function_call_output", "tool_call", "tool_result", "web_search_call", "file_search_call":
+ appendUsagePromptAuditItem(&audit.ToolCalls, itemPath, item)
+ default:
+ if role := auditString(item["role"]); role == "user" {
+ extractUsagePromptAuditContentTexts(&audit.UserInputs, itemPath+".content", item["content"])
+ } else if strings.Contains(itemType, "call") || strings.Contains(itemType, "tool") {
+ appendUsagePromptAuditItem(&audit.ToolCalls, itemPath, item)
+ } else if text := auditString(item["text"]); text != "" {
+ appendUsagePromptAuditText(&audit.UserInputs, itemPath+".text", text)
+ }
+ }
+ }
+ case map[string]any:
+ if role := auditString(value["role"]); role == "" || role == "user" {
+ extractUsagePromptAuditContentTexts(&audit.UserInputs, source+".input.content", value["content"])
+ }
+ if text := auditString(value["text"]); text != "" {
+ appendUsagePromptAuditText(&audit.UserInputs, source+".input.text", text)
+ }
+ }
+}
+
+func extractUsagePromptAuditSystemPrompts(audit *UsagePromptAudit, source string, root map[string]any) {
+ if system, exists := root["system"]; exists {
+ extractUsagePromptAuditContentTexts(&audit.SystemPrompts, source+".system", system)
+ }
+ for _, key := range []string{"instructions", "instruction", "developerInstructions", "baseInstructions", "system_prompt", "systemPrompt"} {
+ value, exists := root[key]
+ if !exists {
+ continue
+ }
+ appendUsagePromptAuditText(&audit.SystemPrompts, source+"."+key, auditTextValue(value))
+ }
+}
+
+func extractUsagePromptAuditTools(audit *UsagePromptAudit, source string, root map[string]any) {
+ if tools, ok := root["tools"].([]any); ok {
+ for i, tool := range tools {
+ appendUsagePromptAuditItem(&audit.Tools, fmt.Sprintf("%s.tools[%d]", source, i), tool)
+ }
+ }
+ if functions, ok := root["functions"].([]any); ok {
+ for i, function := range functions {
+ appendUsagePromptAuditItem(&audit.Tools, fmt.Sprintf("%s.functions[%d]", source, i), function)
+ }
+ }
+ if servers, ok := root["mcp_servers"].([]any); ok {
+ for i, serverValue := range servers {
+ server, ok := serverValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ safeServer := map[string]any{
+ "type": "mcp_server",
+ }
+ for _, key := range []string{"name", "url", "server_label"} {
+ value := auditString(server[key])
+ if key == "url" {
+ value = sanitizeUsagePromptAuditURL(value)
+ }
+ if value != "" {
+ safeServer[key] = value
+ }
+ }
+ if transport := auditString(server["type"]); transport != "" {
+ safeServer["transport"] = transport
+ }
+ appendUsagePromptAuditItem(&audit.Tools, fmt.Sprintf("%s.mcp_servers[%d]", source, i), safeServer)
+ }
+ }
+}
+
+func extractUsagePromptAuditAnthropicResponseTexts(audit *UsagePromptAudit, path string, value any) {
+ switch content := value.(type) {
+ case string:
+ appendUsagePromptAuditText(&audit.AssistantOutputs, path, content)
+ case []any:
+ for i, partValue := range content {
+ part, ok := partValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ partType := auditString(part["type"])
+ if partType != "" && partType != "text" {
+ continue
+ }
+ if text := auditString(part["text"]); text != "" {
+ appendUsagePromptAuditText(&audit.AssistantOutputs, fmt.Sprintf("%s[%d].text", path, i), text)
+ }
+ }
+ }
+}
+
+func sanitizeUsagePromptAuditURL(rawURL string) string {
+ if rawURL == "" {
+ return ""
+ }
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return ""
+ }
+ parsed.User = nil
+ parsed.RawQuery = ""
+ parsed.ForceQuery = false
+ parsed.Fragment = ""
+ return parsed.String()
+}
+
+func extractUsagePromptAuditAnthropicContentTools(audit *UsagePromptAudit, path string, value any) {
+ content, ok := value.([]any)
+ if !ok {
+ return
+ }
+ for i, partValue := range content {
+ part, ok := partValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ partType := auditString(part["type"])
+ if partType == "tool_use" || partType == "tool_result" ||
+ strings.HasSuffix(partType, "_tool_use") || strings.HasSuffix(partType, "_tool_result") {
+ appendUsagePromptAuditItem(&audit.ToolCalls, fmt.Sprintf("%s[%d]", path, i), part)
+ }
+ }
+}
+
+func extractUsagePromptAuditMetadata(audit *UsagePromptAudit, source string, root map[string]any) {
+ metadata, ok := root["metadata"].(map[string]any)
+ if !ok {
+ return
+ }
+ for _, key := range []string{"skills", "skill", "required_skills"} {
+ extractUsagePromptAuditNamedValues(&audit.Skills, source+".metadata."+key, metadata[key])
+ }
+ for _, key := range []string{"plugins", "plugin", "required_plugins"} {
+ extractUsagePromptAuditNamedValues(&audit.Plugins, source+".metadata."+key, metadata[key])
+ }
+}
+
+func extractUsagePromptAuditComposerInputItems(audit *UsagePromptAudit, path string, value any) {
+ composer, ok := value.(map[string]any)
+ if !ok {
+ return
+ }
+ inputItems, ok := composer["inputItems"].([]any)
+ if !ok {
+ return
+ }
+ for i, inputItemValue := range inputItems {
+ itemPath := fmt.Sprintf("%s.inputItems[%d]", path, i)
+ item, ok := inputItemValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ if metadata, ok := item["metadata"].(map[string]any); ok {
+ extractUsagePromptAuditNamedValues(&audit.Skills, itemPath+".metadata.capability_skills", metadata["capability_skills"])
+ extractUsagePromptAuditNamedValues(&audit.Skills, itemPath+".metadata.skills", metadata["skills"])
+ extractUsagePromptAuditNamedValues(&audit.Plugins, itemPath+".metadata.required_plugins", metadata["required_plugins"])
+ extractUsagePromptAuditNamedValues(&audit.Plugins, itemPath+".metadata.plugins", metadata["plugins"])
+ }
+ if pathValue := auditString(item["path"]); pathValue != "" {
+ appendUsagePromptAuditItem(&audit.Plugins, itemPath, map[string]any{
+ "type": "plugin",
+ "name": pathValue,
+ "label": item["name"],
+ "value": item,
+ })
+ }
+ }
+}
+
+func extractUsagePromptAuditClawOpsProfile(audit *UsagePromptAudit, source string, root map[string]any) {
+ profile, ok := root["profile"].(map[string]any)
+ if !ok {
+ return
+ }
+ extractUsagePromptAuditNamedValues(&audit.Skills, source+".profile.entry_skill", profile["entry_skill"])
+ extractUsagePromptAuditNamedValues(&audit.Skills, source+".profile.skills", profile["skills"])
+ extractUsagePromptAuditNamedValues(&audit.Plugins, source+".profile.plugin", profile["plugin"])
+ extractUsagePromptAuditNamedValues(&audit.Plugins, source+".profile.required_plugins", profile["required_plugins"])
+}
+
+func extractUsagePromptAuditClawOpsDispatch(audit *UsagePromptAudit, source string, root map[string]any) {
+ unit, ok := root["unit"].(map[string]any)
+ if !ok {
+ return
+ }
+ dispatch, ok := unit["dispatch"].(map[string]any)
+ if !ok {
+ return
+ }
+ steps, ok := dispatch["steps"].([]any)
+ if !ok {
+ return
+ }
+ for i, stepValue := range steps {
+ step, ok := stepValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ extractUsagePromptAuditNamedValues(&audit.Skills, fmt.Sprintf("%s.unit.dispatch.steps[%d].skills", source, i), step["skills"])
+ }
+}
+
+func extractUsagePromptAuditClawOpsInvestigation(audit *UsagePromptAudit, source string, root map[string]any) {
+ if customerLine := auditString(root["customer_line"]); customerLine != "" {
+ appendUsagePromptAuditText(&audit.AssistantOutputs, source+".customer_line", customerLine)
+ }
+ if headline := auditString(root["headline"]); headline != "" {
+ appendUsagePromptAuditText(&audit.AssistantOutputs, source+".headline", headline)
+ }
+ investigation, ok := root["investigation"].(map[string]any)
+ if !ok {
+ return
+ }
+ stages, ok := investigation["stages"].([]any)
+ if !ok {
+ return
+ }
+ for i, stageValue := range stages {
+ stage, ok := stageValue.(map[string]any)
+ if !ok {
+ continue
+ }
+ stagePath := fmt.Sprintf("%s.investigation.stages[%d]", source, i)
+ appendUsagePromptAuditText(&audit.AssistantOutputs, stagePath+".output", auditString(stage["output"]))
+ extractUsagePromptAuditNamedValues(&audit.Skills, stagePath+".skill", stage["skill"])
+ if tools, ok := stage["tools"].([]any); ok {
+ for j, tool := range tools {
+ appendUsagePromptAuditItem(&audit.ToolCalls, fmt.Sprintf("%s.tools[%d]", stagePath, j), tool)
+ }
+ }
+ }
+}
+
+func extractUsagePromptAuditNamedValues(target *[]UsagePromptAuditItem, path string, value any) {
+ switch values := value.(type) {
+ case nil:
+ return
+ case []any:
+ for i, item := range values {
+ appendUsagePromptAuditItem(target, fmt.Sprintf("%s[%d]", path, i), item)
+ }
+ default:
+ appendUsagePromptAuditItem(target, path, values)
+ }
+}
+
+func extractUsagePromptAuditToolCalls(audit *UsagePromptAudit, path string, value any) {
+ switch calls := value.(type) {
+ case nil:
+ return
+ case []any:
+ for i, call := range calls {
+ appendUsagePromptAuditItem(&audit.ToolCalls, fmt.Sprintf("%s[%d]", path, i), call)
+ }
+ default:
+ appendUsagePromptAuditItem(&audit.ToolCalls, path, calls)
+ }
+}
+
+func extractUsagePromptAuditContentTexts(target *[]UsagePromptAuditText, path string, value any) {
+ switch content := value.(type) {
+ case nil:
+ return
+ case string:
+ appendUsagePromptAuditText(target, path, content)
+ case []any:
+ for i, partValue := range content {
+ partPath := fmt.Sprintf("%s[%d]", path, i)
+ part, ok := partValue.(map[string]any)
+ if !ok {
+ appendUsagePromptAuditText(target, partPath, auditTextValue(partValue))
+ continue
+ }
+ if text := auditString(part["text"]); text != "" {
+ appendUsagePromptAuditText(target, partPath+".text", text)
+ continue
+ }
+ if text := auditString(part["input_text"]); text != "" {
+ appendUsagePromptAuditText(target, partPath+".input_text", text)
+ continue
+ }
+ if text := auditString(part["output_text"]); text != "" {
+ appendUsagePromptAuditText(target, partPath+".output_text", text)
+ }
+ }
+ case map[string]any:
+ if text := auditString(content["text"]); text != "" {
+ appendUsagePromptAuditText(target, path+".text", text)
+ return
+ }
+ appendUsagePromptAuditText(target, path, auditTextValue(content))
+ }
+}
+
+func appendUsagePromptAuditText(target *[]UsagePromptAuditText, source string, text string) {
+ if strings.TrimSpace(text) == "" || len(*target) >= maxUsagePromptAuditItems {
+ return
+ }
+ if len(text) > maxUsagePromptAuditTextBytes {
+ text = text[:maxUsagePromptAuditTextBytes]
+ }
+ *target = append(*target, UsagePromptAuditText{
+ Source: source,
+ Text: text,
+ })
+}
+
+func appendUsagePromptAuditItem(target *[]UsagePromptAuditItem, source string, value any) {
+ if value == nil || len(*target) >= maxUsagePromptAuditItems {
+ return
+ }
+ *target = append(*target, UsagePromptAuditItem{
+ Source: source,
+ Type: auditItemType(value),
+ Name: auditItemName(value),
+ Value: value,
+ })
+}
+
+func deduplicateUsagePromptAuditTexts(items []UsagePromptAuditText) []UsagePromptAuditText {
+ seen := make(map[string]struct{}, len(items))
+ deduplicated := make([]UsagePromptAuditText, 0, len(items))
+ for _, item := range items {
+ if _, exists := seen[item.Text]; exists {
+ continue
+ }
+ seen[item.Text] = struct{}{}
+ deduplicated = append(deduplicated, item)
+ }
+ return deduplicated
+}
+
+func deduplicateUsagePromptAuditItems(items []UsagePromptAuditItem) []UsagePromptAuditItem {
+ seen := make(map[string]struct{}, len(items))
+ deduplicated := make([]UsagePromptAuditItem, 0, len(items))
+ for _, item := range items {
+ identity, err := common.Marshal(struct {
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Value any `json:"value,omitempty"`
+ }{
+ Type: item.Type,
+ Name: item.Name,
+ Value: item.Value,
+ })
+ if err != nil {
+ deduplicated = append(deduplicated, item)
+ continue
+ }
+ key := string(identity)
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ deduplicated = append(deduplicated, item)
+ }
+ return deduplicated
+}
+
+func deduplicateUsagePromptAuditIdentifiedToolCalls(items []UsagePromptAuditItem) []UsagePromptAuditItem {
+ seen := make(map[string]struct{}, len(items))
+ deduplicated := make([]UsagePromptAuditItem, 0, len(items))
+ for _, item := range items {
+ value, ok := item.Value.(map[string]any)
+ if !ok {
+ deduplicated = append(deduplicated, item)
+ continue
+ }
+ identifier := ""
+ for _, key := range []string{"id", "call_id", "tool_use_id"} {
+ if identifier = auditString(value[key]); identifier != "" {
+ break
+ }
+ }
+ if identifier == "" {
+ deduplicated = append(deduplicated, item)
+ continue
+ }
+ key := item.Type + "\x00" + identifier
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ deduplicated = append(deduplicated, item)
+ }
+ return deduplicated
+}
+
+func usagePromptAuditObject(body []byte) map[string]any {
+ body = bytes.TrimSpace(body)
+ if len(body) == 0 {
+ return nil
+ }
+ var root map[string]any
+ if err := common.Unmarshal(body, &root); err != nil {
+ return nil
+ }
+ return root
+}
+
+func auditItemType(value any) string {
+ if item, ok := value.(map[string]any); ok {
+ return auditString(item["type"])
+ }
+ return ""
+}
+
+func auditItemName(value any) string {
+ switch item := value.(type) {
+ case string:
+ return item
+ case map[string]any:
+ if name := auditString(item["name"]); name != "" {
+ return name
+ }
+ if function, ok := item["function"].(map[string]any); ok {
+ if name := auditString(function["name"]); name != "" {
+ return name
+ }
+ }
+ if name := auditString(item["server_label"]); name != "" {
+ return name
+ }
+ if name := auditString(item["plugin"]); name != "" {
+ return name
+ }
+ if name := auditString(item["call_id"]); name != "" {
+ return name
+ }
+ if name := auditString(item["id"]); name != "" {
+ return name
+ }
+ if name := auditString(item["tool_use_id"]); name != "" {
+ return name
+ }
+ return auditString(item["type"])
+ default:
+ return auditTextValue(value)
+ }
+}
+
+func auditTextValue(value any) string {
+ switch typed := value.(type) {
+ case nil:
+ return ""
+ case string:
+ return typed
+ case fmt.Stringer:
+ return typed.String()
+ default:
+ data, err := common.Marshal(value)
+ if err != nil {
+ return fmt.Sprint(value)
+ }
+ return string(data)
+ }
+}
+
+func auditString(value any) string {
+ if value == nil {
+ return ""
+ }
+ if text, ok := value.(string); ok {
+ return text
+ }
+ return auditTextValue(value)
+}
diff --git a/service/usage_prompt_audit_test.go b/service/usage_prompt_audit_test.go
new file mode 100644
index 000000000..87ac011c5
--- /dev/null
+++ b/service/usage_prompt_audit_test.go
@@ -0,0 +1,473 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildUsagePromptAuditExtractsChatRequestAndResponse(t *testing.T) {
+ clientRequest := []byte(`{
+ "model":"gpt-4.1",
+ "messages":[
+ {"role":"system","content":"client system prompt"},
+ {"role":"user","content":[{"type":"text","text":"分析 8.8.8.8 的威胁情报"}]}
+ ],
+ "tools":[{"type":"function","function":{"name":"lookup_ip","description":"查 IP 情报"}}],
+ "metadata":{"plugins":["clawops-ip"],"skills":["ip-analysis"]}
+ }`)
+ upstreamRequest := []byte(`{
+ "model":"gpt-4.1",
+ "messages":[
+ {"role":"developer","content":"newapi channel system prompt"},
+ {"role":"user","content":"分析 8.8.8.8 的威胁情报"}
+ ],
+ "tools":[{"type":"function","function":{"name":"lookup_ip"}}]
+ }`)
+ responseBody := []byte(`{
+ "choices":[{"message":{
+ "role":"assistant",
+ "content":"8.8.8.8 是 Google Public DNS,未见直接恶意结论。",
+ "tool_calls":[{"id":"call_lookup_ip","type":"function","function":{"name":"lookup_ip","arguments":"{\"ip\":\"8.8.8.8\"}"}}]
+ }}]
+ }`)
+
+ audit := BuildUsagePromptAudit(clientRequest, upstreamRequest, responseBody)
+
+ require.NotNil(t, audit)
+ assert.Equal(t, UsagePromptAuditSchemaV1, audit.Schema)
+ assertAuditDoesNotStoreRawBodies(t, audit)
+
+ assertAuditTextContains(t, audit.UserInputs, "client_request.messages[1].content[0].text", "分析 8.8.8.8")
+ assertAuditTextContains(t, audit.UserInputs, "upstream_request.messages[1].content", "分析 8.8.8.8")
+ assertAuditTextContains(t, audit.SystemPrompts, "client_request.messages[0].content", "client system prompt")
+ assertAuditTextContains(t, audit.SystemPrompts, "upstream_request.messages[0].content", "newapi channel system prompt")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.choices[0].message.content", "Google Public DNS")
+ assertAuditItemNamed(t, audit.Tools, "client_request.tools[0]", "lookup_ip")
+ assertAuditItemNamed(t, audit.Tools, "upstream_request.tools[0]", "lookup_ip")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.metadata.plugins[0]", "clawops-ip")
+ assertAuditItemNamed(t, audit.Skills, "client_request.metadata.skills[0]", "ip-analysis")
+ assert.Len(t, audit.ToolCalls, 1)
+ assertAuditItemNamed(t, audit.ToolCalls, "upstream_response.choices[0].message.tool_calls[0]", "lookup_ip")
+ assert.Equal(t, 2, audit.Counts["user_inputs"])
+ assert.Equal(t, 2, audit.Counts["system_prompts"])
+ assert.Equal(t, 1, audit.Counts["assistant_outputs"])
+ assert.Equal(t, 1, audit.Counts["tool_calls"])
+}
+
+func TestBuildUsagePromptAuditDoesNotStoreTopLevelPrompt(t *testing.T) {
+ clientRequest := []byte(`{
+ "model":"codex-mini",
+ "instructions":"基础系统提示词",
+ "developerInstructions":"开发者提示词",
+ "input":[
+ {"type":"skill","name":"claw-soc:soc-triage","path":"/skills/soc/SKILL.md"},
+ {"type":"plugin","name":"claw-cigs"},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"处理 CIGS 告警 651 并给出分诊结论"}]},
+ {"role":"user","content":"综合研判最近 24 小时攻击态势"}
+ ],
+ "tools":[{"type":"function","name":"run_triage"},{"type":"mcp","server_label":"cigs"}],
+ "metadata":{"required_plugins":["claw-soc","claw-cigs"],"skills":[{"name":"triage-skill"}]},
+ "prompt":{"id":"pmpt_123","variables":{"workspace":"default"}}
+ }`)
+ upstreamRequest := []byte(`{
+ "model":"codex-mini",
+ "input":"处理 CIGS 告警 651 并给出分诊结论",
+ "tools":[{"type":"function","name":"run_triage"}]
+ }`)
+ responseBody := []byte(`{
+ "output":[
+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"分诊成功,告警 651 建议升级为中危。"}]},
+ {"type":"function_call","name":"run_triage","arguments":{"alert_id":651}}
+ ]
+ }`)
+
+ audit := BuildUsagePromptAudit(clientRequest, upstreamRequest, responseBody)
+
+ require.NotNil(t, audit)
+ assertAuditTextContains(t, audit.SystemPrompts, "client_request.instructions", "基础系统提示词")
+ assertAuditTextContains(t, audit.SystemPrompts, "client_request.developerInstructions", "开发者提示词")
+ assertAuditTextContains(t, audit.UserInputs, "client_request.input[2].content[0].text", "CIGS 告警 651")
+ assertAuditTextContains(t, audit.UserInputs, "client_request.input[3].content", "最近 24 小时")
+ assertAuditTextContains(t, audit.UserInputs, "upstream_request.input", "CIGS 告警 651")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.output[0].content[0].text", "分诊成功")
+ assertAuditItemNamed(t, audit.Skills, "client_request.input[0]", "claw-soc:soc-triage")
+ assertAuditItemNamed(t, audit.Skills, "client_request.metadata.skills[0]", "triage-skill")
+ assertAuditItemNamed(t, audit.Tools, "client_request.tools[0]", "run_triage")
+ assertAuditItemNamed(t, audit.Tools, "client_request.tools[1]", "cigs")
+ assertAuditItemNamed(t, audit.ToolCalls, "upstream_response.output[1]", "run_triage")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.input[1]", "claw-cigs")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.metadata.required_plugins[0]", "claw-soc")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.metadata.required_plugins[1]", "claw-cigs")
+
+ data, err := common.Marshal(audit)
+ require.NoError(t, err)
+ assert.NotContains(t, string(data), "\"prompts\"")
+ assert.NotContains(t, string(data), "pmpt_123")
+}
+
+func TestBuildUsagePromptAuditExtractsAnthropicRequestAndResponse(t *testing.T) {
+ clientRequest := []byte(`{
+ "model":"claude-sonnet-4-5",
+ "system":[{"type":"text","text":"ClawOps Anthropic 系统提示词"}],
+ "messages":[{
+ "role":"user",
+ "content":[
+ {"type":"text","text":"分析 103.195.103.66"},
+ {"type":"tool_result","tool_use_id":"toolu_lookup","content":"本地告警命中 10.35.177.119"}
+ ]
+ }],
+ "tools":[{"name":"lookup_ip","description":"查询 IP 情报","input_schema":{"type":"object"}}],
+ "mcp_servers":[{
+ "type":"url",
+ "name":"cigs",
+ "url":"https://audit-user:audit-password@mcp.example.test/sse?token=query-secret",
+ "authorization_token":"must-not-be-recorded"
+ }],
+ "metadata":{"skills":["claw-cybersec-analysis:ip-analysis"],"plugins":["claw-cigs"]}
+ }`)
+ upstreamRequest := []byte(`{
+ "model":"claude-sonnet-4-5",
+ "system":"渠道追加的系统提示词",
+ "messages":[{"role":"user","content":"分析 103.195.103.66"}],
+ "tools":[{"name":"lookup_ip","input_schema":{"type":"object"}}]
+ }`)
+ responseBody := []byte(`{
+ "id":"msg_123",
+ "type":"message",
+ "role":"assistant",
+ "content":[
+ {"type":"thinking","thinking":"隐藏推理不得进入使用记录","text":"thinking-text-must-not-be-recorded"},
+ {"type":"text","text":"该 IP 与内网主机存在隧道告警关联。"},
+ {"type":"tool_use","id":"toolu_cigs","name":"query_cigs","input":{"ip":"103.195.103.66"}},
+ {"type":"web_search_tool_result","tool_use_id":"toolu_web","content":[{"type":"web_search_result","url":"https://example.test"}]}
+ ]
+ }`)
+
+ audit := BuildUsagePromptAudit(clientRequest, upstreamRequest, responseBody)
+
+ require.NotNil(t, audit)
+ assertAuditTextContains(t, audit.UserInputs, "client_request.messages[0].content[0].text", "103.195.103.66")
+ assertAuditTextContains(t, audit.SystemPrompts, "client_request.system[0].text", "ClawOps Anthropic 系统提示词")
+ assertAuditTextContains(t, audit.SystemPrompts, "upstream_request.system", "渠道追加的系统提示词")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.content[1].text", "隧道告警关联")
+ assertAuditItemNamed(t, audit.Tools, "client_request.tools[0]", "lookup_ip")
+ assertAuditItemNamed(t, audit.Tools, "client_request.mcp_servers[0]", "cigs")
+ assertAuditItemNamed(t, audit.ToolCalls, "client_request.messages[0].content[1]", "toolu_lookup")
+ assertAuditItemNamed(t, audit.ToolCalls, "upstream_response.content[2]", "query_cigs")
+ assertAuditItemNamed(t, audit.ToolCalls, "upstream_response.content[3]", "toolu_web")
+ assert.Len(t, audit.ToolCalls, 3)
+ assertAuditItemNamed(t, audit.Skills, "client_request.metadata.skills[0]", "claw-cybersec-analysis:ip-analysis")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.metadata.plugins[0]", "claw-cigs")
+
+ data, err := common.Marshal(audit)
+ require.NoError(t, err)
+ jsonText := string(data)
+ assert.NotContains(t, jsonText, "must-not-be-recorded")
+ assert.NotContains(t, jsonText, "audit-user")
+ assert.NotContains(t, jsonText, "audit-password")
+ assert.NotContains(t, jsonText, "query-secret")
+ assert.NotContains(t, jsonText, "隐藏推理不得进入使用记录")
+ assert.NotContains(t, jsonText, "thinking-text-must-not-be-recorded")
+ assert.Contains(t, jsonText, "https://mcp.example.test/sse")
+}
+
+func TestBuildUsagePromptAuditExtractsClawOpsRawSnapshotAndTriageResult(t *testing.T) {
+ clientRequest := []byte(`{
+ "messages":[
+ {
+ "role":"user",
+ "content":"分析 103.195.103.66",
+ "composer":{
+ "inputItems":[
+ {
+ "type":"mention",
+ "name":"IP 分析",
+ "path":"agent-app://ip",
+ "metadata":{
+ "capability_skills":["claw-cybersec-analysis:ip-analysis"]
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "profile":{
+ "entry_skill":"claw-soc:soc-triage",
+ "plugin":"claw-soc",
+ "required_plugins":["claw-cigs"]
+ },
+ "unit":{
+ "dispatch":{
+ "steps":[{"skills":["cigs-alert-context","soc-alert-verdict"]}]
+ }
+ }
+ }`)
+ responseBody := []byte(`{
+ "messages":[{"role":"assistant","content":"IP 分析报告输出"}],
+ "investigation":{
+ "stages":[
+ {
+ "skill":"cigs-alert-context",
+ "output":"确认为产品运营事件",
+ "tools":[{"name":"cigs alerts history --limit 500","status":"success"}]
+ }
+ ]
+ },
+ "customer_line":"这是一条用户长期未使用产品的运营提醒,不是网络攻击或设备失陷。"
+ }`)
+
+ audit := BuildUsagePromptAudit(clientRequest, nil, responseBody)
+
+ require.NotNil(t, audit)
+ assertAuditTextContains(t, audit.UserInputs, "client_request.messages[0].content", "103.195.103.66")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.messages[0].content", "IP 分析报告输出")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.investigation.stages[0].output", "产品运营事件")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.customer_line", "长期未使用")
+ assertAuditItemNamed(t, audit.Skills, "client_request.messages[0].composer.inputItems[0].metadata.capability_skills[0]", "claw-cybersec-analysis:ip-analysis")
+ assertAuditItemNamed(t, audit.Skills, "client_request.profile.entry_skill", "claw-soc:soc-triage")
+ assertAuditItemNamed(t, audit.Skills, "client_request.unit.dispatch.steps[0].skills[0]", "cigs-alert-context")
+ assertAuditItemNamed(t, audit.ToolCalls, "upstream_response.investigation.stages[0].tools[0]", "cigs alerts history --limit 500")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.profile.plugin", "claw-soc")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.profile.required_plugins[0]", "claw-cigs")
+ assertAuditItemNamed(t, audit.Plugins, "client_request.messages[0].composer.inputItems[0]", "agent-app://ip")
+}
+
+func TestBuildUsagePromptAuditFromCapturedBodiesUsesRawBodiesOnlyForExtraction(t *testing.T) {
+ audit := BuildUsagePromptAuditFromCapturedBodies(UsagePromptAuditCapturedBodies{
+ ClientRequestBody: []byte(`{"input":"abc"}`),
+ ClientRequestBodyTruncated: true,
+ })
+
+ require.NotNil(t, audit)
+ assertAuditDoesNotStoreRawBodies(t, audit)
+ assertAuditTextContains(t, audit.UserInputs, "client_request.input", "abc")
+}
+
+func TestBuildUsagePromptAuditDeduplicatesEquivalentRequestAttachments(t *testing.T) {
+ clientRequest := []byte(`{
+ "system":"ClawOps system prompt",
+ "input":"client original question",
+ "tools":[{"type":"function","name":"lookup_ip","parameters":{"type":"object"}}],
+ "metadata":{"skills":["claw-cybersec-analysis:ip-analysis"],"plugins":["claw-cigs"]}
+ }`)
+ upstreamRequest := []byte(`{
+ "system":"ClawOps system prompt",
+ "input":"mapped upstream question",
+ "tools":[{"type":"function","name":"lookup_ip","parameters":{"type":"object"}}],
+ "metadata":{"skills":["claw-cybersec-analysis:ip-analysis"],"plugins":["claw-cigs"]}
+ }`)
+
+ audit := BuildUsagePromptAudit(clientRequest, upstreamRequest, nil)
+
+ require.NotNil(t, audit)
+ assert.Len(t, audit.UserInputs, 2)
+ assert.Len(t, audit.SystemPrompts, 1)
+ assert.Len(t, audit.Tools, 1)
+ assert.Len(t, audit.Skills, 1)
+ assert.Len(t, audit.Plugins, 1)
+ assert.Equal(t, "client_request.system", audit.SystemPrompts[0].Source)
+ assert.Equal(t, "client_request.tools[0]", audit.Tools[0].Source)
+ assert.Equal(t, "client_request.metadata.skills[0]", audit.Skills[0].Source)
+ assert.Equal(t, "client_request.metadata.plugins[0]", audit.Plugins[0].Source)
+ assert.Equal(t, 1, audit.Counts["system_prompts"])
+ assert.Equal(t, 1, audit.Counts["tools"])
+ assert.Equal(t, 1, audit.Counts["skills"])
+ assert.Equal(t, 1, audit.Counts["plugins"])
+}
+
+func TestBuildUsagePromptAuditSeparatesToolDefinitionsFromToolCalls(t *testing.T) {
+ clientRequest := []byte(`{
+ "tools":[{"type":"function","name":"lookup_ip"}],
+ "messages":[{"role":"assistant","tool_calls":[{"id":"call_history","function":{"name":"lookup_ip","arguments":"{}"}}]}]
+ }`)
+ responseBody := []byte(`{
+ "output":[{"type":"function_call","name":"run_triage","arguments":"{}"}]
+ }`)
+
+ audit := BuildUsagePromptAudit(clientRequest, nil, responseBody)
+
+ require.NotNil(t, audit)
+ assert.Len(t, audit.Tools, 1)
+ assertAuditItemNamed(t, audit.Tools, "client_request.tools[0]", "lookup_ip")
+ assert.Len(t, audit.ToolCalls, 2)
+ assertAuditItemNamed(t, audit.ToolCalls, "client_request.messages[0].tool_calls[0]", "lookup_ip")
+ assertAuditItemNamed(t, audit.ToolCalls, "upstream_response.output[0]", "run_triage")
+ assert.Equal(t, 1, audit.Counts["tools"])
+ assert.Equal(t, 2, audit.Counts["tool_calls"])
+}
+
+func TestBuildUsagePromptAuditExtractsResponsesInputToolCallsWithoutCrossBodyDuplicates(t *testing.T) {
+ request := []byte(`{
+ "input":[
+ {"type":"function_call","call_id":"call_lookup_ip","name":"lookup_ip","arguments":"{\"ip\":\"8.8.8.8\"}"},
+ {"type":"function_call_output","call_id":"call_lookup_ip","output":"no malicious result"}
+ ]
+ }`)
+
+ audit := BuildUsagePromptAudit(request, request, nil)
+
+ require.NotNil(t, audit)
+ require.Len(t, audit.ToolCalls, 2)
+ assertAuditItemNamed(t, audit.ToolCalls, "client_request.input[0]", "lookup_ip")
+ assertAuditItemNamed(t, audit.ToolCalls, "client_request.input[1]", "call_lookup_ip")
+ assert.Equal(t, 2, audit.Counts["tool_calls"])
+}
+
+func TestBuildUsagePromptAuditUsesTriageSummaryAsUserVisibleOutput(t *testing.T) {
+ responseBody := []byte(`{
+ "output":[{
+ "type":"message",
+ "role":"assistant",
+ "content":[{
+ "type":"output_text",
+ "text":"{\"determination\":\"良性\",\"headline\":\"产品运营事件\",\"summary\":\"该告警属于产品用户留存运营提醒,无需安全处置。\",\"customer_line\":\"这是一条运营提醒。\"}"
+ }]
+ }]
+ }`)
+
+ audit := BuildUsagePromptAudit(nil, nil, responseBody)
+
+ require.NotNil(t, audit)
+ require.Len(t, audit.AssistantOutputs, 1)
+ assert.Equal(t, "该告警属于产品用户留存运营提醒,无需安全处置。", audit.AssistantOutputs[0].Text)
+}
+
+func TestBuildUsagePromptAuditPreservesInputAndOutputWhitespace(t *testing.T) {
+ audit := BuildUsagePromptAudit(
+ []byte(`{"input":"\n original user input \n"}`),
+ nil,
+ []byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n original model output \n"}]}]}`),
+ )
+
+ require.NotNil(t, audit)
+ require.Equal(t, "\n original user input \n", audit.UserInputs[0].Text)
+ require.Equal(t, "\n original model output \n", audit.AssistantOutputs[0].Text)
+}
+
+func TestNormalizeUsagePromptAuditOtherDeduplicatesLegacyAttachments(t *testing.T) {
+ other := `{
+ "usage_semantic":"openai",
+ "audit_info":{
+ "prompt_audit":{
+ "schema":"newapi_prompt_audit:v1",
+ "system_prompts":[
+ {"source":"client_request.instructions","text":"legacy system prompt"},
+ {"source":"upstream_request.instructions","text":"legacy system prompt"}
+ ],
+ "tools":[
+ {"source":"client_request.tools[0]","type":"function","name":"lookup_ip","value":{"type":"function","name":"lookup_ip"}},
+ {"source":"upstream_request.tools[0]","type":"function","name":"lookup_ip","value":{"type":"function","name":"lookup_ip"}}
+ ],
+ "counts":{"system_prompts":2,"tools":2}
+ }
+ }
+ }`
+
+ normalized := NormalizeUsagePromptAuditOther(other)
+ var normalizedOther map[string]any
+ require.NoError(t, common.Unmarshal([]byte(normalized), &normalizedOther))
+ auditInfo, ok := normalizedOther["audit_info"].(map[string]any)
+ require.True(t, ok)
+ auditBytes, err := common.Marshal(auditInfo[UsagePromptAuditOtherKey])
+ require.NoError(t, err)
+ var audit UsagePromptAudit
+ require.NoError(t, common.Unmarshal(auditBytes, &audit))
+ assert.Len(t, audit.SystemPrompts, 1)
+ assert.Len(t, audit.Tools, 1)
+ assert.Equal(t, "client_request.instructions", audit.SystemPrompts[0].Source)
+ assert.Equal(t, "client_request.tools[0]", audit.Tools[0].Source)
+ assert.Equal(t, 1, audit.Counts["system_prompts"])
+ assert.Equal(t, 1, audit.Counts["tools"])
+}
+
+func TestNormalizeUsagePromptAuditOtherMovesLegacyReplayToolsToToolCalls(t *testing.T) {
+ other := `{
+ "audit_info":{
+ "prompt_audit":{
+ "schema":"newapi_prompt_audit:v1",
+ "tools":[{
+ "source":"upstream_response.output[1]",
+ "type":"function",
+ "name":"/bin/zsh -lc 'clawops runtime status'",
+ "value":{"type":"function","name":"/bin/zsh -lc 'clawops runtime status'","description":"ClawOps raw snapshot tool replay"}
+ }]
+ }
+ }
+ }`
+
+ normalized := NormalizeUsagePromptAuditOther(other)
+ var normalizedOther map[string]any
+ require.NoError(t, common.Unmarshal([]byte(normalized), &normalizedOther))
+ auditInfo, ok := normalizedOther["audit_info"].(map[string]any)
+ require.True(t, ok)
+ auditBytes, err := common.Marshal(auditInfo[UsagePromptAuditOtherKey])
+ require.NoError(t, err)
+ var audit UsagePromptAudit
+ require.NoError(t, common.Unmarshal(auditBytes, &audit))
+ assert.Empty(t, audit.Tools)
+ assert.Len(t, audit.ToolCalls, 1)
+ assert.Equal(t, "/bin/zsh -lc 'clawops runtime status'", audit.ToolCalls[0].Name)
+ assert.Equal(t, 0, audit.Counts["tools"])
+ assert.Equal(t, 1, audit.Counts["tool_calls"])
+}
+
+func TestInjectUsagePromptAuditAddsPromptAuditToOther(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ UsageLogRawClientRequestBody: []byte(`{"input":"appserver 原始输入"}`),
+ UsageLogRawUpstreamRequestBody: []byte(`{"input":"最终上游输入"}`),
+ UsageLogRawResponseBody: []byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"系统输出"}]}]}`),
+ }
+ other := model.NewLogOther()
+ other.SetPublic("usage_semantic", "openai")
+
+ InjectUsagePromptAudit(other, info)
+
+ auditInfo, ok := other.Snapshot()["audit_info"].(map[string]any)
+ require.True(t, ok)
+ audit, ok := auditInfo[UsagePromptAuditOtherKey].(*UsagePromptAudit)
+ require.True(t, ok)
+ assert.Equal(t, "openai", other.Snapshot()["usage_semantic"])
+ assertAuditDoesNotStoreRawBodies(t, audit)
+ assertAuditTextContains(t, audit.UserInputs, "client_request.input", "appserver 原始输入")
+ assertAuditTextContains(t, audit.UserInputs, "upstream_request.input", "最终上游输入")
+ assertAuditTextContains(t, audit.AssistantOutputs, "upstream_response.output[0].content[0].text", "系统输出")
+}
+
+func assertAuditDoesNotStoreRawBodies(t *testing.T, audit *UsagePromptAudit) {
+ t.Helper()
+ data, err := common.Marshal(audit)
+ require.NoError(t, err)
+ jsonText := string(data)
+ assert.NotContains(t, jsonText, "raw_client_request_body")
+ assert.NotContains(t, jsonText, "raw_client_request_body_truncated")
+ assert.NotContains(t, jsonText, "raw_upstream_request_body")
+ assert.NotContains(t, jsonText, "raw_upstream_request_body_truncated")
+ assert.NotContains(t, jsonText, "raw_response_body")
+ assert.NotContains(t, jsonText, "raw_response_body_truncated")
+}
+
+func assertAuditTextContains(t *testing.T, texts []UsagePromptAuditText, source string, want string) {
+ t.Helper()
+ for _, text := range texts {
+ if text.Source == source && strings.Contains(text.Text, want) {
+ return
+ }
+ }
+ require.Failf(t, "missing audit text", "source=%s want substring=%q texts=%+v", source, want, texts)
+}
+
+func assertAuditItemNamed(t *testing.T, items []UsagePromptAuditItem, source string, name string) {
+ t.Helper()
+ for _, item := range items {
+ if item.Source == source && item.Name == name {
+ return
+ }
+ }
+ require.Failf(t, "missing audit item", "source=%s name=%s items=%+v", source, name, items)
+}
diff --git a/web/src/components/data-table/toolbar/faceted-filter.tsx b/web/src/components/data-table/toolbar/faceted-filter.tsx
index 9dc31b0cc..1eaceaf79 100644
--- a/web/src/components/data-table/toolbar/faceted-filter.tsx
+++ b/web/src/components/data-table/toolbar/faceted-filter.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { type Column } from '@tanstack/react-table'
+import type { Column } from '@tanstack/react-table'
import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@@ -52,6 +52,10 @@ type DataTableFacetedFilterProps = {
}[]
/** Enable single select mode (only one option can be selected at a time) */
singleSelect?: boolean
+ /** Option value that clears the filter when selected. */
+ allOptionValue?: string
+ /** Show explicit or faceted option counts. */
+ showCounts?: boolean
}
function DataTableFacetedFilterInner({
@@ -59,6 +63,8 @@ function DataTableFacetedFilterInner({
title,
options,
singleSelect = false,
+ allOptionValue,
+ showCounts = true,
}: DataTableFacetedFilterProps) {
const { t } = useTranslation()
const facets = column?.getFacetedUniqueValues()
@@ -69,7 +75,8 @@ function DataTableFacetedFilterInner({
const nextSelectedValues = getNextSelectedValues(
selectedValues,
optionValue,
- singleSelect
+ singleSelect,
+ allOptionValue
)
column?.setFilterValue(
@@ -128,6 +135,35 @@ function DataTableFacetedFilterInner({
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
+
+ let optionCount: React.ReactNode = null
+ if (showCounts && typeof option.count === 'number') {
+ optionCount = (
+
+ {option.count}
+
+ )
+ } else if (showCounts && facets?.get(option.value)) {
+ optionCount = (
+
+ {facets.get(option.value)}
+
+ )
+ }
+
+ let optionIcon: React.ReactNode = null
+ if (option.iconNode) {
+ optionIcon = (
+
+ {option.iconNode}
+
+ )
+ } else if (option.icon) {
+ optionIcon = (
+
+ )
+ }
+
return (
({
>
- {option.iconNode ? (
-
- {option.iconNode}
-
- ) : option.icon ? (
-
- ) : null}
+ {optionIcon}
{t(option.label)}
- {typeof option.count === 'number' ? (
-
- {option.count}
-
- ) : facets?.get(option.value) ? (
-
- {facets.get(option.value)}
-
- ) : null}
+ {optionCount}
)
})}
@@ -196,8 +218,13 @@ export const DataTableFacetedFilter = React.memo(
function getNextSelectedValues(
selectedValues: Set,
optionValue: string,
- singleSelect: boolean
+ singleSelect: boolean,
+ allOptionValue?: string
): string[] {
+ if (optionValue === allOptionValue) {
+ return []
+ }
+
if (singleSelect) {
return selectedValues.has(optionValue) ? [] : [optionValue]
}
@@ -209,5 +236,5 @@ function getNextSelectedValues(
nextSelectedValues.add(optionValue)
}
- return Array.from(nextSelectedValues)
+ return [...nextSelectedValues]
}
diff --git a/web/src/components/data-table/toolbar/toolbar.tsx b/web/src/components/data-table/toolbar/toolbar.tsx
index 4e272b1ee..62b25fbb7 100644
--- a/web/src/components/data-table/toolbar/toolbar.tsx
+++ b/web/src/components/data-table/toolbar/toolbar.tsx
@@ -42,6 +42,8 @@ type FilterDef = {
count?: number
}[]
singleSelect?: boolean
+ allOptionValue?: string
+ showCounts?: boolean
}
type SearchDraft = {
@@ -272,6 +274,8 @@ export function DataTableToolbar(props: DataTableToolbarProps) {
title={filter.title}
options={filter.options}
singleSelect={filter.singleSelect}
+ allOptionValue={filter.allOptionValue}
+ showCounts={filter.showCounts}
/>
)
}),
diff --git a/web/src/features/keys/api.ts b/web/src/features/keys/api.ts
index 0f90490c7..4709b4d82 100644
--- a/web/src/features/keys/api.ts
+++ b/web/src/features/keys/api.ts
@@ -36,8 +36,12 @@ import type {
export async function getApiKeys(
params: GetApiKeysParams = {}
): Promise {
- const { p = 1, size = 10 } = params
- const res = await api.get(`/api/token/?p=${p}&size=${size}`)
+ const { groups = [], p = 1, size = 10 } = params
+ const queryParams = new URLSearchParams()
+ groups.forEach((group) => queryParams.append('group', group))
+ queryParams.set('p', String(p))
+ queryParams.set('size', String(size))
+ const res = await api.get(`/api/token/?${queryParams.toString()}`)
return res.data
}
@@ -45,10 +49,11 @@ export async function getApiKeys(
export async function searchApiKeys(
params: SearchApiKeysParams
): Promise {
- const { keyword = '', token = '', p, size } = params
+ const { keyword = '', token = '', groups = [], p, size } = params
const queryParams = new URLSearchParams()
if (keyword) queryParams.set('keyword', keyword)
if (token) queryParams.set('token', token)
+ groups.forEach((group) => queryParams.append('group', group))
if (p != null) queryParams.set('p', String(p))
if (size != null) queryParams.set('size', String(size))
const res = await api.get(`/api/token/search?${queryParams.toString()}`)
diff --git a/web/src/features/keys/components/__tests__/api-key-listing.test.tsx b/web/src/features/keys/components/__tests__/api-key-listing.test.tsx
index 46a937ecf..d03d5ac93 100644
--- a/web/src/features/keys/components/__tests__/api-key-listing.test.tsx
+++ b/web/src/features/keys/components/__tests__/api-key-listing.test.tsx
@@ -465,9 +465,12 @@ it('keeps full mobile information without group or quota section headings', asyn
expect(screen.getByText(zh.translation['Created'])).toBeInTheDocument()
expect(screen.getByText(zh.translation['Last Used'])).toBeInTheDocument()
expect(screen.getByText(zh.translation['Expires'])).toBeInTheDocument()
- expect(
- screen.queryByText(zh.translation['Group'], { exact: true })
- ).not.toBeInTheDocument()
+ // 移动端卡片不渲染 "Group" 小节标题;工具栏的分组筛选按钮除外
+ for (const el of screen.queryAllByText(zh.translation['Group'], {
+ exact: true,
+ })) {
+ expect(el.closest('button')).not.toBeNull()
+ }
expect(screen.getByText('default')).toBeInTheDocument()
expect(screen.getByText('1x')).toBeInTheDocument()
expect(screen.getByText(zh.translation['Models'])).toBeInTheDocument()
diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx
index e60dfbe65..e5440059a 100644
--- a/web/src/features/keys/components/api-keys-table.tsx
+++ b/web/src/features/keys/components/api-keys-table.tsx
@@ -20,7 +20,7 @@ import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
@@ -41,6 +41,7 @@ import {
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { useTableUrlState } from '@/hooks/use-table-url-state'
+import { getUserGroups } from '@/lib/api'
import { createServerError } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
@@ -244,6 +245,13 @@ export function ApiKeysTable() {
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
+ {
+ columnId: 'group',
+ searchKey: 'group',
+ type: 'array',
+ deserialize: (value) =>
+ Array.isArray(value) ? value.filter((group) => group !== 'all') : [],
+ },
{ columnId: '_tokenSearch', searchKey: 'token', type: 'string' },
],
})
@@ -257,8 +265,31 @@ export function ApiKeysTable() {
columnId: '_tokenSearch',
onColumnFiltersChange,
})
+ const selectedGroups = useMemo(
+ () =>
+ (
+ (columnFilters.find((filter) => filter.id === 'group')
+ ?.value as string[]) || []
+ ).filter((group) => group !== 'all'),
+ [columnFilters]
+ )
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
+ const { data: groupsData } = useQuery({
+ queryKey: ['user-groups'],
+ queryFn: getUserGroups,
+ })
+ const groupFilterOptions = useMemo(
+ () => [
+ { label: t('All Groups'), value: 'all' },
+ ...Object.keys(groupsData?.data || {}).map((group) => ({
+ label: group,
+ value: group,
+ })),
+ ],
+ [groupsData, t]
+ )
+
// Fetch data with React Query
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
@@ -268,6 +299,7 @@ export function ApiKeysTable() {
pagination.pageSize,
globalFilter,
tokenFilter,
+ selectedGroups,
refreshTrigger,
],
queryFn: async () => {
@@ -275,10 +307,12 @@ export function ApiKeysTable() {
? await searchApiKeys({
keyword: globalFilter,
token: tokenFilter,
+ groups: selectedGroups,
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
: await getApiKeys({
+ groups: selectedGroups,
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
@@ -369,6 +403,13 @@ export function ApiKeysTable() {
options: API_KEY_STATUS_OPTIONS,
singleSelect: true,
},
+ {
+ columnId: 'group',
+ title: t('Group'),
+ options: groupFilterOptions,
+ allOptionValue: 'all',
+ showCounts: false,
+ },
],
}}
mobile={
diff --git a/web/src/features/keys/types.ts b/web/src/features/keys/types.ts
index 78bc16d09..674e074f2 100644
--- a/web/src/features/keys/types.ts
+++ b/web/src/features/keys/types.ts
@@ -61,6 +61,7 @@ export interface ApiResponse {
}
export interface GetApiKeysParams {
+ groups?: string[]
p?: number
size?: number
}
@@ -79,6 +80,7 @@ export interface GetApiKeysResponse {
export interface SearchApiKeysParams {
keyword?: string
token?: string
+ groups?: string[]
p?: number
size?: number
}
diff --git a/web/src/routes/_authenticated/keys/index.tsx b/web/src/routes/_authenticated/keys/index.tsx
index 14d885cd7..3deb63265 100644
--- a/web/src/routes/_authenticated/keys/index.tsx
+++ b/web/src/routes/_authenticated/keys/index.tsx
@@ -29,6 +29,7 @@ const apiKeySearchSchema = z.object({
.array(z.enum(API_KEY_STATUS_OPTIONS.map((s) => s.value as `${number}`)))
.optional()
.catch([]),
+ group: z.array(z.string()).optional().catch([]),
filter: z.string().optional().catch(''),
token: z.string().optional().catch(''),
})