Files
MyGoNavi/internal/ai/provider/request_log.go
Syngnat 440172aae8 feat(ai): 记录 Claude CLI 上游请求入参日志
- 统一 Claude CLI 聊天请求写入 AI 上游请求日志

- 记录脱敏后的 prompt、参数、模型和工具名称

- 补充 CLI 上游日志解析与脱敏测试
2026-06-11 13:04:59 +08:00

228 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package provider
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
"GoNavi-Wails/internal/logger"
)
const (
aiUpstreamStringPreviewLimit = 4096
aiUpstreamBodyPreviewLimit = 24000
)
var (
secretLikeValuePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)bearer\s+[a-z0-9._~+/=-]{8,}`),
regexp.MustCompile(`(?i)\bsk-[a-z0-9._-]{8,}`),
regexp.MustCompile(`(?i)\bgh[pousr]_[a-z0-9_]{8,}`),
regexp.MustCompile(`(?i)\b(xox[baprs]-[a-z0-9-]{8,})`),
}
dataURIValuePattern = regexp.MustCompile(`^data:([^;,]+);base64,`)
)
type aiUpstreamRequestLogHandle struct {
id string
provider string
started time.Time
endpoint string
}
func newAIUpstreamRequestLogID(providerName string) string {
normalized := strings.ToLower(strings.TrimSpace(providerName))
normalized = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(normalized, "-")
normalized = strings.Trim(normalized, "-")
if normalized == "" {
normalized = "provider"
}
return fmt.Sprintf("%s-%d", normalized, time.Now().UnixNano())
}
func logAIUpstreamRequestStart(providerName string, method string, endpoint string, body any) aiUpstreamRequestLogHandle {
handle := aiUpstreamRequestLogHandle{
id: newAIUpstreamRequestLogID(providerName),
provider: strings.TrimSpace(providerName),
started: time.Now(),
endpoint: sanitizeAIUpstreamURL(endpoint),
}
logger.Infof(
"AI 上游请求开始requestId=%s provider=%s method=%s endpoint=%s body=%s",
handle.id,
handle.provider,
strings.TrimSpace(method),
handle.endpoint,
formatAIUpstreamRequestLogBody(body),
)
return handle
}
func logAIUpstreamRequestFinish(handle aiUpstreamRequestLogHandle, statusCode int, err error) {
duration := time.Since(handle.started).Round(time.Millisecond)
if err != nil {
logger.Warnf(
"AI 上游请求失败requestId=%s provider=%s endpoint=%s duration=%s err=%v",
handle.id,
handle.provider,
handle.endpoint,
duration,
RedactAIUpstreamLogText(err.Error()),
)
return
}
if statusCode > 0 {
logger.Infof(
"AI 上游请求完成requestId=%s provider=%s endpoint=%s status=%d duration=%s",
handle.id,
handle.provider,
handle.endpoint,
statusCode,
duration,
)
return
}
logger.Infof(
"AI 上游请求完成requestId=%s provider=%s endpoint=%s duration=%s",
handle.id,
handle.provider,
handle.endpoint,
duration,
)
}
func formatAIUpstreamRequestLogBody(body any) string {
sanitized := sanitizeAIUpstreamLogValue(body)
bytes, err := json.Marshal(sanitized)
if err != nil {
return fmt.Sprintf(`{"marshalError":%q}`, err.Error())
}
text := string(bytes)
if len(text) <= aiUpstreamBodyPreviewLimit {
return text
}
return fmt.Sprintf("%s...[truncated %d chars]", text[:aiUpstreamBodyPreviewLimit], len(text)-aiUpstreamBodyPreviewLimit)
}
func RedactAIUpstreamLogText(value string) string {
return sanitizeAIUpstreamString(value)
}
func sanitizeAIUpstreamURL(rawURL string) string {
text := strings.TrimSpace(rawURL)
if text == "" {
return ""
}
parsed, err := url.Parse(text)
if err != nil {
return redactSecretLikeString(text)
}
query := parsed.Query()
for key := range query {
if isSensitiveFieldName(key) {
query.Set(key, "[REDACTED]")
}
}
parsed.RawQuery = query.Encode()
return redactSecretLikeString(parsed.String())
}
func sanitizeAIUpstreamLogValue(value any) any {
switch typed := value.(type) {
case nil:
return nil
case bool, float32, float64,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return typed
case json.Number:
return typed
case json.RawMessage:
var decoded any
if err := json.Unmarshal(typed, &decoded); err == nil {
return sanitizeAIUpstreamLogValue(decoded)
}
return sanitizeAIUpstreamString(string(typed))
case []byte:
return sanitizeAIUpstreamString(string(typed))
case string:
return sanitizeAIUpstreamString(typed)
case map[string]any:
return sanitizeAIUpstreamMap(typed)
case []any:
result := make([]any, 0, len(typed))
for _, item := range typed {
result = append(result, sanitizeAIUpstreamLogValue(item))
}
return result
}
bytes, err := json.Marshal(value)
if err != nil {
return sanitizeAIUpstreamString(fmt.Sprint(value))
}
var decoded any
if err := json.Unmarshal(bytes, &decoded); err != nil {
return sanitizeAIUpstreamString(string(bytes))
}
return sanitizeAIUpstreamLogValue(decoded)
}
func sanitizeAIUpstreamMap(input map[string]any) map[string]any {
result := make(map[string]any, len(input))
keys := make([]string, 0, len(input))
for key := range input {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if isSensitiveFieldName(key) {
result[key] = "[REDACTED]"
continue
}
result[key] = sanitizeAIUpstreamLogValue(input[key])
}
return result
}
func isSensitiveFieldName(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.NewReplacer("-", "", "_", "", ".", "").Replace(normalized)
if normalized == "" {
return false
}
if normalized == "key" || normalized == "apikey" || normalized == "xapikey" {
return true
}
return strings.Contains(normalized, "authorization") ||
strings.Contains(normalized, "token") ||
strings.Contains(normalized, "secret") ||
strings.Contains(normalized, "password") ||
strings.Contains(normalized, "passwd") ||
strings.Contains(normalized, "credential") ||
strings.Contains(normalized, "cookie")
}
func sanitizeAIUpstreamString(value string) string {
text := redactSecretLikeString(value)
if matches := dataURIValuePattern.FindStringSubmatch(text); len(matches) == 2 {
return fmt.Sprintf("data:%s;base64,[REDACTED %d chars]", matches[1], len(text))
}
if len(text) <= aiUpstreamStringPreviewLimit {
return text
}
return fmt.Sprintf("%s...[truncated %d chars]", text[:aiUpstreamStringPreviewLimit], len(text)-aiUpstreamStringPreviewLimit)
}
func redactSecretLikeString(value string) string {
result := value
for _, pattern := range secretLikeValuePatterns {
result = pattern.ReplaceAllString(result, "[REDACTED]")
}
return result
}