From 7c6eb3c7a6cc588e753fb04e72d109035d32e7da Mon Sep 17 00:00:00 2001 From: yang-nan-1 <1962155093@qq.com> Date: Mon, 13 Jul 2026 15:36:43 +0800 Subject: [PATCH 1/5] fix(tokens): use substring matching for searches --- controller/token_test.go | 34 ++++++++++++++++++++++++++++++++++ model/token.go | 29 ++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/controller/token_test.go b/controller/token_test.go index 28af3bb9c..ab7771c60 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -490,6 +490,40 @@ 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 TestGetTokenMasksKeyInResponse(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678") diff --git a/model/token.go b/model/token.go index bd2d5bf19..bacd0cfcc 100644 --- a/model/token.go +++ b/model/token.go @@ -154,6 +154,17 @@ 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) { @@ -171,7 +182,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,7 +190,7 @@ 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("令牌数量超过上限,仅允许精确搜索") } } @@ -187,18 +198,26 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi // 非空才加 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 } - baseQuery = baseQuery.Where(commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern) + keyCol := commonKeyCol + if keyCol == "" { + if common.UsingPostgreSQL { + keyCol = `"key"` + } else { + keyCol = "`key`" + } + } + baseQuery = baseQuery.Where(keyCol+" LIKE ? ESCAPE '!'", tokenPattern) } // 先查匹配总数(用于分页,受 maxTokens 上限保护,避免全表 COUNT) From 1a0d2f77f1781ccc87bce83c8011c5336c26253d Mon Sep 17 00:00:00 2001 From: yang-nan-1 <1962155093@qq.com> Date: Mon, 13 Jul 2026 15:44:04 +0800 Subject: [PATCH 2/5] feat(usage-logs): capture and display prompt audit details --- controller/log.go | 10 + controller/relay.go | 11 + model/token.go | 10 +- relay/channel/claude/usage_prompt_audit.go | 13 + relay/channel/openai/relay-openai.go | 2 + relay/channel/openai/usage_prompt_audit.go | 68 ++ relay/common/relay_info.go | 8 + service/text_quota.go | 1 + service/usage_prompt_audit.go | 847 +++++++++++++++++++++ service/usage_prompt_audit_test.go | 473 ++++++++++++ 10 files changed, 1434 insertions(+), 9 deletions(-) create mode 100644 relay/channel/claude/usage_prompt_audit.go create mode 100644 relay/channel/openai/usage_prompt_audit.go create mode 100644 service/usage_prompt_audit.go create mode 100644 service/usage_prompt_audit_test.go 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/model/token.go b/model/token.go index bacd0cfcc..f2d0b55ea 100644 --- a/model/token.go +++ b/model/token.go @@ -209,15 +209,7 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi if err != nil { return nil, 0, err } - keyCol := commonKeyCol - if keyCol == "" { - if common.UsingPostgreSQL { - keyCol = `"key"` - } else { - keyCol = "`key`" - } - } - baseQuery = baseQuery.Where(keyCol+" LIKE ? ESCAPE '!'", tokenPattern) + baseQuery = baseQuery.Where(commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern) } // 先查匹配总数(用于分页,受 maxTokens 上限保护,避免全表 COUNT) 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) +} From 5865ab7438e37641ddd33ee95dc597b526075efc Mon Sep 17 00:00:00 2001 From: yang-nan-1 <1962155093@qq.com> Date: Thu, 8 Oct 2026 14:16:20 +0800 Subject: [PATCH 3/5] chore(model): widen group columns to varchar(255) --- model/ability.go | 2 +- model/channel.go | 2 +- model/user.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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"` // 邀请剩余额度 From e1ddf794ee4617dad0a002f37a35419a619d97aa Mon Sep 17 00:00:00 2001 From: yang-nan-1 <1962155093@qq.com> Date: Thu, 8 Oct 2026 14:16:20 +0800 Subject: [PATCH 4/5] feat(tokens): filter API keys by group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植自 relaxcloud/main 的 API key 分组筛选功能: - GET /api/token/ 与 /api/token/search 支持重复 group 查询参数(多分组 IN 过滤) - keys 页工具栏新增分组 faceted filter(含 All Groups 清除项),选项来自 /api/user/self/groups - data-table faceted filter 组件新增 allOptionValue/showCounts - 修复 token controller 测试在 -run 隔离下因列名未初始化导致的失败 --- controller/token.go | 7 +- controller/token_test.go | 95 +++++++++++++++++++ model/token.go | 20 ++-- .../data-table/toolbar/faceted-filter.tsx | 67 +++++++++---- .../components/data-table/toolbar/toolbar.tsx | 4 + web/src/features/keys/api.ts | 11 ++- .../__tests__/api-key-listing.test.tsx | 9 +- .../keys/components/api-keys-table.tsx | 43 ++++++++- web/src/features/keys/types.ts | 2 + web/src/routes/_authenticated/keys/index.tsx | 1 + 10 files changed, 223 insertions(+), 36 deletions(-) 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 ab7771c60..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 { @@ -524,6 +529,96 @@ func TestSearchTokensTokenMatchesKeySubstring(t *testing.T) { 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/token.go b/model/token.go index f2d0b55ea..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 搜索模式。 @@ -167,7 +172,7 @@ func sanitizeContainsLikePattern(input string) (string, error) { 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 @@ -195,6 +200,9 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi } baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId) + if len(groups) > 0 { + baseQuery = baseQuery.Where(commonGroupCol+" IN ?", groups) + } // 非空才加 LIKE 条件,空则跳过(不过滤该字段) if keyword != "" { 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(''), }) From 8c1d91863360563c7bc099b79bbf25193c29f7e5 Mon Sep 17 00:00:00 2001 From: yang-nan-1 <1962155093@qq.com> Date: Thu, 8 Oct 2026 14:24:00 +0800 Subject: [PATCH 5/5] chore: set release version to v1.0.0-rc.41 --- VERSION | 1 + 1 file changed, 1 insertion(+) diff --git a/VERSION b/VERSION index e69de29bb..50935e9ab 100644 --- a/VERSION +++ b/VERSION @@ -0,0 +1 @@ +v1.0.0-rc.41