diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 29b6cfd6..1d3e39e8 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "math" + "regexp" + "sort" "strings" "unsafe" @@ -34,6 +36,9 @@ var ( patternEmptyTextSpaced = []byte(`"text": ""`) patternEmptyTextSp1 = []byte(`"text" : ""`) patternEmptyTextSp2 = []byte(`"text" :""`) + + sessionUserAgentProductPattern = regexp.MustCompile(`([A-Za-z0-9._-]+)/[A-Za-z0-9._-]+`) + sessionUserAgentVersionPattern = regexp.MustCompile(`\bv?\d+(?:\.\d+){1,3}\b`) ) // SessionContext 粘性会话上下文,用于区分不同来源的请求。 @@ -75,6 +80,49 @@ type ParsedRequest struct { OnUpstreamAccepted func() } +// NormalizeSessionUserAgent reduces UA noise for sticky-session and digest hashing. +// It preserves the set of product names from Product/Version tokens while +// discarding version-only changes and incidental comments. +func NormalizeSessionUserAgent(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + matches := sessionUserAgentProductPattern.FindAllStringSubmatch(raw, -1) + if len(matches) == 0 { + return normalizeSessionUserAgentFallback(raw) + } + + products := make([]string, 0, len(matches)) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + product := strings.ToLower(strings.TrimSpace(match[1])) + if product == "" { + continue + } + if _, exists := seen[product]; exists { + continue + } + seen[product] = struct{}{} + products = append(products, product) + } + if len(products) == 0 { + return normalizeSessionUserAgentFallback(raw) + } + sort.Strings(products) + return strings.Join(products, "+") +} + +func normalizeSessionUserAgentFallback(raw string) string { + normalized := strings.ToLower(strings.Join(strings.Fields(raw), " ")) + normalized = sessionUserAgentVersionPattern.ReplaceAllString(normalized, "") + return strings.Join(strings.Fields(normalized), " ") +} + // ParseGatewayRequest 解析网关请求体并返回结构化结果。 // protocol 指定请求协议格式(domain.PlatformAnthropic / domain.PlatformGemini), // 不同协议使用不同的 system/messages 字段名。 diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 781e6a01..9dd39276 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -658,7 +658,7 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string { if parsed.SessionContext != nil { _, _ = combined.WriteString(parsed.SessionContext.ClientIP) _, _ = combined.WriteString(":") - _, _ = combined.WriteString(parsed.SessionContext.UserAgent) + _, _ = combined.WriteString(NormalizeSessionUserAgent(parsed.SessionContext.UserAgent)) _, _ = combined.WriteString(":") _, _ = combined.WriteString(strconv.FormatInt(parsed.SessionContext.APIKeyID, 10)) _, _ = combined.WriteString("|") diff --git a/backend/internal/service/generate_session_hash_test.go b/backend/internal/service/generate_session_hash_test.go index f91fb4c9..39679c3d 100644 --- a/backend/internal/service/generate_session_hash_test.go +++ b/backend/internal/service/generate_session_hash_test.go @@ -504,6 +504,48 @@ func TestGenerateSessionHash_SessionContext_UADifference(t *testing.T) { require.NotEqual(t, h1, h2, "different User-Agent should produce different hash") } +func TestGenerateSessionHash_SessionContext_UAVersionNoiseIgnored(t *testing.T) { + svc := &GatewayService{} + + base := func(ua string) *ParsedRequest { + return &ParsedRequest{ + Messages: []any{ + map[string]any{"role": "user", "content": "test"}, + }, + SessionContext: &SessionContext{ + ClientIP: "1.1.1.1", + UserAgent: ua, + APIKeyID: 1, + }, + } + } + + h1 := svc.GenerateSessionHash(base("Mozilla/5.0 codex_cli_rs/0.1.0")) + h2 := svc.GenerateSessionHash(base("Mozilla/5.0 codex_cli_rs/0.1.1")) + require.Equal(t, h1, h2, "version-only User-Agent changes should not perturb the sticky session hash") +} + +func TestGenerateSessionHash_SessionContext_FreeformUAVersionNoiseIgnored(t *testing.T) { + svc := &GatewayService{} + + base := func(ua string) *ParsedRequest { + return &ParsedRequest{ + Messages: []any{ + map[string]any{"role": "user", "content": "test"}, + }, + SessionContext: &SessionContext{ + ClientIP: "1.1.1.1", + UserAgent: ua, + APIKeyID: 1, + }, + } + } + + h1 := svc.GenerateSessionHash(base("Codex CLI 0.1.0")) + h2 := svc.GenerateSessionHash(base("Codex CLI 0.1.1")) + require.Equal(t, h1, h2, "free-form version-only User-Agent changes should not perturb the sticky session hash") +} + func TestGenerateSessionHash_SessionContext_APIKeyIDDifference(t *testing.T) { svc := &GatewayService{}